Merge branch 'main' of github.com:grafana/grafana into scene-share-query-rethink
This commit is contained in:
+21
-58
@@ -1,5 +1,5 @@
|
||||
// BETTERER RESULTS V2.
|
||||
//
|
||||
//
|
||||
// If this file contains merge conflicts, use `betterer merge` to automatically resolve them:
|
||||
// https://phenomnomnominal.github.io/betterer/docs/results-file/#merge
|
||||
//
|
||||
@@ -293,17 +293,14 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"packages/grafana-data/src/types/fieldOverrides.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
|
||||
],
|
||||
"packages/grafana-data/src/types/flot.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
@@ -435,8 +432,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-data/src/utils/location.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-data/src/utils/url.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -578,10 +574,7 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"packages/grafana-runtime/src/utils/queryResponse.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
@@ -1184,21 +1177,13 @@ exports[`better eslint`] = {
|
||||
"public/app/core/utils/deferred.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/core/utils/explore.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/core/utils/fetch.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "5"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "8"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
],
|
||||
"public/app/core/utils/flatten.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
@@ -1214,12 +1199,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/core/utils/ticks.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "5"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/core/utils/tracing.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
@@ -1659,15 +1640,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/notification-policies/Policy.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "7"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "8"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/notification-policies/PromDurationDocs.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
@@ -2467,11 +2440,13 @@ exports[`better eslint`] = {
|
||||
"public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/utils/test-utils.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
@@ -4192,8 +4167,7 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/app/features/query/state/PanelQueryRunner.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/query/state/runRequest.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
@@ -4212,9 +4186,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
|
||||
],
|
||||
"public/app/features/sandbox/TestStuffPage.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/search/page/components/ActionRow.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
@@ -4316,9 +4287,7 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/app/features/storage/storage.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/features/teams/TeamGroupSync.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -4393,8 +4362,7 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/app/features/transformers/calculateHeatmap/heatmap.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
@@ -4406,9 +4374,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
@@ -4503,9 +4469,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/features/variables/adhoc/actions.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/variables/adhoc/picker/AdHocFilterRenderer.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
|
||||
+19
-19
@@ -185,13 +185,13 @@
|
||||
/devenv/docker/blocks/influxdb1/ @grafana/observability-metrics
|
||||
/devenv/docker/blocks/jaeger/ @grafana/observability-traces-and-profiling
|
||||
/devenv/docker/blocks/maildev/ @grafana/alerting-frontend
|
||||
/devenv/docker/blocks/mariadb/ @grafana/grafana-bi-squad
|
||||
/devenv/docker/blocks/mariadb/ @grafana/oss-big-tent
|
||||
/devenv/docker/blocks/memcached/ @grafana/backend-platform
|
||||
/devenv/docker/blocks/mimir_backend/ @grafana/alerting-backend-product
|
||||
/devenv/docker/blocks/mssql/ @grafana/grafana-bi-squad
|
||||
/devenv/docker/blocks/mssql_arm64/ @grafana/grafana-bi-squad
|
||||
/devenv/docker/blocks/mssql_tests/ @grafana/grafana-bi-squad
|
||||
/devenv/docker/blocks/mssql_tls/ @grafana/grafana-bi-squad
|
||||
/devenv/docker/blocks/mssql/ @grafana/partner-datasources
|
||||
/devenv/docker/blocks/mssql_arm64/ @grafana/partner-datasources
|
||||
/devenv/docker/blocks/mssql_tests/ @grafana/partner-datasources
|
||||
/devenv/docker/blocks/mssql_tls/ @grafana/partner-datasources
|
||||
/devenv/docker/blocks/mysql/ @grafana/oss-big-tent
|
||||
/devenv/docker/blocks/mysql_exporter/ @grafana/oss-big-tent
|
||||
/devenv/docker/blocks/mysql_opendata/ @grafana/oss-big-tent
|
||||
@@ -317,8 +317,8 @@
|
||||
/packages/grafana-ui/.storybook/ @grafana/plugins-platform-frontend
|
||||
/packages/grafana-ui/src/components/ @grafana/grafana-frontend-platform
|
||||
/packages/grafana-ui/src/components/DateTimePickers/ @grafana/grafana-frontend-platform
|
||||
/packages/grafana-ui/src/components/Table/ @grafana/grafana-bi-squad
|
||||
/packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/grafana-bi-squad @grafana/app-o11y-visualizations
|
||||
/packages/grafana-ui/src/components/Table/ @grafana/dataviz-squad
|
||||
/packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations
|
||||
/packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad
|
||||
/packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad
|
||||
/packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad
|
||||
@@ -333,7 +333,7 @@
|
||||
/packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad
|
||||
/packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad
|
||||
/packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend
|
||||
/packages/grafana-data/src/transformations/ @grafana/grafana-bi-squad
|
||||
/packages/grafana-data/src/transformations/ @grafana/dataviz-squad
|
||||
/packages/grafana-data/src/**/*logs* @grafana/observability-logs
|
||||
/packages/grafana-schema/src/**/*tempo* @grafana/observability-traces-and-profiling
|
||||
/packages/grafana-schema/src/**/*canvas* @grafana/dataviz-squad
|
||||
@@ -392,11 +392,11 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/public/app/features/connections/ @grafana/plugins-platform-frontend @mikkancso
|
||||
/public/app/features/correlations/ @grafana/explore-squad
|
||||
/public/app/features/dashboard/ @grafana/dashboards-squad
|
||||
/public/app/features/dashboard/components/TransformationsEditor/ @grafana/grafana-bi-squad
|
||||
/public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad
|
||||
/public/app/features/dashboard-scene/ @grafana/dashboards-squad
|
||||
/public/app/features/datasources/ @grafana/plugins-platform-frontend @mikkancso
|
||||
/public/app/features/dimensions/ @grafana/dataviz-squad
|
||||
/public/app/features/dataframe-import/ @grafana/grafana-bi-squad
|
||||
/public/app/features/dataframe-import/ @grafana/dataviz-squad
|
||||
/public/app/features/explore/ @grafana/explore-squad
|
||||
/public/app/features/expressions/ @grafana/observability-metrics
|
||||
/public/app/features/folders/ @grafana/grafana-frontend-platform
|
||||
@@ -423,8 +423,8 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/public/app/features/teams/ @grafana/identity-access-team
|
||||
/public/app/features/templating/ @grafana/dashboards-squad
|
||||
/public/app/features/trails/ @torkelo
|
||||
/public/app/features/transformers/ @grafana/grafana-bi-squad
|
||||
/public/app/features/transformers/timeSeriesTable/ @grafana/grafana-bi-squad @grafana/app-o11y-visualizations
|
||||
/public/app/features/transformers/ @grafana/dataviz-squad
|
||||
/public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations
|
||||
/public/app/features/users/ @grafana/identity-access-team
|
||||
/public/app/features/variables/ @grafana/dashboards-squad
|
||||
/public/app/plugins/panel/alertGroups/ @grafana/alerting-frontend
|
||||
@@ -434,7 +434,7 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/bargauge/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/dashlist/ @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/debug/ @ryantxu
|
||||
/public/app/plugins/panel/datagrid/ @grafana/grafana-bi-squad
|
||||
/public/app/plugins/panel/datagrid/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/gauge/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/gettingstarted/ @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/graph/ @grafana/dataviz-squad
|
||||
@@ -447,9 +447,9 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/piechart/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/state-timeline/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/status-history/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/table/ @grafana/grafana-bi-squad
|
||||
/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx @grafana/grafana-bi-squad @grafana/app-o11y-visualizations
|
||||
/public/app/plugins/panel/table-old/ @grafana/grafana-bi-squad
|
||||
/public/app/plugins/panel/table/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations
|
||||
/public/app/plugins/panel/table-old/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/timeseries/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/trend/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/geomap/ @grafana/dataviz-squad
|
||||
@@ -508,7 +508,7 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/scripts/circle-* @grafana/grafana-release-guild
|
||||
/scripts/publish-npm-packages.sh @grafana/grafana-release-guild @grafana/plugins-platform-frontend
|
||||
/scripts/validate-npm-packages.sh @grafana/grafana-release-guild @grafana/plugins-platform-frontend
|
||||
/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend @grafana/grafana-bi-squad
|
||||
/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend @grafana/dataviz-squad
|
||||
/scripts/cli/ @grafana/grafana-frontend-platform
|
||||
/scripts/clean-git-or-error.sh @grafana/grafana-as-code
|
||||
/scripts/grafana-server/ @grafana/grafana-frontend-platform
|
||||
@@ -528,7 +528,7 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/scripts/generate-icon-bundle.js @grafana/plugins-platform-frontend @grafana/grafana-frontend-platform
|
||||
/scripts/levitate-parse-json-report.js @grafana/plugins-platform-frontend
|
||||
|
||||
/scripts/docs/generate-transformations.ts @grafana/grafana-bi-squad
|
||||
/scripts/docs/generate-transformations.ts @grafana/dataviz-squad
|
||||
/scripts/webpack/ @grafana/frontend-ops
|
||||
/scripts/generate-a11y-report.sh @grafana/grafana-frontend-platform
|
||||
.pa11yci.conf.js @grafana/grafana-frontend-platform
|
||||
@@ -554,7 +554,7 @@ cypress.config.js @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/datasource/jaeger/ @grafana/observability-traces-and-profiling
|
||||
/public/app/plugins/datasource/loki/ @grafana/observability-logs
|
||||
/public/app/plugins/datasource/mixed/ @grafana/dashboards-squad
|
||||
/public/app/plugins/datasource/mssql/ @grafana/grafana-bi-squad
|
||||
/public/app/plugins/datasource/mssql/ @grafana/partner-datasources
|
||||
/public/app/plugins/datasource/mysql/ @grafana/oss-big-tent
|
||||
/public/app/plugins/datasource/opentsdb/ @grafana/observability-metrics
|
||||
/public/app/plugins/datasource/grafana-postgresql-datasource/ @grafana/oss-big-tent
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: Build and release core plugins
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -61,7 +63,12 @@ jobs:
|
||||
shell: bash
|
||||
id: get_dir
|
||||
run: |
|
||||
dir=$(find public/app/plugins -name ${{ inputs.plugin_id }} -print -quit)
|
||||
dir=$(dirname \
|
||||
$(egrep -lir --include=plugin.json --exclude-dir=dist \
|
||||
'"id": "${{ inputs.plugin_id }}"' \
|
||||
public/app/plugins \
|
||||
) \
|
||||
)
|
||||
echo "dir=${dir}" >> $GITHUB_OUTPUT
|
||||
- name: Install frontend dependencies
|
||||
shell: bash
|
||||
|
||||
@@ -369,3 +369,8 @@ To configure this feature, see the [introduction to exemplars][exemplars] docume
|
||||
[variable-syntax]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/variable-syntax"
|
||||
[variable-syntax]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/dashboards/variables/variable-syntax"
|
||||
{{% /docs/reference %}}
|
||||
|
||||
## Visualizing the dependency graph
|
||||
|
||||
If service dependency information is available in Jaeger, it can be visualized in Grafana.
|
||||
Use the Jaeger data source with the "Dependency Graph" query type on a Node Graph panel for this.
|
||||
|
||||
@@ -172,6 +172,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
|
||||
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
|
||||
| `enablePluginsTracingByDefault` | Enable plugin tracing for all external plugins |
|
||||
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Explore', () => {
|
||||
// This seems to be flaky on CI, skipping it for now.
|
||||
describe.skip('Explore', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ require (
|
||||
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae // @grafana/backend-platform
|
||||
github.com/yalue/merged_fs v1.2.2 // @grafana/grafana-as-code
|
||||
github.com/yudai/gojsondiff v1.0.0 // @grafana/backend-platform
|
||||
go.opentelemetry.io/collector/pdata v1.0.0-rc8 // @grafana/backend-platform
|
||||
go.opentelemetry.io/collector/pdata v1.0.1 // @grafana/backend-platform
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // @grafana/grafana-operator-experience-squad
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.10.0 // @grafana/backend-platform
|
||||
go.opentelemetry.io/otel/sdk v1.21.0 // @grafana/backend-platform
|
||||
|
||||
@@ -2997,8 +2997,8 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/collector/pdata v1.0.0-rc8 h1:vBikWdZFsRiT5dVsLQhnE99w3edM7eem3Q9dSqMlStE=
|
||||
go.opentelemetry.io/collector/pdata v1.0.0-rc8/go.mod h1:BVCBhWgclYCh7Oi6BkMiQfRa6MXv1uRTlKXuL5oBby8=
|
||||
go.opentelemetry.io/collector/pdata v1.0.1 h1:dGX2h7maA6zHbl5D3AsMnF1c3Nn+3EUftbVCLzeyNvA=
|
||||
go.opentelemetry.io/collector/pdata v1.0.1/go.mod h1:jutXeu0QOXYY8wcZ/hege+YAnSBP3+jpTqYU1+JTI5Y=
|
||||
go.opentelemetry.io/contrib v0.18.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
|
||||
|
||||
+11
-11
@@ -115,7 +115,7 @@
|
||||
"@types/history": "4.7.11",
|
||||
"@types/hoist-non-react-statics": "3.3.5",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/jquery": "3.5.16",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/jsurl": "^1.2.28",
|
||||
"@types/lodash": "4.14.202",
|
||||
@@ -144,7 +144,7 @@
|
||||
"@types/redux-mock-store": "1.0.6",
|
||||
"@types/semver": "7.5.6",
|
||||
"@types/slate": "0.47.11",
|
||||
"@types/slate-plain-serializer": "0.7.2",
|
||||
"@types/slate-plain-serializer": "0.7.5",
|
||||
"@types/slate-react": "0.22.9",
|
||||
"@types/systemjs": "6.13.1",
|
||||
"@types/testing-library__jest-dom": "5.14.9",
|
||||
@@ -154,7 +154,7 @@
|
||||
"@types/yargs": "17.0.32",
|
||||
"@typescript-eslint/eslint-plugin": "6.18.1",
|
||||
"@typescript-eslint/parser": "6.18.1",
|
||||
"autoprefixer": "10.4.14",
|
||||
"autoprefixer": "10.4.16",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-angularjs-annotate": "0.10.0",
|
||||
@@ -176,7 +176,7 @@
|
||||
"eslint": "8.52.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "27.6.0",
|
||||
"eslint-plugin-jest": "27.6.3",
|
||||
"eslint-plugin-jsdoc": "46.8.2",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-lodash": "7.4.0",
|
||||
@@ -185,7 +185,7 @@
|
||||
"eslint-webpack-plugin": "4.0.1",
|
||||
"expose-loader": "4.1.0",
|
||||
"fork-ts-checker-webpack-plugin": "8.0.0",
|
||||
"glob": "10.3.3",
|
||||
"glob": "10.3.10",
|
||||
"html-loader": "4.2.0",
|
||||
"html-webpack-plugin": "5.5.3",
|
||||
"http-server": "14.1.1",
|
||||
@@ -194,7 +194,7 @@
|
||||
"jest-canvas-mock": "2.5.2",
|
||||
"jest-date-mock": "1.0.8",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest-fail-on-console": "3.1.1",
|
||||
"jest-fail-on-console": "3.1.2",
|
||||
"jest-junit": "16.0.0",
|
||||
"jest-matcher-utils": "29.7.0",
|
||||
"lerna": "7.4.1",
|
||||
@@ -330,14 +330,14 @@
|
||||
"hoist-non-react-statics": "3.3.2",
|
||||
"i18next": "^22.0.0",
|
||||
"i18next-browser-languagedetector": "^7.0.2",
|
||||
"immer": "10.0.2",
|
||||
"immutable": "4.3.1",
|
||||
"jquery": "3.7.0",
|
||||
"immer": "10.0.3",
|
||||
"immutable": "4.3.4",
|
||||
"jquery": "3.7.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-markup": "^1.1.0",
|
||||
"json-source-map": "0.6.1",
|
||||
"jsurl": "^0.1.5",
|
||||
"kbar": "0.1.0-beta.44",
|
||||
"kbar": "0.1.0-beta.45",
|
||||
"leven": "^4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"logfmt": "^1.3.2",
|
||||
@@ -345,7 +345,7 @@
|
||||
"lru-memoize": "^1.1.0",
|
||||
"lucene": "^2.1.1",
|
||||
"marked": "5.1.1",
|
||||
"marked-mangle": "1.1.0",
|
||||
"marked-mangle": "1.1.6",
|
||||
"memoize-one": "6.0.0",
|
||||
"ml-regression-polynomial": "^3.0.0",
|
||||
"ml-regression-simple-linear": "^3.0.0",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"history": "4.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "5.1.1",
|
||||
"marked-mangle": "1.1.0",
|
||||
"marked-mangle": "1.1.6",
|
||||
"moment": "2.29.4",
|
||||
"moment-timezone": "0.5.43",
|
||||
"ol": "7.4.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@types/dompurify": "^2",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/jquery": "3.5.16",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/lodash": "4.14.202",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/node": "20.8.10",
|
||||
|
||||
@@ -174,4 +174,5 @@ export interface FeatureToggles {
|
||||
enablePluginsTracingByDefault?: boolean;
|
||||
cloudRBACRoles?: boolean;
|
||||
alertingQueryOptimization?: boolean;
|
||||
newFolderPicker?: boolean;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,7 @@ export interface SystemConfigOverrideRule extends ConfigOverrideRule {
|
||||
*/
|
||||
export function isSystemOverrideWithRef<T extends SystemConfigOverrideRule>(ref: string) {
|
||||
return (override: ConfigOverrideRule): override is T => {
|
||||
const overrideAs = override as T;
|
||||
return overrideAs.__systemRef === ref;
|
||||
return '__systemRef' in override && override.__systemRef === ref;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +57,7 @@ export function isSystemOverrideWithRef<T extends SystemConfigOverrideRule>(ref:
|
||||
* @internal
|
||||
*/
|
||||
export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => {
|
||||
return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string';
|
||||
return '__systemRef' in override && typeof override.__systemRef === 'string';
|
||||
};
|
||||
|
||||
export interface FieldConfigSource<TOptions = any> {
|
||||
@@ -69,7 +68,7 @@ export interface FieldConfigSource<TOptions = any> {
|
||||
overrides: ConfigOverrideRule[];
|
||||
}
|
||||
|
||||
export interface FieldOverrideContext extends StandardEditorContext<any, any> {
|
||||
export interface FieldOverrideContext extends StandardEditorContext<any> {
|
||||
field?: Field;
|
||||
dataFrameIndex?: number; // The index for the selected field frame
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface NavLinkDTO {
|
||||
pluginId?: string;
|
||||
// Whether the page is used to create a new resource. We may place these in a different position in the UI.
|
||||
isCreateAction?: boolean;
|
||||
// Optional keywords to match on when searching (e.g. in the CommandPalette)
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface NavModelItem extends NavLinkDTO {
|
||||
|
||||
@@ -69,7 +69,7 @@ const assureBaseUrl = (url: string): string => {
|
||||
* @param searchParamsToUpdate
|
||||
* @returns
|
||||
*/
|
||||
const getUrlForPartial = (location: Location<any>, searchParamsToUpdate: UrlQueryMap) => {
|
||||
const getUrlForPartial = (location: Location, searchParamsToUpdate: UrlQueryMap) => {
|
||||
const searchParams = urlUtil.parseKeyValue(
|
||||
location.search.startsWith('?') ? location.search.substring(1) : location.search
|
||||
);
|
||||
|
||||
@@ -329,6 +329,9 @@ export const Pages = {
|
||||
UsersListPage: {
|
||||
container: 'data-testid users-list-page',
|
||||
},
|
||||
UserAnonListPage: {
|
||||
container: 'data-testid user-anon-list-page',
|
||||
},
|
||||
UsersListPublicDashboardsPage: {
|
||||
container: 'data-testid users-list-public-dashboards-page',
|
||||
DashboardsListModal: {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"eslint-webpack-plugin": "4.0.1",
|
||||
"fork-ts-checker-webpack-plugin": "8.0.0",
|
||||
"glob": "10.3.3",
|
||||
"glob": "10.3.10",
|
||||
"replace-in-file-webpack-plugin": "1.0.6",
|
||||
"swc-loader": "0.2.3",
|
||||
"webpack": "5.89.0"
|
||||
|
||||
@@ -166,6 +166,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
|
||||
tokenExpirationDayLimit: undefined;
|
||||
disableFrontendSandboxForPlugins: string[] = [];
|
||||
sharedWithMeFolderUID: string | undefined;
|
||||
|
||||
constructor(options: GrafanaBootConfig) {
|
||||
this.bootData = options.bootData;
|
||||
|
||||
@@ -268,7 +268,7 @@ class DataSourceWithBackend<
|
||||
* Apply template variables for explore
|
||||
*/
|
||||
interpolateVariablesInQueries(queries: TQuery[], scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): TQuery[] {
|
||||
return queries.map((q) => this.applyTemplateVariables(q, scopedVars, filters) as TQuery);
|
||||
return queries.map((q) => this.applyTemplateVariables(q, scopedVars, filters));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,7 +290,7 @@ class DataSourceWithBackend<
|
||||
*
|
||||
* @virtual
|
||||
*/
|
||||
applyTemplateVariables(query: TQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): Record<string, any> {
|
||||
applyTemplateVariables(query: TQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) {
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ class DataSourceWithBackend<
|
||||
/**
|
||||
* Send a POST request to the datasource resource path
|
||||
*/
|
||||
async postResource<T = any>(
|
||||
async postResource<T = unknown>(
|
||||
path: string,
|
||||
data?: BackendSrvRequest['data'],
|
||||
options?: Partial<BackendSrvRequest>
|
||||
|
||||
@@ -72,9 +72,9 @@
|
||||
"hoist-non-react-statics": "3.3.2",
|
||||
"i18next": "^22.0.0",
|
||||
"i18next-browser-languagedetector": "^7.0.2",
|
||||
"immutable": "4.3.1",
|
||||
"immutable": "4.3.4",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jquery": "3.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"lodash": "4.17.21",
|
||||
"micro-memoize": "^4.1.2",
|
||||
"moment": "2.29.4",
|
||||
@@ -140,7 +140,7 @@
|
||||
"@types/hoist-non-react-statics": "3.3.5",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/jquery": "3.5.16",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/lodash": "4.14.202",
|
||||
"@types/mock-raf": "1.0.6",
|
||||
"@types/node": "20.8.10",
|
||||
@@ -157,7 +157,7 @@
|
||||
"@types/react-transition-group": "4.4.10",
|
||||
"@types/react-window": "1.8.5",
|
||||
"@types/slate": "0.47.11",
|
||||
"@types/slate-plain-serializer": "0.7.2",
|
||||
"@types/slate-plain-serializer": "0.7.5",
|
||||
"@types/slate-react": "0.22.9",
|
||||
"@types/testing-library__jest-dom": "5.14.9",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
@@ -165,7 +165,7 @@
|
||||
"common-tags": "1.8.2",
|
||||
"core-js": "3.33.0",
|
||||
"css-loader": "6.8.1",
|
||||
"csstype": "3.1.2",
|
||||
"csstype": "3.1.3",
|
||||
"esbuild": "0.18.12",
|
||||
"expose-loader": "4.1.0",
|
||||
"mock-raf": "1.0.1",
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
"datagridpanelcfg": {
|
||||
"category": "composable",
|
||||
"codeowners": [
|
||||
"grafana/grafana-bi-squad"
|
||||
"grafana/dataviz-squad"
|
||||
],
|
||||
"currentVersion": [
|
||||
0,
|
||||
@@ -1731,7 +1731,7 @@
|
||||
"tablepanelcfg": {
|
||||
"category": "composable",
|
||||
"codeowners": [
|
||||
"grafana/grafana-bi-squad"
|
||||
"grafana/dataviz-squad"
|
||||
],
|
||||
"currentVersion": [
|
||||
0,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
)
|
||||
@@ -32,6 +33,30 @@ type Device struct {
|
||||
UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type DeviceSearchHitDTO struct {
|
||||
DeviceID string `json:"deviceId" xorm:"device_id" db:"device_id"`
|
||||
ClientIP string `json:"clientIp" xorm:"client_ip" db:"client_ip"`
|
||||
UserAgent string `json:"userAgent" xorm:"user_agent" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"createdAt" xorm:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
type SearchDeviceQueryResult struct {
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Devices []*DeviceSearchHitDTO `json:"devices"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
type SearchDeviceQuery struct {
|
||||
Query string
|
||||
Page int
|
||||
Limit int
|
||||
From time.Time
|
||||
To time.Time
|
||||
SortOpts []model.SortOption
|
||||
}
|
||||
|
||||
func (a *Device) CacheKey() string {
|
||||
return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":")
|
||||
}
|
||||
@@ -47,6 +72,8 @@ type AnonStore interface {
|
||||
DeleteDevice(ctx context.Context, deviceID string) error
|
||||
// DeleteDevicesOlderThan deletes all devices that have no been updated since the given time.
|
||||
DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error
|
||||
// SearchDevices searches for devices within the 30 days active.
|
||||
SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error)
|
||||
}
|
||||
|
||||
func ProvideAnonDBStore(sqlStore db.DB, deviceLimit int64) *AnonDBStore {
|
||||
@@ -183,3 +210,64 @@ func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AnonDBStore) SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) {
|
||||
result := SearchDeviceQueryResult{
|
||||
Devices: make([]*DeviceSearchHitDTO, 0),
|
||||
}
|
||||
err := s.sqlStore.WithDbSession(ctx, func(dbSess *db.Session) error {
|
||||
if query.From.IsZero() && !query.To.IsZero() {
|
||||
return fmt.Errorf("from date must be set if to date is set")
|
||||
}
|
||||
if !query.From.IsZero() && query.To.IsZero() {
|
||||
return fmt.Errorf("to date must be set if from date is set")
|
||||
}
|
||||
|
||||
// restricted only to last 30 days, if noting else specified
|
||||
if query.From.IsZero() && query.To.IsZero() {
|
||||
query.From = time.Now().Add(-anonymousDeviceExpiration)
|
||||
query.To = time.Now()
|
||||
}
|
||||
|
||||
sess := dbSess.Table("anon_device").Alias("d")
|
||||
|
||||
if query.Limit > 0 {
|
||||
offset := query.Limit * (query.Page - 1)
|
||||
sess.Limit(query.Limit, offset)
|
||||
}
|
||||
sess.Cols("d.id", "d.device_id", "d.client_ip", "d.user_agent", "d.updated_at")
|
||||
|
||||
if len(query.SortOpts) > 0 {
|
||||
for i := range query.SortOpts {
|
||||
for j := range query.SortOpts[i].Filter {
|
||||
sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sess.Asc("d.user_agent")
|
||||
}
|
||||
|
||||
// add to query about from and to session
|
||||
sess.Where("d.updated_at BETWEEN ? AND ?", query.From.UTC(), query.To.UTC())
|
||||
|
||||
if query.Query != "" {
|
||||
queryWithWildcards := "%" + strings.Replace(query.Query, "\\", "", -1) + "%"
|
||||
sess.Where("d.client_ip "+s.sqlStore.GetDialect().LikeStr()+" ?", queryWithWildcards)
|
||||
}
|
||||
|
||||
// get total
|
||||
devices, err := s.ListDevices(ctx, &query.From, &query.To)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// cast to int64
|
||||
result.TotalCount = int64(len(devices))
|
||||
if err := sess.Find(&result.Devices); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Page = query.Page
|
||||
result.PerPage = query.Limit
|
||||
return nil
|
||||
})
|
||||
return &result, err
|
||||
}
|
||||
|
||||
@@ -19,3 +19,7 @@ func (s *FakeAnonStore) CreateOrUpdateDevice(ctx context.Context, device *Device
|
||||
func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *FakeAnonStore) SearchDevices(ctx context.Context, query SearchDeviceQuery) (*SearchDeviceQueryResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/sortopts"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -50,6 +51,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() {
|
||||
auth := accesscontrol.Middleware(api.accesscontrol)
|
||||
api.RouterRegister.Group("/api/anonymous", func(anonRoutes routing.RouteRegister) {
|
||||
anonRoutes.Get("/devices", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.ListDevices))
|
||||
anonRoutes.Get("/search", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.SearchDevices))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,8 +91,60 @@ func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) respons
|
||||
return response.JSON(http.StatusOK, resDevices)
|
||||
}
|
||||
|
||||
// swagger:route POST /search devices SearchDevices
|
||||
//
|
||||
// # Lists all devices within the last 30 days
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Responses:
|
||||
//
|
||||
// 200: devicesSearchResponse
|
||||
// 401: unauthorisedError
|
||||
// 403: forbiddenError
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (api *AnonDeviceServiceAPI) SearchDevices(c *contextmodel.ReqContext) response.Response {
|
||||
perPage := c.QueryInt("perpage")
|
||||
if perPage <= 0 {
|
||||
perPage = 100
|
||||
}
|
||||
page := c.QueryInt("page")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
searchQuery := c.Query("query")
|
||||
|
||||
sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort"))
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err)
|
||||
}
|
||||
|
||||
// TODO: potential add from and to time to query
|
||||
query := &anonstore.SearchDeviceQuery{
|
||||
Query: searchQuery,
|
||||
Page: page,
|
||||
Limit: perPage,
|
||||
SortOpts: sortOpts,
|
||||
}
|
||||
results, err := api.store.SearchDevices(c.Req.Context(), query)
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err)
|
||||
}
|
||||
return response.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// swagger:response devicesResponse
|
||||
type DevicesResponse struct {
|
||||
// in:body
|
||||
Body []deviceDTO `json:"body"`
|
||||
}
|
||||
|
||||
// swagger:response devicesSearchResponse
|
||||
type DevicesSearchResponse struct {
|
||||
// in:body
|
||||
Body anonstore.SearchDeviceQueryResult `json:"body"`
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request
|
||||
// ListDevices returns all devices that have been updated between the given times.
|
||||
func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*anonstore.Device, error) {
|
||||
if !a.cfg.AnonymousEnabled {
|
||||
a.log.Debug("Anonymous access is disabled, returning empty result")
|
||||
return []*anonstore.Device{}, nil
|
||||
}
|
||||
|
||||
@@ -163,12 +164,21 @@ func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to
|
||||
// CountDevices returns the number of devices that have been updated between the given times.
|
||||
func (a *AnonDeviceService) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
|
||||
if !a.cfg.AnonymousEnabled {
|
||||
a.log.Debug("Anonymous access is disabled, returning empty result")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return a.anonStore.CountDevices(ctx, from, to)
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) SearchDevices(ctx context.Context, query *anonstore.SearchDeviceQuery) (*anonstore.SearchDeviceQueryResult, error) {
|
||||
if !a.cfg.AnonymousEnabled {
|
||||
a.log.Debug("Anonymous access is disabled, returning empty result")
|
||||
return nil, nil
|
||||
}
|
||||
return a.anonStore.SearchDevices(ctx, query)
|
||||
}
|
||||
|
||||
func (a *AnonDeviceService) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(2 * time.Hour)
|
||||
|
||||
|
||||
@@ -177,3 +177,86 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
|
||||
|
||||
assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64))
|
||||
}
|
||||
|
||||
func TestIntegrationDeviceService_SearchDevice(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
insertDevices []*anonstore.Device
|
||||
searchQuery anonstore.SearchDeviceQuery
|
||||
expectedCount int
|
||||
expectedDevice *anonstore.Device
|
||||
}{
|
||||
{
|
||||
name: "two devices and limit set to 1",
|
||||
insertDevices: []*anonstore.Device{
|
||||
{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "",
|
||||
UserAgent: "test",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
{
|
||||
DeviceID: "32mdo31deeqwes2",
|
||||
ClientIP: "",
|
||||
UserAgent: "test2",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
searchQuery: anonstore.SearchDeviceQuery{
|
||||
Query: "",
|
||||
Page: 1,
|
||||
Limit: 1,
|
||||
},
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "two devices and search for client ip 192.1",
|
||||
insertDevices: []*anonstore.Device{
|
||||
{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "192.168.0.2:10",
|
||||
UserAgent: "",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
{
|
||||
DeviceID: "32mdo31deeqwes2",
|
||||
ClientIP: "192.268.1.3:200",
|
||||
UserAgent: "",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
searchQuery: anonstore.SearchDeviceQuery{
|
||||
Query: "192.1",
|
||||
Page: 1,
|
||||
Limit: 50,
|
||||
},
|
||||
expectedCount: 1,
|
||||
expectedDevice: &anonstore.Device{
|
||||
DeviceID: "32mdo31deeqwes",
|
||||
ClientIP: "192.168.0.2:10",
|
||||
UserAgent: "",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
}
|
||||
store := db.InitTestDB(t)
|
||||
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
|
||||
&authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{})
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, device := range tc.insertDevices {
|
||||
err := anonService.anonStore.CreateOrUpdateDevice(context.Background(), device)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
devices, err := anonService.anonStore.SearchDevices(context.Background(), &tc.searchQuery)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, devices.Devices, tc.expectedCount)
|
||||
if tc.expectedDevice != nil {
|
||||
device := devices.Devices[0]
|
||||
require.Equal(t, tc.expectedDevice.UserAgent, device.UserAgent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package sortopts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
// SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s)
|
||||
SortOptionsByQueryParam = map[string]model.SortOption{
|
||||
"userAgent-asc": newSortOption("user_agent", false, 0),
|
||||
"userAgent-desc": newSortOption("user_agent", true, 0),
|
||||
"updatedAt-asc": newTimeSortOption("updated_at", false, 1),
|
||||
"updatedAt-desc": newTimeSortOption("updated_at", true, 2),
|
||||
}
|
||||
|
||||
ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option")
|
||||
)
|
||||
|
||||
type Sorter struct {
|
||||
Field string
|
||||
LowerCase bool
|
||||
Descending bool
|
||||
WithTableName bool
|
||||
}
|
||||
|
||||
func (s Sorter) OrderBy() string {
|
||||
orderBy := "anon_device."
|
||||
if !s.WithTableName {
|
||||
orderBy = ""
|
||||
}
|
||||
orderBy += s.Field
|
||||
if s.LowerCase {
|
||||
orderBy = fmt.Sprintf("LOWER(%v)", orderBy)
|
||||
}
|
||||
if s.Descending {
|
||||
return orderBy + " DESC"
|
||||
}
|
||||
return orderBy + " ASC"
|
||||
}
|
||||
|
||||
func newSortOption(field string, desc bool, index int) model.SortOption {
|
||||
direction := "asc"
|
||||
description := ("A-Z")
|
||||
if desc {
|
||||
direction = "desc"
|
||||
description = ("Z-A")
|
||||
}
|
||||
return model.SortOption{
|
||||
Name: fmt.Sprintf("%v-%v", field, direction),
|
||||
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
|
||||
Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction),
|
||||
Index: index,
|
||||
Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}},
|
||||
}
|
||||
}
|
||||
|
||||
func newTimeSortOption(field string, desc bool, index int) model.SortOption {
|
||||
direction := "asc"
|
||||
description := ("Oldest-Newest")
|
||||
if desc {
|
||||
direction = "desc"
|
||||
description = ("Newest-Oldest")
|
||||
}
|
||||
return model.SortOption{
|
||||
Name: fmt.Sprintf("%v-%v", field, direction),
|
||||
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
|
||||
Description: fmt.Sprintf("Sort %v by time in an %vending order", field, direction),
|
||||
Index: index,
|
||||
Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s)
|
||||
func ParseSortQueryParam(param string) ([]model.SortOption, error) {
|
||||
opts := []model.SortOption{}
|
||||
if param != "" {
|
||||
optsStr := strings.Split(param, ",")
|
||||
for i := range optsStr {
|
||||
if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok {
|
||||
return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i])
|
||||
} else {
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
}
|
||||
sort.Slice(opts, func(i, j int) bool {
|
||||
return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name)
|
||||
})
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
@@ -1323,5 +1323,13 @@ var (
|
||||
AllowSelfServe: false,
|
||||
Created: time.Date(2024, time.January, 10, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Name: "newFolderPicker",
|
||||
Description: "Enables the nested folder picker without having nested folders enabled",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
FrontendOnly: true,
|
||||
Created: time.Date(2024, time.January, 12, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -155,3 +155,4 @@ alertingPreviewUpgrade,experimental,@grafana/alerting-squad,2024-01-03,false,fal
|
||||
enablePluginsTracingByDefault,experimental,@grafana/plugins-platform-backend,2024-01-09,false,false,true,false
|
||||
cloudRBACRoles,experimental,@grafana/identity-access-team,2024-01-10,false,false,true,false
|
||||
alertingQueryOptimization,GA,@grafana/alerting-squad,2024-01-10,false,false,false,false
|
||||
newFolderPicker,experimental,@grafana/grafana-frontend-platform,2024-01-12,false,false,false,true
|
||||
|
||||
|
@@ -630,4 +630,8 @@ const (
|
||||
// FlagAlertingQueryOptimization
|
||||
// Optimizes eligible queries in order to reduce load on datasources
|
||||
FlagAlertingQueryOptimization = "alertingQueryOptimization"
|
||||
|
||||
// FlagNewFolderPicker
|
||||
// Enables the nested folder picker without having nested folders enabled
|
||||
FlagNewFolderPicker = "newFolderPicker"
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ type NavLink struct {
|
||||
EmptyMessageId string `json:"emptyMessageId,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page)
|
||||
IsCreateAction bool `json:"isCreateAction,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
}
|
||||
|
||||
func (node *NavLink) Sort() {
|
||||
|
||||
@@ -500,6 +500,7 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
|
||||
SubTitle: "Browse and create new connections",
|
||||
Url: baseUrl + "/add-new-connection",
|
||||
Children: []*navtree.NavLink{},
|
||||
Keywords: []string{"csv", "graphite", "json", "loki", "prometheus", "sql", "tempo"},
|
||||
})
|
||||
|
||||
// Data sources
|
||||
|
||||
@@ -105,7 +105,8 @@ func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFacto
|
||||
if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() {
|
||||
driverName, err = createPostgresProxyDriver(cnnstr, proxyOpts)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
logger.Error("postgres proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("postgres proxy creation failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9452,6 +9452,33 @@
|
||||
"$ref": "#/responses/internalServerError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"devices"
|
||||
],
|
||||
"summary": "Lists all devices within the last 30 days",
|
||||
"operationId": "SearchDevices",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/devicesSearchResponse"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/responses/unauthorisedError"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbiddenError"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFoundError"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/responses/internalServerError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/search/sorting": {
|
||||
@@ -14263,6 +14290,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeviceSearchHitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clientIp": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastSeenAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DiscordConfig": {
|
||||
"type": "object",
|
||||
"title": "DiscordConfig configures notifications via Discord.",
|
||||
@@ -19229,6 +19282,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SearchDeviceQueryResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/DeviceSearchHitDTO"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"perPage": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"totalCount": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SearchOrgServiceAccountsResult": {
|
||||
"description": "swagger: model",
|
||||
"type": "object",
|
||||
@@ -22431,6 +22507,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"devicesSearchResponse": {
|
||||
"description": "(empty)",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/SearchDeviceQueryResult"
|
||||
}
|
||||
},
|
||||
"folderResponse": {
|
||||
"description": "(empty)",
|
||||
"schema": {
|
||||
|
||||
@@ -131,7 +131,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
dockedMegaMenu: css({
|
||||
background: theme.colors.background.primary,
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
display: 'none',
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function AppChromeMenu({}: Props) {
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
|
||||
const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1;
|
||||
const topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2;
|
||||
|
||||
return {
|
||||
backdrop: css({
|
||||
|
||||
@@ -33,6 +33,7 @@ export class AppChromeService {
|
||||
|
||||
private megaMenuDocked = Boolean(
|
||||
config.featureToggles.dockedMegaMenu &&
|
||||
window.innerWidth >= config.theme2.breakpoints.values.xl &&
|
||||
store.getBool(
|
||||
DOCKED_LOCAL_STORAGE_KEY,
|
||||
Boolean(config.featureToggles.dockedMegaMenu && window.innerWidth >= config.theme2.breakpoints.values.xxl)
|
||||
|
||||
@@ -6,7 +6,6 @@ import InfiniteLoader from 'react-window-infinite-loader';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
|
||||
import { Text } from '@grafana/ui/src/components/Text/Text';
|
||||
import { Indent } from 'app/core/components/Indent/Indent';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
@@ -191,6 +190,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
|
||||
>
|
||||
<div className={styles.rowBody}>
|
||||
<Indent level={level} spacing={2} />
|
||||
|
||||
{foldersAreOpenable ? (
|
||||
<IconButton
|
||||
size={CHEVRON_SIZE}
|
||||
@@ -237,9 +237,8 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
width: '100%',
|
||||
}),
|
||||
|
||||
// Should be the same size as the <IconButton /> for proper alignment
|
||||
folderButtonSpacer: css({
|
||||
paddingLeft: `calc(${getSvgSize(CHEVRON_SIZE)}px + ${theme.spacing(0.5)})`,
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
}),
|
||||
|
||||
row: css({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SetupServer, setupServer } from 'msw/node';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { wellFormedTree } from '../../../features/browse-dashboards/fixtures/dashboardsTreeItem.fixture';
|
||||
@@ -122,60 +123,111 @@ describe('NestedFolderPicker', () => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith(folderA.item.uid, folderA.item.title);
|
||||
});
|
||||
|
||||
it('can expand and collapse a folder to show its children', async () => {
|
||||
render(<NestedFolderPicker onChange={mockOnChange} />);
|
||||
describe('when nestedFolders is enabled', () => {
|
||||
let originalToggles = { ...config.featureToggles };
|
||||
|
||||
// Open the picker and wait for children to load
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
await userEvent.click(button);
|
||||
await screen.findByLabelText(folderA.item.title);
|
||||
beforeAll(() => {
|
||||
config.featureToggles.nestedFolders = true;
|
||||
});
|
||||
|
||||
// Expand Folder A
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
|
||||
afterAll(() => {
|
||||
config.featureToggles = originalToggles;
|
||||
});
|
||||
|
||||
// Folder A's children are visible
|
||||
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
|
||||
it('can expand and collapse a folder to show its children', async () => {
|
||||
render(<NestedFolderPicker onChange={mockOnChange} />);
|
||||
|
||||
// Collapse Folder A
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` }));
|
||||
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
|
||||
// Open the picker and wait for children to load
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
await userEvent.click(button);
|
||||
await screen.findByLabelText(folderA.item.title);
|
||||
|
||||
// Expand Folder A again
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
|
||||
// Expand Folder A
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
|
||||
|
||||
// Select the first child
|
||||
await userEvent.click(screen.getByLabelText(folderA_folderA.item.title));
|
||||
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title);
|
||||
// Folder A's children are visible
|
||||
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
|
||||
|
||||
// Collapse Folder A
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` }));
|
||||
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
|
||||
|
||||
// Expand Folder A again
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
|
||||
|
||||
// Select the first child
|
||||
await userEvent.click(screen.getByLabelText(folderA_folderA.item.title));
|
||||
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title);
|
||||
});
|
||||
|
||||
it('can expand and collapse a folder to show its children with the keyboard', async () => {
|
||||
render(<NestedFolderPicker onChange={mockOnChange} />);
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
// Expand Folder A
|
||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
|
||||
|
||||
// Folder A's children are visible
|
||||
expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
|
||||
|
||||
// Collapse Folder A
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
|
||||
|
||||
// Expand Folder A again
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
|
||||
// Select the first child
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||||
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title);
|
||||
});
|
||||
});
|
||||
|
||||
it('can expand and collapse a folder to show its children with the keyboard', async () => {
|
||||
render(<NestedFolderPicker onChange={mockOnChange} />);
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
describe('when nestedFolders is disabled', () => {
|
||||
let originalToggles = { ...config.featureToggles };
|
||||
|
||||
await userEvent.click(button);
|
||||
beforeAll(() => {
|
||||
config.featureToggles.nestedFolders = false;
|
||||
});
|
||||
|
||||
// Expand Folder A
|
||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
|
||||
afterAll(() => {
|
||||
config.featureToggles = originalToggles;
|
||||
});
|
||||
|
||||
// Folder A's children are visible
|
||||
expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
|
||||
it('does not show an expand button', async () => {
|
||||
render(<NestedFolderPicker onChange={mockOnChange} />);
|
||||
|
||||
// Collapse Folder A
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
|
||||
// Open the picker and wait for children to load
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
await userEvent.click(button);
|
||||
await screen.findByLabelText(folderA.item.title);
|
||||
|
||||
// Expand Folder A again
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
// There should be no expand button
|
||||
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
|
||||
expect(screen.queryByRole('button', { name: `Expand folder ${folderA.item.title}` })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select the first child
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||||
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title);
|
||||
it('does not expand a folder with the keyboard', async () => {
|
||||
render(<NestedFolderPicker onChange={mockOnChange} />);
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
// try to expand Folder A
|
||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
|
||||
|
||||
// Folder A's children are not visible
|
||||
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
@@ -58,6 +59,7 @@ export function NestedFolderPicker({
|
||||
const selectedFolder = useGetFolderQuery(value || skipToken);
|
||||
|
||||
const rootStatus = useBrowseLoadingStatus(undefined);
|
||||
const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [autoFocusButton, setAutoFocusButton] = useState(false);
|
||||
@@ -164,6 +166,10 @@ export function NestedFolderPicker({
|
||||
return createFlatTree(undefined, searchCollection, childrenCollections, {}, 0, EXCLUDED_KINDS, excludeUIDs);
|
||||
}
|
||||
|
||||
const allExcludedUIDs = config.sharedWithMeFolderUID
|
||||
? [...(excludeUIDs || []), config.sharedWithMeFolderUID]
|
||||
: excludeUIDs;
|
||||
|
||||
let flatTree = createFlatTree(
|
||||
undefined,
|
||||
rootCollection,
|
||||
@@ -171,7 +177,7 @@ export function NestedFolderPicker({
|
||||
folderOpenState,
|
||||
0,
|
||||
EXCLUDED_KINDS,
|
||||
excludeUIDs
|
||||
allExcludedUIDs
|
||||
);
|
||||
|
||||
if (showRootFolder) {
|
||||
@@ -305,7 +311,7 @@ export function NestedFolderPicker({
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
idPrefix={overlayId}
|
||||
foldersAreOpenable={!(search && searchState.value)}
|
||||
foldersAreOpenable={nestedFoldersEnabled && !(search && searchState.value)}
|
||||
isItemLoaded={isItemLoaded}
|
||||
requestLoadMore={handleLoadMore}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
|
||||
import { DashboardViewItem } from 'app/features/search/types';
|
||||
|
||||
@@ -25,6 +26,7 @@ export function useTreeInteractions({
|
||||
visible,
|
||||
}: TreeInteractionProps) {
|
||||
const [focusedItemIndex, setFocusedItemIndex] = useState(-1);
|
||||
const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -44,7 +46,7 @@ export function useTreeInteractions({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const foldersAreOpenable = !search;
|
||||
const foldersAreOpenable = nestedFoldersEnabled && !search;
|
||||
switch (ev.key) {
|
||||
// Expand/collapse folder on right/left arrow keys
|
||||
case 'ArrowRight':
|
||||
@@ -84,7 +86,7 @@ export function useTreeInteractions({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, search, tree]
|
||||
[focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, nestedFoldersEnabled, search, tree]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,7 +28,9 @@ interface FolderPickerProps extends NestedFolderPickerProps {
|
||||
// Temporary wrapper component to switch between the NestedFolderPicker and the old flat
|
||||
// FolderPicker depending on feature flags
|
||||
export function FolderPicker(props: FolderPickerProps) {
|
||||
const nestedEnabled = config.featureToggles.nestedFolders && config.featureToggles.nestedFolderPicker;
|
||||
const nestedEnabled =
|
||||
config.featureToggles.newFolderPicker ||
|
||||
(config.featureToggles.nestedFolders && config.featureToggles.nestedFolderPicker);
|
||||
const { initialTitle, dashboardId, enableCreateNew, ...newFolderPickerProps } = props;
|
||||
|
||||
return nestedEnabled ? <NestedFolderPicker {...newFolderPickerProps} /> : <OldFolderPickerWrapper {...props} />;
|
||||
|
||||
+43
-38
@@ -1,16 +1,50 @@
|
||||
import { BuildInfo } from '@grafana/data';
|
||||
import { GrafanaEdition } from '@grafana/data/src/types/config';
|
||||
import { Instrumentation } from '@grafana/faro-core';
|
||||
import { Faro, Instrumentation } from '@grafana/faro-core';
|
||||
import * as faroWebSdkModule from '@grafana/faro-web-sdk';
|
||||
import { FetchTransport, initializeFaro } from '@grafana/faro-web-sdk';
|
||||
import { BrowserConfig, FetchTransport } from '@grafana/faro-web-sdk';
|
||||
|
||||
import { EchoSrvTransport } from './EchoSrvTransport';
|
||||
import { GrafanaJavascriptAgentBackend, GrafanaJavascriptAgentBackendOptions } from './GrafanaJavascriptAgentBackend';
|
||||
|
||||
describe('GrafanaJavascriptAgentEchoBackend', () => {
|
||||
let mockedSetUser: jest.Mock;
|
||||
let initializeFaroMock: jest.SpyInstance<Faro, [config: BrowserConfig]>;
|
||||
|
||||
beforeEach(() => {
|
||||
// arrange
|
||||
mockedSetUser = jest.fn();
|
||||
const mockedInstrumentationsForConfig: Instrumentation[] = [];
|
||||
const mockedInstrumentations = {
|
||||
add: jest.fn(),
|
||||
instrumentations: mockedInstrumentationsForConfig,
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const mockedInternalLogger = {
|
||||
prefix: 'Faro',
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
initializeFaroMock = jest.spyOn(faroWebSdkModule, 'initializeFaro').mockReturnValueOnce({
|
||||
...faroWebSdkModule.faro,
|
||||
api: {
|
||||
...faroWebSdkModule.faro.api,
|
||||
setUser: mockedSetUser,
|
||||
},
|
||||
config: {
|
||||
...faroWebSdkModule.faro.config,
|
||||
instrumentations: mockedInstrumentationsForConfig,
|
||||
},
|
||||
instrumentations: mockedInstrumentations,
|
||||
internalLogger: mockedInternalLogger,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
window.fetch = jest.fn();
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -50,47 +84,18 @@ describe('GrafanaJavascriptAgentEchoBackend', () => {
|
||||
|
||||
//assert
|
||||
expect(constructorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(faroWebSdkModule.faro.transports.transports.length).toEqual(2);
|
||||
expect(faroWebSdkModule.faro.transports.transports[0]).toBeInstanceOf(EchoSrvTransport);
|
||||
expect(faroWebSdkModule.faro.transports.transports[1]).toBeInstanceOf(FetchTransport);
|
||||
expect(initializeFaroMock).toHaveBeenCalledTimes(1);
|
||||
expect(initializeFaroMock.mock.calls[0][0].transports?.length).toEqual(2);
|
||||
expect(initializeFaroMock.mock.calls[0][0].transports?.[0]).toBeInstanceOf(EchoSrvTransport);
|
||||
expect(initializeFaroMock.mock.calls[0][0].transports?.[1]).toBeInstanceOf(FetchTransport);
|
||||
});
|
||||
|
||||
it('will initialize GrafanaJavascriptAgent and set user', () => {
|
||||
// arrange
|
||||
const mockedSetUser = jest.fn();
|
||||
const mockedInstrumentationsForConfig: Instrumentation[] = [];
|
||||
const mockedInstrumentations = {
|
||||
add: jest.fn(),
|
||||
instrumentations: mockedInstrumentationsForConfig,
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const mockedInternalLogger = {
|
||||
prefix: 'Faro',
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
jest.spyOn(faroWebSdkModule, 'initializeFaro').mockReturnValueOnce({
|
||||
...faroWebSdkModule.faro,
|
||||
api: {
|
||||
...faroWebSdkModule.faro.api,
|
||||
setUser: mockedSetUser,
|
||||
},
|
||||
config: {
|
||||
...faroWebSdkModule.faro.config,
|
||||
instrumentations: mockedInstrumentationsForConfig,
|
||||
},
|
||||
instrumentations: mockedInstrumentations,
|
||||
internalLogger: mockedInternalLogger,
|
||||
});
|
||||
|
||||
it('will initialize GrafanaJavascriptAgent and set user', async () => {
|
||||
//act
|
||||
new GrafanaJavascriptAgentBackend(options);
|
||||
|
||||
//assert
|
||||
expect(initializeFaro).toHaveBeenCalledTimes(1);
|
||||
expect(initializeFaroMock).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSetUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSetUser).toHaveBeenCalledWith({
|
||||
id: '504',
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
import * as ticks from '../utils/ticks';
|
||||
|
||||
describe('ticks', () => {
|
||||
describe('getFlotTickDecimals()', () => {
|
||||
const axis = {
|
||||
min: null,
|
||||
max: null,
|
||||
};
|
||||
|
||||
it('should calculate decimals precision based on graph height', () => {
|
||||
let dec = ticks.getFlotTickDecimals(0, 10, axis, 200);
|
||||
expect(dec.tickDecimals).toBe(1);
|
||||
expect(dec.scaledDecimals).toBe(1);
|
||||
|
||||
dec = ticks.getFlotTickDecimals(0, 100, axis, 200);
|
||||
expect(dec.tickDecimals).toBe(0);
|
||||
expect(dec.scaledDecimals).toBe(-1);
|
||||
|
||||
dec = ticks.getFlotTickDecimals(0, 1, axis, 200);
|
||||
expect(dec.tickDecimals).toBe(2);
|
||||
expect(dec.scaledDecimals).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStringPrecision()', () => {
|
||||
it('"3.12" should return 2', () => {
|
||||
expect(ticks.getStringPrecision('3.12')).toBe(2);
|
||||
|
||||
@@ -263,12 +263,11 @@ const validKeys = ['refId', 'key', 'context', 'datasource'];
|
||||
export function hasNonEmptyQuery<TQuery extends DataQuery>(queries: TQuery[]): boolean {
|
||||
return (
|
||||
queries &&
|
||||
queries.some((query: any) => {
|
||||
const keys = Object.keys(query)
|
||||
.filter((key) => validKeys.indexOf(key) === -1)
|
||||
.map((k) => query[k])
|
||||
.filter((v) => v);
|
||||
return keys.length > 0;
|
||||
queries.some((query) => {
|
||||
const entries = Object.entries(query)
|
||||
.filter(([key, _]) => validKeys.indexOf(key) === -1)
|
||||
.filter(([_, value]) => value);
|
||||
return entries.length > 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,23 +103,29 @@ export async function parseResponseBody<T>(
|
||||
if (responseType) {
|
||||
switch (responseType) {
|
||||
case 'arraybuffer':
|
||||
return response.arrayBuffer() as any;
|
||||
// this specifically returns a Promise<ArrayBuffer>
|
||||
// TODO refactor this function to remove the type assertions
|
||||
return response.arrayBuffer() as Promise<T>;
|
||||
|
||||
case 'blob':
|
||||
return response.blob() as any;
|
||||
// this specifically returns a Promise<Blob>
|
||||
// TODO refactor this function to remove the type assertions
|
||||
return response.blob() as Promise<T>;
|
||||
|
||||
case 'json':
|
||||
// An empty string is not a valid JSON.
|
||||
// Sometimes (unfortunately) our APIs declare their Content-Type as JSON, however they return an empty body.
|
||||
if (response.headers.get('Content-Length') === '0') {
|
||||
console.warn(`${response.url} returned an invalid JSON`);
|
||||
return {} as unknown as T;
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
case 'text':
|
||||
return response.text() as any;
|
||||
// this specifically returns a Promise<string>
|
||||
// TODO refactor this function to remove the type assertions
|
||||
return response.text() as Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +133,7 @@ export async function parseResponseBody<T>(
|
||||
try {
|
||||
return JSON.parse(textData); // majority of the requests this will be something that can be parsed
|
||||
} catch {}
|
||||
return textData as any;
|
||||
return textData as T;
|
||||
}
|
||||
|
||||
function serializeParams(data: Record<string, any>): string {
|
||||
|
||||
@@ -30,127 +30,6 @@ export function getScaledDecimals(decimals: number, tickSize: number) {
|
||||
return decimals - Math.floor(Math.log(tickSize) / Math.LN10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tick size based on min and max values, number of ticks and precision.
|
||||
* Implementation from Flot.
|
||||
* @param min Axis minimum
|
||||
* @param max Axis maximum
|
||||
* @param noTicks Number of ticks
|
||||
* @param tickDecimals Tick decimal precision
|
||||
*/
|
||||
export function getFlotTickSize(min: number, max: number, noTicks: number, tickDecimals: number) {
|
||||
const delta = (max - min) / noTicks;
|
||||
let dec = -Math.floor(Math.log(delta) / Math.LN10);
|
||||
const maxDec = tickDecimals;
|
||||
|
||||
const magn = Math.pow(10, -dec);
|
||||
const norm = delta / magn; // norm is between 1.0 and 10.0
|
||||
let size;
|
||||
|
||||
if (norm < 1.5) {
|
||||
size = 1;
|
||||
} else if (norm < 3) {
|
||||
size = 2;
|
||||
// special case for 2.5, requires an extra decimal
|
||||
if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) {
|
||||
size = 2.5;
|
||||
++dec;
|
||||
}
|
||||
} else if (norm < 7.5) {
|
||||
size = 5;
|
||||
} else {
|
||||
size = 10;
|
||||
}
|
||||
|
||||
size *= magn;
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate axis range (min and max).
|
||||
* Implementation from Flot.
|
||||
*/
|
||||
export function getFlotRange(panelMin: any, panelMax: any, datamin: number, datamax: number) {
|
||||
const autoscaleMargin = 0.02;
|
||||
|
||||
let min = +(panelMin != null ? panelMin : datamin);
|
||||
let max = +(panelMax != null ? panelMax : datamax);
|
||||
const delta = max - min;
|
||||
|
||||
if (delta === 0.0) {
|
||||
// Grafana fix: wide Y min and max using increased wideFactor
|
||||
// when all series values are the same
|
||||
const wideFactor = 0.25;
|
||||
const widen = Math.abs(max === 0 ? 1 : max * wideFactor);
|
||||
|
||||
if (panelMin === null) {
|
||||
min -= widen;
|
||||
}
|
||||
// always widen max if we couldn't widen min to ensure we
|
||||
// don't fall into min == max which doesn't work
|
||||
if (panelMax == null || panelMin != null) {
|
||||
max += widen;
|
||||
}
|
||||
} else {
|
||||
// consider autoscaling
|
||||
const margin = autoscaleMargin;
|
||||
if (margin != null) {
|
||||
if (panelMin == null) {
|
||||
min -= delta * margin;
|
||||
// make sure we don't go below zero if all values
|
||||
// are positive
|
||||
if (min < 0 && datamin != null && datamin >= 0) {
|
||||
min = 0;
|
||||
}
|
||||
}
|
||||
if (panelMax == null) {
|
||||
max += delta * margin;
|
||||
if (max > 0 && datamax != null && datamax <= 0) {
|
||||
max = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tick decimals.
|
||||
* Implementation from Flot.
|
||||
*/
|
||||
export function getFlotTickDecimals(datamin: number, datamax: number, axis: { min: any; max: any }, height: number) {
|
||||
const { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax);
|
||||
const noTicks = 0.3 * Math.sqrt(height);
|
||||
const delta = (max - min) / noTicks;
|
||||
const dec = -Math.floor(Math.log(delta) / Math.LN10);
|
||||
|
||||
const magn = Math.pow(10, -dec);
|
||||
// norm is between 1.0 and 10.0
|
||||
const norm = delta / magn;
|
||||
let size;
|
||||
|
||||
if (norm < 1.5) {
|
||||
size = 1;
|
||||
} else if (norm < 3) {
|
||||
size = 2;
|
||||
// special case for 2.5, requires an extra decimal
|
||||
if (norm > 2.25) {
|
||||
size = 2.5;
|
||||
}
|
||||
} else if (norm < 7.5) {
|
||||
size = 5;
|
||||
} else {
|
||||
size = 10;
|
||||
}
|
||||
size *= magn;
|
||||
|
||||
const tickDecimals = Math.max(0, -Math.floor(Math.log(delta) / Math.LN10) + 1);
|
||||
// grafana addition
|
||||
const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
|
||||
return { tickDecimals, scaledDecimals };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp similar to Grafana graph panel.
|
||||
* @param ticks Number of ticks
|
||||
|
||||
@@ -1,35 +1,85 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { StoreState } from '../../types';
|
||||
|
||||
import { AnonUsersDevicesTable } from './Users/AnonUsersTable';
|
||||
import { fetchUsersAnonymousDevices } from './state/actions';
|
||||
import { fetchUsersAnonymousDevices, changeAnonUserSort, changeAnonPage, changeAnonQuery } from './state/actions';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchUsersAnonymousDevices,
|
||||
changeAnonUserSort,
|
||||
changeAnonPage,
|
||||
changeAnonQuery,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: StoreState) => ({
|
||||
devices: state.userListAnonymousDevices.devices,
|
||||
query: state.userListAnonymousDevices.query,
|
||||
showPaging: state.userListAnonymousDevices.showPaging,
|
||||
totalPages: state.userListAnonymousDevices.totalPages,
|
||||
page: state.userListAnonymousDevices.page,
|
||||
filters: state.userListAnonymousDevices.filters,
|
||||
});
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage.UserListAdminPage;
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
interface OwnProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => {
|
||||
const UserListAnonymousDevicesPageUnConnected = ({
|
||||
devices,
|
||||
fetchUsersAnonymousDevices,
|
||||
query,
|
||||
changeAnonQuery,
|
||||
filters,
|
||||
showPaging,
|
||||
totalPages,
|
||||
page,
|
||||
changeAnonPage,
|
||||
changeAnonUserSort,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsersAnonymousDevices();
|
||||
}, [fetchUsersAnonymousDevices]);
|
||||
|
||||
return (
|
||||
<Page.Contents>
|
||||
<AnonUsersDevicesTable devices={devices} />
|
||||
<div className={styles.actionBar} data-testid={selectors.container}>
|
||||
<div className={styles.row}>
|
||||
<FilterInput
|
||||
placeholder="Search devices by ip adress."
|
||||
autoFocus={true}
|
||||
value={query}
|
||||
onChange={changeAnonQuery}
|
||||
/>
|
||||
<RadioButtonGroup
|
||||
options={[{ label: 'Active last 30 days', value: true }]}
|
||||
// onChange={(value) => changeFilter({ name: 'activeLast30Days', value })}
|
||||
value={filters.find((f) => f.name === 'activeLast30Days')?.value}
|
||||
className={styles.filter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnonUsersDevicesTable
|
||||
devices={devices}
|
||||
showPaging={showPaging}
|
||||
totalPages={totalPages}
|
||||
onChangePage={changeAnonPage}
|
||||
currentPage={page}
|
||||
fetchData={changeAnonUserSort}
|
||||
/>
|
||||
</Page.Contents>
|
||||
);
|
||||
};
|
||||
@@ -44,4 +94,37 @@ export function UserListAnonymousDevicesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
filter: css({
|
||||
margin: theme.spacing(0, 1),
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
margin: 0,
|
||||
},
|
||||
}),
|
||||
actionBar: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(2),
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
}),
|
||||
row: css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
textAlign: 'left',
|
||||
marginBottom: theme.spacing(0.5),
|
||||
flexGrow: 1,
|
||||
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing(2),
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export default UserListAnonymousDevicesPage;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Avatar, CellProps, Column, InteractiveTable, Stack, Badge, Tooltip } from '@grafana/ui';
|
||||
import {
|
||||
Avatar,
|
||||
CellProps,
|
||||
Column,
|
||||
InteractiveTable,
|
||||
Stack,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Pagination,
|
||||
FetchDataFunc,
|
||||
} from '@grafana/ui';
|
||||
import { EmptyArea } from 'app/features/alerting/unified/components/EmptyArea';
|
||||
import { UserAnonymousDeviceDTO } from 'app/types';
|
||||
|
||||
@@ -49,9 +59,22 @@ const UserAgentCell = ({ value }: UserAgentCellProps) => {
|
||||
|
||||
interface AnonUsersTableProps {
|
||||
devices: UserAnonymousDeviceDTO[];
|
||||
// for pagination
|
||||
showPaging?: boolean;
|
||||
totalPages: number;
|
||||
onChangePage: (page: number) => void;
|
||||
currentPage: number;
|
||||
fetchData?: FetchDataFunc<UserAnonymousDeviceDTO>;
|
||||
}
|
||||
|
||||
export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => {
|
||||
export const AnonUsersDevicesTable = ({
|
||||
devices,
|
||||
showPaging,
|
||||
totalPages,
|
||||
onChangePage,
|
||||
currentPage,
|
||||
fetchData,
|
||||
}: AnonUsersTableProps) => {
|
||||
const columns: Array<Column<UserAnonymousDeviceDTO>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -71,9 +94,9 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => {
|
||||
sortType: 'string',
|
||||
},
|
||||
{
|
||||
id: 'lastSeenAt',
|
||||
id: 'updatedAt',
|
||||
header: 'Last active',
|
||||
cell: ({ cell: { value } }: Cell<'lastSeenAt'>) => value,
|
||||
cell: ({ cell: { value } }: Cell<'updatedAt'>) => value,
|
||||
sortType: (a, b) => new Date(a.original.updatedAt).getTime() - new Date(b.original.updatedAt).getTime(),
|
||||
},
|
||||
{
|
||||
@@ -86,7 +109,12 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => {
|
||||
);
|
||||
return (
|
||||
<Stack direction={'column'} gap={2}>
|
||||
<InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} />
|
||||
<InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} fetchData={fetchData} />
|
||||
{showPaging && (
|
||||
<Stack justifyContent={'flex-end'}>
|
||||
<Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} />
|
||||
</Stack>
|
||||
)}
|
||||
{devices.length === 0 && (
|
||||
<EmptyArea>
|
||||
<span>No anonymous users found.</span>
|
||||
|
||||
@@ -6,7 +6,15 @@ import { FetchDataArgs } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
||||
import { ThunkResult, LdapUser, UserSession, UserDTO, AccessControlAction, UserFilter } from 'app/types';
|
||||
import {
|
||||
ThunkResult,
|
||||
LdapUser,
|
||||
UserSession,
|
||||
UserDTO,
|
||||
AccessControlAction,
|
||||
UserFilter,
|
||||
AnonUserFilter,
|
||||
} from 'app/types';
|
||||
|
||||
import {
|
||||
userAdminPageLoadedAction,
|
||||
@@ -29,6 +37,9 @@ import {
|
||||
usersFetchEnd,
|
||||
sortChanged,
|
||||
usersAnonymousDevicesFetched,
|
||||
anonUserSortChanged,
|
||||
anonPageChanged,
|
||||
anonQueryChanged,
|
||||
} from './reducers';
|
||||
// UserAdminPage
|
||||
|
||||
@@ -337,16 +348,72 @@ export function changeSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void
|
||||
}
|
||||
|
||||
// UserListAnonymousPage
|
||||
const getAnonFilters = (filters: AnonUserFilter[]) => {
|
||||
return filters
|
||||
.map((filter) => {
|
||||
if (Array.isArray(filter.value)) {
|
||||
return filter.value.map((v) => `${filter.name}=${v.value}`).join('&');
|
||||
}
|
||||
return `${filter.name}=${filter.value}`;
|
||||
})
|
||||
.join('&');
|
||||
};
|
||||
|
||||
export function fetchUsersAnonymousDevices(): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
let url = `/api/anonymous/devices`;
|
||||
const { perPage, page, query, filters, sort } = getState().userListAnonymousDevices;
|
||||
let url = `/api/anonymous/search?perpage=${perPage}&page=${page}&query=${query}&${getAnonFilters(filters)}`;
|
||||
if (sort) {
|
||||
url += `&sort=${sort}`;
|
||||
}
|
||||
const result = await getBackendSrv().get(url);
|
||||
dispatch(usersAnonymousDevicesFetched({ devices: result }));
|
||||
dispatch(usersAnonymousDevicesFetched(result));
|
||||
} catch (error) {
|
||||
usersFetchEnd();
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fetchAnonUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsersAnonymousDevices()), 500);
|
||||
|
||||
export function changeAnonUserSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void> {
|
||||
const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined;
|
||||
return async (dispatch, getState) => {
|
||||
const currentSort = getState().userListAnonymousDevices.sort;
|
||||
if (currentSort !== sort) {
|
||||
// dispatch(usersFetchBegin());
|
||||
dispatch(anonUserSortChanged(sort));
|
||||
dispatch(fetchUsersAnonymousDevices());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function changeAnonQuery(query: string): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
// dispatch(usersFetchBegin());
|
||||
dispatch(anonQueryChanged(query));
|
||||
fetchAnonUsersWithDebounce(dispatch);
|
||||
};
|
||||
}
|
||||
|
||||
export function changeAnonPage(page: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
// dispatch(usersFetchBegin());
|
||||
dispatch(anonPageChanged(page));
|
||||
dispatch(fetchUsersAnonymousDevices());
|
||||
};
|
||||
}
|
||||
|
||||
// export function fetchUsersAnonymousDevices(): ThunkResult<void> {
|
||||
// return async (dispatch, getState) => {
|
||||
// try {
|
||||
// let url = `/api/anonymous/devices`;
|
||||
// const result = await getBackendSrv().get(url);
|
||||
// dispatch(usersAnonymousDevicesFetched({ devices: result }));
|
||||
// } catch (error) {
|
||||
// usersFetchEnd();
|
||||
// console.error(error);
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UserFilter,
|
||||
UserListAnonymousDevicesState,
|
||||
UserAnonymousDeviceDTO,
|
||||
AnonUserFilter,
|
||||
} from 'app/types';
|
||||
|
||||
const initialLdapState: LdapState = {
|
||||
@@ -207,10 +208,19 @@ export const userListAdminReducer = userListAdminSlice.reducer;
|
||||
|
||||
const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = {
|
||||
devices: [],
|
||||
query: '',
|
||||
page: 0,
|
||||
perPage: 50,
|
||||
totalPages: 1,
|
||||
showPaging: false,
|
||||
filters: [{ name: 'activeLast30Days', value: true }],
|
||||
};
|
||||
|
||||
interface UsersAnonymousDevicesFetched {
|
||||
devices: UserAnonymousDeviceDTO[];
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const userListAnonymousDevicesSlice = createSlice({
|
||||
@@ -218,17 +228,52 @@ export const userListAnonymousDevicesSlice = createSlice({
|
||||
initialState: initialUserListAnonymousDevicesState,
|
||||
reducers: {
|
||||
usersAnonymousDevicesFetched: (state, action: PayloadAction<UsersAnonymousDevicesFetched>) => {
|
||||
const { devices } = action.payload;
|
||||
const { totalCount, perPage, ...rest } = action.payload;
|
||||
const totalPages = Math.ceil(totalCount / perPage);
|
||||
|
||||
return {
|
||||
...state,
|
||||
devices,
|
||||
isLoading: false,
|
||||
...rest,
|
||||
totalPages,
|
||||
perPage,
|
||||
showPaging: totalPages > 1,
|
||||
};
|
||||
},
|
||||
anonQueryChanged: (state, action: PayloadAction<string>) => ({
|
||||
...state,
|
||||
query: action.payload,
|
||||
page: 0,
|
||||
}),
|
||||
anonPageChanged: (state, action: PayloadAction<number>) => ({
|
||||
...state,
|
||||
page: action.payload,
|
||||
}),
|
||||
anonUserSortChanged: (state, action: PayloadAction<UserListAnonymousDevicesState['sort']>) => ({
|
||||
...state,
|
||||
page: 0,
|
||||
sort: action.payload,
|
||||
}),
|
||||
filterChanged: (state, action: PayloadAction<AnonUserFilter>) => {
|
||||
const { name, value } = action.payload;
|
||||
|
||||
if (state.filters.some((filter) => filter.name === name)) {
|
||||
return {
|
||||
...state,
|
||||
page: 0,
|
||||
filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
page: 0,
|
||||
filters: [...state.filters, action.payload],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { usersAnonymousDevicesFetched } = userListAnonymousDevicesSlice.actions;
|
||||
export const { usersAnonymousDevicesFetched, anonUserSortChanged, anonPageChanged, anonQueryChanged } =
|
||||
userListAnonymousDevicesSlice.actions;
|
||||
export const userListAnonymousDevicesReducer = userListAnonymousDevicesSlice.reducer;
|
||||
|
||||
export default {
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
|
||||
import { Alert, LoadingPlaceholder, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary, Stack } from '@grafana/ui';
|
||||
import { Alert, LoadingPlaceholder, Stack, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { useDispatch } from 'app/types';
|
||||
@@ -16,12 +16,12 @@ import { useGetContactPointsState } from './api/receiversApi';
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
|
||||
import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable';
|
||||
import { findRoutesMatchingPredicate, NotificationPoliciesFilter } from './components/notification-policies/Filters';
|
||||
import { NotificationPoliciesFilter, findRoutesMatchingPredicate } from './components/notification-policies/Filters';
|
||||
import {
|
||||
useAddPolicyModal,
|
||||
useEditPolicyModal,
|
||||
useDeletePolicyModal,
|
||||
useAlertGroupsModal,
|
||||
useDeletePolicyModal,
|
||||
useEditPolicyModal,
|
||||
} from './components/notification-policies/Modals';
|
||||
import { Policy } from './components/notification-policies/Policy';
|
||||
import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig';
|
||||
@@ -249,6 +249,7 @@ const AmRoutes = () => {
|
||||
onShowAlertInstances={showAlertGroupsModal}
|
||||
routesMatchingFilters={routesMatchingFilters}
|
||||
matchingInstancesPreview={{ groupsMap: routeAlertGroupsMap, enabled: !instancesPreviewError }}
|
||||
isAutoGenerated={false}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
+1
@@ -322,6 +322,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
`,
|
||||
noMatchersWarning: css`
|
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
+110
-3
@@ -1,10 +1,10 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { render, renderHook, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { noop } from 'lodash';
|
||||
import React from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import {
|
||||
AlertmanagerGroup,
|
||||
@@ -19,7 +19,12 @@ import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../..
|
||||
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import { Policy } from './Policy';
|
||||
import {
|
||||
AUTOGENERATED_ROOT_LABEL_NAME,
|
||||
Policy,
|
||||
isAutoGeneratedRootAndSimplifiedEnabled,
|
||||
useCreateDropdownMenuActions,
|
||||
} from './Policy';
|
||||
|
||||
jest.mock('../../hooks/useAbilities', () => ({
|
||||
...jest.requireActual('../../hooks/useAbilities'),
|
||||
@@ -163,6 +168,7 @@ describe('Policy', () => {
|
||||
onAddPolicy={onAddPolicy}
|
||||
onDeletePolicy={onDeletePolicy}
|
||||
onShowAlertInstances={onShowAlertInstances}
|
||||
isAutoGenerated={false}
|
||||
/>
|
||||
);
|
||||
// should have default policy
|
||||
@@ -379,3 +385,104 @@ const mockRoutes: RouteWithID = {
|
||||
group_interval: undefined,
|
||||
repeat_interval: undefined,
|
||||
};
|
||||
|
||||
describe('isAutoGeneratedRootAndSimplifiedEnabled', () => {
|
||||
it('returns false when simplified routing is not enabled', () => {
|
||||
const route: RouteWithID = {
|
||||
id: '1',
|
||||
object_matchers: [['label', MatcherOperator.equal, 'true']],
|
||||
};
|
||||
config.featureToggles.alertingSimplifiedRouting = false;
|
||||
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when object_matchers is not defined', () => {
|
||||
const route: RouteWithID = {
|
||||
id: '1',
|
||||
};
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when object_matchers contains AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => {
|
||||
const route: RouteWithID = {
|
||||
id: '1',
|
||||
object_matchers: [[AUTOGENERATED_ROOT_LABEL_NAME, MatcherOperator.equal, 'true']],
|
||||
};
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when object_matchers does not contain AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => {
|
||||
const route: RouteWithID = {
|
||||
id: '1',
|
||||
object_matchers: [['label', MatcherOperator.equal, 'true']],
|
||||
};
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateDropdownMenuActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const openDetailModal = jest.fn();
|
||||
const currentRoute: RouteWithID = { id: '0', routes: [{ id: '1' }] };
|
||||
const toggleShowExportDrawer = jest.fn();
|
||||
const onDeletePolicy = jest.fn();
|
||||
const testCases = [
|
||||
{
|
||||
isAutoGenerated: false,
|
||||
isDefaultPolicy: true,
|
||||
provisioned: false,
|
||||
expectedMenu: ['edit-policy', 'export-policy'],
|
||||
},
|
||||
{
|
||||
isAutoGenerated: false,
|
||||
isDefaultPolicy: true,
|
||||
provisioned: true,
|
||||
expectedMenu: ['edit-policy', 'export-policy'],
|
||||
},
|
||||
{
|
||||
isAutoGenerated: false,
|
||||
isDefaultPolicy: false,
|
||||
provisioned: false,
|
||||
expectedMenu: ['edit-policy', 'delete-policy'],
|
||||
},
|
||||
{
|
||||
isAutoGenerated: false,
|
||||
isDefaultPolicy: false,
|
||||
provisioned: true,
|
||||
expectedMenu: ['edit-policy', 'delete-policy'],
|
||||
},
|
||||
{ isAutoGenerated: true, isDefaultPolicy: true, provisioned: true, expectedMenu: ['edit-policy'] },
|
||||
{ isAutoGenerated: true, isDefaultPolicy: false, provisioned: false, expectedMenu: ['edit-policy'] },
|
||||
{ isAutoGenerated: true, isDefaultPolicy: true, provisioned: false, expectedMenu: ['edit-policy'] },
|
||||
{ isAutoGenerated: true, isDefaultPolicy: false, provisioned: true, expectedMenu: ['edit-policy'] },
|
||||
];
|
||||
|
||||
testCases.forEach(({ isAutoGenerated, isDefaultPolicy, provisioned, expectedMenu }) => {
|
||||
it(`Having all the permissions returns ${expectedMenu.length} menu items for isAutoGenerated=${isAutoGenerated}, isDefaultPolicy=${isDefaultPolicy}, provisioned=${provisioned}`, () => {
|
||||
useAlertmanagerAbilitiesMock.mockReturnValue([
|
||||
[true, true],
|
||||
[true, true],
|
||||
[true, true],
|
||||
]);
|
||||
const { result } = renderHook(() =>
|
||||
useCreateDropdownMenuActions(
|
||||
isAutoGenerated,
|
||||
isDefaultPolicy,
|
||||
provisioned,
|
||||
openDetailModal,
|
||||
currentRoute,
|
||||
toggleShowExportDrawer,
|
||||
onDeletePolicy
|
||||
)
|
||||
);
|
||||
|
||||
const menuItemsKeys = result.current.map((item) => item.key ?? '');
|
||||
expect(menuItemsKeys).toEqual(expectedMenu);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+453
-298
@@ -6,11 +6,13 @@ import { Link } from 'react-router-dom';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
Menu,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -19,14 +21,20 @@ import {
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
|
||||
import { AlertmanagerGroup, ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
import {
|
||||
AlertmanagerGroup,
|
||||
MatcherOperator,
|
||||
ObjectMatcher,
|
||||
Receiver,
|
||||
RouteWithID,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { ReceiversState } from 'app/types';
|
||||
|
||||
import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities';
|
||||
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||
import { INTEGRATION_ICONS } from '../../types/contact-points';
|
||||
import { normalizeMatchers } from '../../utils/matchers';
|
||||
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
|
||||
import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies';
|
||||
import { InhertitableProperties, getInheritedProperties } from '../../utils/notification-policies';
|
||||
import { Authorize } from '../Authorize';
|
||||
import { HoverCard } from '../HoverCard';
|
||||
import { Label } from '../Label';
|
||||
@@ -37,7 +45,7 @@ import { Strong } from '../Strong';
|
||||
import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter';
|
||||
|
||||
import { Matchers } from './Matchers';
|
||||
import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions';
|
||||
import { TIMING_OPTIONS_DEFAULTS, TimingOptions } from './timingOptions';
|
||||
|
||||
interface PolicyComponentProps {
|
||||
receivers?: Receiver[];
|
||||
@@ -54,55 +62,42 @@ interface PolicyComponentProps {
|
||||
routeTree: RouteWithID;
|
||||
currentRoute: RouteWithID;
|
||||
alertManagerSourceName: string;
|
||||
onEditPolicy: (route: RouteWithID, isDefault?: boolean) => void;
|
||||
onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void;
|
||||
onAddPolicy: (route: RouteWithID) => void;
|
||||
onDeletePolicy: (route: RouteWithID) => void;
|
||||
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
|
||||
isAutoGenerated?: boolean;
|
||||
}
|
||||
|
||||
const Policy: FC<PolicyComponentProps> = ({
|
||||
receivers = [],
|
||||
contactPointsState,
|
||||
readOnly = false,
|
||||
provisioned = false,
|
||||
alertGroups = [],
|
||||
alertManagerSourceName,
|
||||
currentRoute,
|
||||
routeTree,
|
||||
inheritedProperties,
|
||||
routesMatchingFilters = [],
|
||||
matchingInstancesPreview = { enabled: false },
|
||||
onEditPolicy,
|
||||
onAddPolicy,
|
||||
onDeletePolicy,
|
||||
onShowAlertInstances,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const isDefaultPolicy = currentRoute === routeTree;
|
||||
const Policy = (props: PolicyComponentProps) => {
|
||||
const {
|
||||
receivers = [],
|
||||
contactPointsState,
|
||||
readOnly = false,
|
||||
provisioned = false,
|
||||
alertGroups = [],
|
||||
alertManagerSourceName,
|
||||
currentRoute,
|
||||
routeTree,
|
||||
inheritedProperties,
|
||||
routesMatchingFilters = [],
|
||||
matchingInstancesPreview = { enabled: false },
|
||||
onEditPolicy,
|
||||
onAddPolicy,
|
||||
onDeletePolicy,
|
||||
onShowAlertInstances,
|
||||
isAutoGenerated = false,
|
||||
} = props;
|
||||
|
||||
const [
|
||||
[updatePoliciesSupported, updatePoliciesAllowed],
|
||||
[deletePolicySupported, deletePolicyAllowed],
|
||||
[exportPoliciesSupported, exportPoliciesAllowed],
|
||||
] = useAlertmanagerAbilities([
|
||||
AlertmanagerAction.UpdateNotificationPolicyTree,
|
||||
AlertmanagerAction.DeleteNotificationPolicy,
|
||||
AlertmanagerAction.ExportNotificationPolicies,
|
||||
]);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const isDefaultPolicy = currentRoute === routeTree;
|
||||
|
||||
const contactPoint = currentRoute.receiver;
|
||||
const continueMatching = currentRoute.continue ?? false;
|
||||
const groupBy = currentRoute.group_by;
|
||||
const muteTimings = currentRoute.mute_time_intervals ?? [];
|
||||
const timingOptions: TimingOptions = {
|
||||
group_wait: currentRoute.group_wait,
|
||||
group_interval: currentRoute.group_interval,
|
||||
repeat_interval: currentRoute.repeat_interval,
|
||||
};
|
||||
|
||||
const matchers = normalizeMatchers(currentRoute);
|
||||
const hasMatchers = Boolean(matchers && matchers.length);
|
||||
const hasMuteTimings = Boolean(muteTimings.length);
|
||||
const hasFocus = routesMatchingFilters.some((route) => route.id === currentRoute.id);
|
||||
|
||||
// gather errors here
|
||||
@@ -116,33 +111,340 @@ const Policy: FC<PolicyComponentProps> = ({
|
||||
const actualContactPoint = contactPoint ?? inheritedProperties?.receiver ?? '';
|
||||
const contactPointErrors = contactPointsState ? getContactPointErrors(actualContactPoint, contactPointsState) : [];
|
||||
|
||||
contactPointErrors.forEach((error) => {
|
||||
errors.push(error);
|
||||
});
|
||||
|
||||
const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0;
|
||||
|
||||
const childPolicies = currentRoute.routes ?? [];
|
||||
|
||||
const inheritedGrouping = hasInheritedProperties && inheritedProperties.group_by;
|
||||
const noGrouping = isArray(groupBy) && groupBy[0] === '...';
|
||||
const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0;
|
||||
const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0;
|
||||
|
||||
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
|
||||
const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id);
|
||||
|
||||
// sum all alert instances for all groups we're handling
|
||||
const numberOfAlertInstances = matchingAlertGroups
|
||||
? sumBy(matchingAlertGroups, (group) => group.alerts.length)
|
||||
: undefined;
|
||||
|
||||
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
|
||||
const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy;
|
||||
const showEditAction = updatePoliciesSupported && updatePoliciesAllowed;
|
||||
const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy;
|
||||
// simplified routing permissions
|
||||
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.ViewAutogeneratedPolicyTree
|
||||
);
|
||||
// collapsible policies variables
|
||||
const isThisPolicyCollapsible = useShouldPolicyBeCollapsible(currentRoute);
|
||||
const [isBranchOpen, toggleBranchOpen] = useToggle(false);
|
||||
const renderChildPolicies = (isThisPolicyCollapsible && isBranchOpen) || !isThisPolicyCollapsible;
|
||||
|
||||
const groupBy = currentRoute.group_by;
|
||||
const muteTimings = currentRoute.mute_time_intervals ?? [];
|
||||
|
||||
const timingOptions: TimingOptions = {
|
||||
group_wait: currentRoute.group_wait,
|
||||
group_interval: currentRoute.group_interval,
|
||||
repeat_interval: currentRoute.repeat_interval,
|
||||
};
|
||||
|
||||
contactPointErrors.forEach((error) => {
|
||||
errors.push(error);
|
||||
});
|
||||
|
||||
const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute);
|
||||
|
||||
// build the menu actions for our policy
|
||||
const dropdownMenuActions: JSX.Element[] = [];
|
||||
const dropdownMenuActions: JSX.Element[] = useCreateDropdownMenuActions(
|
||||
isAutoGenerated,
|
||||
isDefaultPolicy,
|
||||
provisioned,
|
||||
onEditPolicy,
|
||||
currentRoute,
|
||||
toggleShowExportDrawer,
|
||||
onDeletePolicy
|
||||
);
|
||||
|
||||
// check if this policy should be visible. If it's autogenerated and the user is not allowed to see autogenerated
|
||||
// policies then we should not show it. Same if the user is not supported to see autogenerated policies.
|
||||
const hideCurrentPolicy =
|
||||
isAutoGenerated && (!isAllowedToSeeAutogeneratedChunk || !isSupportedToSeeAutogeneratedChunk);
|
||||
|
||||
if (hideCurrentPolicy) {
|
||||
return null;
|
||||
}
|
||||
const isImmutablePolicy = isDefaultPolicy || isAutogeneratedPolicyRoot;
|
||||
// TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column" gap={1.5}>
|
||||
<div
|
||||
className={styles.policyWrapper(hasFocus)}
|
||||
data-testid={isDefaultPolicy ? 'am-root-route-container' : 'am-route-container'}
|
||||
>
|
||||
{/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */}
|
||||
{continueMatching && <ContinueMatchingIndicator />}
|
||||
{showMatchesAllLabelsWarning && <AllMatchesIndicator />}
|
||||
|
||||
<div className={styles.policyItemWrapper}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{/* Matchers and actions */}
|
||||
<div>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
{isThisPolicyCollapsible && (
|
||||
<IconButton
|
||||
name={isBranchOpen ? 'angle-down' : 'angle-right'}
|
||||
onClick={toggleBranchOpen}
|
||||
aria-label={isBranchOpen ? 'Collapse' : 'Expand'}
|
||||
/>
|
||||
)}
|
||||
{isImmutablePolicy ? (
|
||||
isAutogeneratedPolicyRoot ? (
|
||||
<AutogeneratedRootIndicator />
|
||||
) : (
|
||||
<DefaultPolicyIndicator />
|
||||
)
|
||||
) : hasMatchers ? (
|
||||
<Matchers matchers={matchers ?? []} />
|
||||
) : (
|
||||
<span className={styles.metadata}>No matchers</span>
|
||||
)}
|
||||
<Spacer />
|
||||
{/* TODO maybe we should move errors to the gutter instead? */}
|
||||
{errors.length > 0 && <Errors errors={errors} />}
|
||||
{provisioned && <ProvisioningBadge />}
|
||||
<Stack direction="row" gap={0.5}>
|
||||
{!isAutoGenerated && !readOnly && (
|
||||
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
|
||||
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
size="sm"
|
||||
onClick={() => onAddPolicy(currentRoute)}
|
||||
disabled={provisioned}
|
||||
type="button"
|
||||
>
|
||||
New nested policy
|
||||
</Button>
|
||||
</ConditionalWrap>
|
||||
</Authorize>
|
||||
)}
|
||||
{dropdownMenuActions.length > 0 && (
|
||||
<Dropdown overlay={<Menu>{dropdownMenuActions}</Menu>}>
|
||||
<Button
|
||||
icon="ellipsis-h"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
aria-label="more-actions"
|
||||
data-testid="more-actions"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<MetadataRow
|
||||
matchingInstancesPreview={matchingInstancesPreview}
|
||||
numberOfAlertInstances={numberOfAlertInstances}
|
||||
contactPoint={contactPoint}
|
||||
groupBy={groupBy}
|
||||
muteTimings={muteTimings}
|
||||
timingOptions={timingOptions}
|
||||
inheritedProperties={inheritedProperties}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
receivers={receivers}
|
||||
matchingAlertGroups={matchingAlertGroups}
|
||||
matchers={matchers}
|
||||
isDefaultPolicy={isDefaultPolicy}
|
||||
onShowAlertInstances={onShowAlertInstances}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.childPolicies}>
|
||||
{renderChildPolicies && (
|
||||
<>
|
||||
{childPolicies.map((child) => {
|
||||
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
|
||||
// This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy.
|
||||
const isThisChildAutoGenerated = isAutoGeneratedRootAndSimplifiedEnabled(child) || isAutoGenerated;
|
||||
/* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable,
|
||||
then the child policy should not be editable either */
|
||||
const isThisChildReadOnly = readOnly || provisioned || isAutoGenerated;
|
||||
|
||||
return (
|
||||
<Policy
|
||||
key={child.id}
|
||||
routeTree={routeTree}
|
||||
currentRoute={child}
|
||||
receivers={receivers}
|
||||
contactPointsState={contactPointsState}
|
||||
readOnly={isThisChildReadOnly}
|
||||
inheritedProperties={childInheritedProperties}
|
||||
onAddPolicy={onAddPolicy}
|
||||
onEditPolicy={onEditPolicy}
|
||||
onDeletePolicy={onDeletePolicy}
|
||||
onShowAlertInstances={onShowAlertInstances}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
alertGroups={alertGroups}
|
||||
routesMatchingFilters={routesMatchingFilters}
|
||||
matchingInstancesPreview={matchingInstancesPreview}
|
||||
isAutoGenerated={isThisChildAutoGenerated}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This function returns if the policy should be collapsible or not.
|
||||
* Add here conditions for policies that should be collapsible.
|
||||
*/
|
||||
function useShouldPolicyBeCollapsible(route: RouteWithID): boolean {
|
||||
const childrenCount = route.routes?.length ?? 0;
|
||||
const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.ViewAutogeneratedPolicyTree
|
||||
);
|
||||
return (
|
||||
childrenCount > 0 &&
|
||||
isSupportedToSeeAutogeneratedChunk &&
|
||||
isAllowedToSeeAutogeneratedChunk &&
|
||||
isAutoGeneratedRootAndSimplifiedEnabled(route)
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataRowProps {
|
||||
matchingInstancesPreview: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
|
||||
numberOfAlertInstances?: number;
|
||||
contactPoint?: string;
|
||||
groupBy?: string[];
|
||||
muteTimings?: string[];
|
||||
timingOptions?: TimingOptions;
|
||||
inheritedProperties?: Partial<InhertitableProperties>;
|
||||
alertManagerSourceName: string;
|
||||
receivers: Receiver[];
|
||||
matchingAlertGroups?: AlertmanagerGroup[];
|
||||
matchers?: ObjectMatcher[];
|
||||
isDefaultPolicy: boolean;
|
||||
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
|
||||
}
|
||||
|
||||
function MetadataRow({
|
||||
numberOfAlertInstances,
|
||||
isDefaultPolicy,
|
||||
timingOptions,
|
||||
groupBy,
|
||||
muteTimings = [],
|
||||
matchingInstancesPreview,
|
||||
inheritedProperties,
|
||||
matchingAlertGroups,
|
||||
onShowAlertInstances,
|
||||
matchers,
|
||||
contactPoint,
|
||||
alertManagerSourceName,
|
||||
receivers,
|
||||
}: MetadataRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const inheritedGrouping = inheritedProperties && inheritedProperties.group_by;
|
||||
const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0;
|
||||
|
||||
const noGrouping = isArray(groupBy) && groupBy[0] === '...';
|
||||
const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0;
|
||||
const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0;
|
||||
|
||||
const hasMuteTimings = Boolean(muteTimings.length);
|
||||
|
||||
return (
|
||||
<div className={styles.metadataRow}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
{matchingInstancesPreview.enabled && (
|
||||
<MetaText
|
||||
icon="layers-alt"
|
||||
onClick={() => {
|
||||
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
|
||||
}}
|
||||
data-testid="matching-instances"
|
||||
>
|
||||
<Strong>{numberOfAlertInstances ?? '-'}</Strong>
|
||||
<span>{pluralize('instance', numberOfAlertInstances)}</span>
|
||||
</MetaText>
|
||||
)}
|
||||
{contactPoint && (
|
||||
<MetaText icon="at" data-testid="contact-point">
|
||||
<span>Delivered to</span>
|
||||
<ContactPointsHoverDetails
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
receivers={receivers}
|
||||
contactPoint={contactPoint}
|
||||
/>
|
||||
</MetaText>
|
||||
)}
|
||||
{!inheritedGrouping && (
|
||||
<>
|
||||
{customGrouping && (
|
||||
<MetaText icon="layer-group" data-testid="grouping">
|
||||
<span>Grouped by</span>
|
||||
<Strong>{groupBy.join(', ')}</Strong>
|
||||
</MetaText>
|
||||
)}
|
||||
{singleGroup && (
|
||||
<MetaText icon="layer-group">
|
||||
<span>Single group</span>
|
||||
</MetaText>
|
||||
)}
|
||||
{noGrouping && (
|
||||
<MetaText icon="layer-group">
|
||||
<span>Not grouping</span>
|
||||
</MetaText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasMuteTimings && (
|
||||
<MetaText icon="calendar-slash" data-testid="mute-timings">
|
||||
<span>Muted when</span>
|
||||
<MuteTimings timings={muteTimings} alertManagerSourceName={alertManagerSourceName} />
|
||||
</MetaText>
|
||||
)}
|
||||
{timingOptions && (
|
||||
// for the default policy we will also merge the default timings, that way a user can observe what the timing options would be
|
||||
<TimingOptionsMeta
|
||||
timingOptions={isDefaultPolicy ? defaults(timingOptions, TIMING_OPTIONS_DEFAULTS) : timingOptions}
|
||||
/>
|
||||
)}
|
||||
{hasInheritedProperties && (
|
||||
<>
|
||||
<MetaText icon="corner-down-right-alt" data-testid="inherited-properties">
|
||||
<span>Inherited</span>
|
||||
<InheritedProperties properties={inheritedProperties} />
|
||||
</MetaText>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const useCreateDropdownMenuActions = (
|
||||
isAutoGenerated: boolean,
|
||||
isDefaultPolicy: boolean,
|
||||
provisioned: boolean,
|
||||
onEditPolicy: (route: RouteWithID, isDefault?: boolean, readOnly?: boolean) => void,
|
||||
currentRoute: RouteWithID,
|
||||
toggleShowExportDrawer: (nextValue?: any) => void,
|
||||
onDeletePolicy: (route: RouteWithID) => void
|
||||
) => {
|
||||
const [
|
||||
[updatePoliciesSupported, updatePoliciesAllowed],
|
||||
[deletePolicySupported, deletePolicyAllowed],
|
||||
[exportPoliciesSupported, exportPoliciesAllowed],
|
||||
] = useAlertmanagerAbilities([
|
||||
AlertmanagerAction.UpdateNotificationPolicyTree,
|
||||
AlertmanagerAction.DeleteNotificationPolicy,
|
||||
AlertmanagerAction.ExportNotificationPolicies,
|
||||
]);
|
||||
const dropdownMenuActions = [];
|
||||
const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy && !isAutoGenerated;
|
||||
const showEditAction = updatePoliciesSupported && updatePoliciesAllowed;
|
||||
const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy && !isAutoGenerated;
|
||||
|
||||
if (showEditAction) {
|
||||
dropdownMenuActions.push(
|
||||
@@ -150,7 +452,7 @@ const Policy: FC<PolicyComponentProps> = ({
|
||||
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
|
||||
<Menu.Item
|
||||
icon="edit"
|
||||
disabled={provisioned}
|
||||
disabled={provisioned || isAutoGenerated}
|
||||
label="Edit"
|
||||
onClick={() => onEditPolicy(currentRoute, isDefaultPolicy)}
|
||||
/>
|
||||
@@ -173,7 +475,7 @@ const Policy: FC<PolicyComponentProps> = ({
|
||||
<Menu.Item
|
||||
destructive
|
||||
icon="trash-alt"
|
||||
disabled={provisioned}
|
||||
disabled={provisioned || isAutoGenerated}
|
||||
label="Delete"
|
||||
onClick={() => onDeletePolicy(currentRoute)}
|
||||
/>
|
||||
@@ -181,167 +483,31 @@ const Policy: FC<PolicyComponentProps> = ({
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated
|
||||
return (
|
||||
<Stack direction="column" gap={1.5}>
|
||||
<div
|
||||
className={styles.policyWrapper(hasFocus)}
|
||||
data-testid={isDefaultPolicy ? 'am-root-route-container' : 'am-route-container'}
|
||||
>
|
||||
{/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */}
|
||||
{continueMatching && <ContinueMatchingIndicator />}
|
||||
{showMatchesAllLabelsWarning && <AllMatchesIndicator />}
|
||||
<div className={styles.policyItemWrapper}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{/* Matchers and actions */}
|
||||
<div>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
{isDefaultPolicy ? (
|
||||
<DefaultPolicyIndicator />
|
||||
) : hasMatchers ? (
|
||||
<Matchers matchers={matchers ?? []} />
|
||||
) : (
|
||||
<span className={styles.metadata}>No matchers</span>
|
||||
)}
|
||||
<Spacer />
|
||||
{/* TODO maybe we should move errors to the gutter instead? */}
|
||||
{errors.length > 0 && <Errors errors={errors} />}
|
||||
{provisioned && <ProvisioningBadge />}
|
||||
{!readOnly && (
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
|
||||
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
size="sm"
|
||||
onClick={() => onAddPolicy(currentRoute)}
|
||||
disabled={provisioned}
|
||||
type="button"
|
||||
>
|
||||
New nested policy
|
||||
</Button>
|
||||
</ConditionalWrap>
|
||||
</Authorize>
|
||||
{dropdownMenuActions.length > 0 && (
|
||||
<Dropdown overlay={<Menu>{dropdownMenuActions}</Menu>}>
|
||||
<Button
|
||||
icon="ellipsis-h"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
aria-label="more-actions"
|
||||
data-testid="more-actions"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className={styles.metadataRow}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
{matchingInstancesPreview.enabled && (
|
||||
<MetaText
|
||||
icon="layers-alt"
|
||||
onClick={() => {
|
||||
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
|
||||
}}
|
||||
data-testid="matching-instances"
|
||||
>
|
||||
<Strong>{numberOfAlertInstances ?? '-'}</Strong>
|
||||
<span>{pluralize('instance', numberOfAlertInstances)}</span>
|
||||
</MetaText>
|
||||
)}
|
||||
{contactPoint && (
|
||||
<MetaText icon="at" data-testid="contact-point">
|
||||
<span>Delivered to</span>
|
||||
<ContactPointsHoverDetails
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
receivers={receivers}
|
||||
contactPoint={contactPoint}
|
||||
/>
|
||||
</MetaText>
|
||||
)}
|
||||
{!inheritedGrouping && (
|
||||
<>
|
||||
{customGrouping && (
|
||||
<MetaText icon="layer-group" data-testid="grouping">
|
||||
<span>Grouped by</span>
|
||||
<Strong>{groupBy.join(', ')}</Strong>
|
||||
</MetaText>
|
||||
)}
|
||||
{singleGroup && (
|
||||
<MetaText icon="layer-group">
|
||||
<span>Single group</span>
|
||||
</MetaText>
|
||||
)}
|
||||
{noGrouping && (
|
||||
<MetaText icon="layer-group">
|
||||
<span>Not grouping</span>
|
||||
</MetaText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasMuteTimings && (
|
||||
<MetaText icon="calendar-slash" data-testid="mute-timings">
|
||||
<span>Muted when</span>
|
||||
<MuteTimings timings={muteTimings} alertManagerSourceName={alertManagerSourceName} />
|
||||
</MetaText>
|
||||
)}
|
||||
{timingOptions && (
|
||||
// for the default policy we will also merge the default timings, that way a user can observe what the timing options would be
|
||||
<TimingOptionsMeta
|
||||
timingOptions={isDefaultPolicy ? defaults(timingOptions, TIMING_OPTIONS_DEFAULTS) : timingOptions}
|
||||
/>
|
||||
)}
|
||||
{hasInheritedProperties && (
|
||||
<>
|
||||
<MetaText icon="corner-down-right-alt" data-testid="inherited-properties">
|
||||
<span>Inherited</span>
|
||||
<InheritedProperties properties={inheritedProperties} />
|
||||
</MetaText>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.childPolicies}>
|
||||
{/* pass the "readOnly" prop from the parent, because if you can't edit the parent you can't edit children */}
|
||||
{childPolicies.map((child) => {
|
||||
const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties);
|
||||
|
||||
return (
|
||||
<Policy
|
||||
key={uniqueId()}
|
||||
routeTree={routeTree}
|
||||
currentRoute={child}
|
||||
receivers={receivers}
|
||||
contactPointsState={contactPointsState}
|
||||
readOnly={readOnly || provisioned}
|
||||
inheritedProperties={childInheritedProperties}
|
||||
onAddPolicy={onAddPolicy}
|
||||
onEditPolicy={onEditPolicy}
|
||||
onDeletePolicy={onDeletePolicy}
|
||||
onShowAlertInstances={onShowAlertInstances}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
alertGroups={alertGroups}
|
||||
routesMatchingFilters={routesMatchingFilters}
|
||||
matchingInstancesPreview={matchingInstancesPreview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
|
||||
</Stack>
|
||||
);
|
||||
return dropdownMenuActions;
|
||||
};
|
||||
|
||||
export const AUTOGENERATED_ROOT_LABEL_NAME = '__grafana_autogenerated__';
|
||||
|
||||
export function isAutoGeneratedRootAndSimplifiedEnabled(route: RouteWithID) {
|
||||
const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
|
||||
if (!simplifiedRoutingToggleEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (!route.object_matchers) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
route.object_matchers.some((objectMatcher) => {
|
||||
return (
|
||||
objectMatcher[0] === AUTOGENERATED_ROOT_LABEL_NAME &&
|
||||
objectMatcher[1] === MatcherOperator.equal &&
|
||||
objectMatcher[2] === 'true'
|
||||
);
|
||||
}) ?? false
|
||||
);
|
||||
// return simplifiedRoutingToggleEnabled && route.receiver === 'contact_point_5';
|
||||
}
|
||||
|
||||
const ProvisionedTooltip = (children: ReactNode) => (
|
||||
<Tooltip content="Provisioned items cannot be edited in the UI" placement="top">
|
||||
<span>{children}</span>
|
||||
@@ -388,7 +554,7 @@ const AllMatchesIndicator: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultPolicyIndicator: FC = () => {
|
||||
function DefaultPolicyIndicator() {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<>
|
||||
@@ -398,7 +564,11 @@ const DefaultPolicyIndicator: FC = () => {
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function AutogeneratedRootIndicator() {
|
||||
return <strong> Auto-generated policies</strong>;
|
||||
}
|
||||
|
||||
const InheritedProperties: FC<{ properties: InhertitableProperties }> = ({ properties }) => (
|
||||
<HoverCard
|
||||
@@ -627,84 +797,69 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
const { color, borderColor } = getTagColorsFromName(label);
|
||||
|
||||
return {
|
||||
wrapper: css`
|
||||
color: #fff;
|
||||
background: ${color};
|
||||
padding: ${theme.spacing(0.33)} ${theme.spacing(0.66)};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
|
||||
border: solid 1px ${borderColor};
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
`,
|
||||
wrapper: css({
|
||||
color: '#fff',
|
||||
background: color,
|
||||
padding: `${theme.spacing(0.33)} ${theme.spacing(0.66)}`,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
border: `solid 1px ${borderColor}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
};
|
||||
},
|
||||
childPolicies: css`
|
||||
margin-left: ${theme.spacing(4)};
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: calc(100% - 10px);
|
||||
|
||||
border-left: solid 1px ${theme.colors.border.weak};
|
||||
|
||||
margin-top: 0;
|
||||
margin-left: -20px;
|
||||
}
|
||||
`,
|
||||
policyItemWrapper: css`
|
||||
padding: ${theme.spacing(1.5)};
|
||||
`,
|
||||
metadataRow: css`
|
||||
background: ${theme.colors.background.secondary};
|
||||
|
||||
border-bottom-left-radius: ${theme.shape.borderRadius(2)};
|
||||
border-bottom-right-radius: ${theme.shape.borderRadius(2)};
|
||||
`,
|
||||
policyWrapper: (hasFocus = false) => css`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: ${theme.colors.background.secondary};
|
||||
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
border: solid 1px ${theme.colors.border.weak};
|
||||
|
||||
${hasFocus &&
|
||||
css`
|
||||
border-color: ${theme.colors.primary.border};
|
||||
`}
|
||||
`,
|
||||
metadata: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
font-weight: ${theme.typography.bodySmall.fontWeight};
|
||||
`,
|
||||
break: css`
|
||||
width: 100%;
|
||||
height: 0;
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
gutterIcon: css`
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
transform: translateY(50%);
|
||||
left: -${theme.spacing(4)};
|
||||
|
||||
color: ${theme.colors.text.secondary};
|
||||
background: ${theme.colors.background.primary};
|
||||
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
text-align: center;
|
||||
|
||||
border: solid 1px ${theme.colors.border.weak};
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
|
||||
padding: 0;
|
||||
`,
|
||||
childPolicies: css({
|
||||
marginLeft: theme.spacing(4),
|
||||
position: 'relative',
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
height: 'calc(100% - 10px)',
|
||||
borderLeft: `solid 1px ${theme.colors.border.weak}`,
|
||||
marginTop: 0,
|
||||
marginLeft: '-20px',
|
||||
},
|
||||
}),
|
||||
policyItemWrapper: css({
|
||||
padding: theme.spacing(1.5),
|
||||
}),
|
||||
metadataRow: css({
|
||||
background: theme.colors.background.secondary,
|
||||
borderBottomLeftRadius: theme.shape.borderRadius(2),
|
||||
borderBottomRightRadius: theme.shape.borderRadius(2),
|
||||
}),
|
||||
policyWrapper: (hasFocus = false) =>
|
||||
css({
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
background: theme.colors.background.secondary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
...(hasFocus && { borderColor: theme.colors.primary.border }),
|
||||
}),
|
||||
metadata: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.bodySmall.fontWeight,
|
||||
}),
|
||||
break: css({
|
||||
width: '100%',
|
||||
height: 0,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
gutterIcon: css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: 'translateY(50%)',
|
||||
left: `-${theme.spacing(4)}`,
|
||||
color: theme.colors.text.secondary,
|
||||
background: theme.colors.background.primary,
|
||||
width: '25px',
|
||||
height: '25px',
|
||||
textAlign: 'center',
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
padding: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
export { Policy };
|
||||
|
||||
@@ -78,6 +78,10 @@ exports[`alertmanager abilities should report Create / Update / Delete actions a
|
||||
true,
|
||||
false,
|
||||
],
|
||||
"view-autogenerated-policy-tree": [
|
||||
false,
|
||||
false,
|
||||
],
|
||||
"view-contact-point": [
|
||||
true,
|
||||
false,
|
||||
@@ -183,6 +187,10 @@ exports[`alertmanager abilities should report everything except exporting for Mi
|
||||
true,
|
||||
true,
|
||||
],
|
||||
"view-autogenerated-policy-tree": [
|
||||
false,
|
||||
false,
|
||||
],
|
||||
"view-contact-point": [
|
||||
true,
|
||||
true,
|
||||
@@ -288,6 +296,10 @@ exports[`alertmanager abilities should report everything is supported for builti
|
||||
true,
|
||||
false,
|
||||
],
|
||||
"view-autogenerated-policy-tree": [
|
||||
true,
|
||||
false,
|
||||
],
|
||||
"view-contact-point": [
|
||||
true,
|
||||
true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { contextSrv as ctx } from 'app/core/services/context_srv';
|
||||
import { contextSrv, contextSrv as ctx } from 'app/core/services/context_srv';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
@@ -45,6 +45,7 @@ export enum AlertmanagerAction {
|
||||
UpdateNotificationPolicyTree = 'update-notification-policy-tree',
|
||||
DeleteNotificationPolicy = 'delete-notification-policy',
|
||||
ExportNotificationPolicies = 'export-notification-policies',
|
||||
ViewAutogeneratedPolicyTree = 'view-autogenerated-policy-tree',
|
||||
|
||||
// silences – these cannot be deleted only "expired" (updated)
|
||||
CreateSilence = 'create-silence',
|
||||
@@ -190,6 +191,9 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
|
||||
const notificationsPermissions = getNotificationsPermissions(selectedAlertmanager!);
|
||||
const instancePermissions = getInstancesPermissions(selectedAlertmanager!);
|
||||
|
||||
//we need to know user role to determine if they can view autogenerated policy tree
|
||||
const isAdmin = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
|
||||
|
||||
// list out all of the abilities, and if the user has permissions to perform them
|
||||
const abilities: Abilities<AlertmanagerAction> = {
|
||||
// -- configuration --
|
||||
@@ -226,6 +230,7 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
|
||||
isGrafanaFlavoredAlertmanager,
|
||||
notificationsPermissions.provisioning.readSecrets
|
||||
),
|
||||
[AlertmanagerAction.ViewAutogeneratedPolicyTree]: [isGrafanaFlavoredAlertmanager, isAdmin],
|
||||
// -- silences --
|
||||
// for now, all supported Alertmanager flavors have API endpoints for managing silences
|
||||
[AlertmanagerAction.CreateSilence]: toAbility(AlwaysSupported, instancePermissions.create),
|
||||
|
||||
@@ -57,6 +57,7 @@ export const browseDashboardsAPI = createApi({
|
||||
providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }],
|
||||
query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }),
|
||||
}),
|
||||
|
||||
// create a new folder
|
||||
newFolder: builder.mutation<FolderDTO, { title: string; parentUid?: string }>({
|
||||
query: ({ title, parentUid }) => ({
|
||||
@@ -81,6 +82,7 @@ export const browseDashboardsAPI = createApi({
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
// save an existing folder (e.g. rename)
|
||||
saveFolder: builder.mutation<FolderDTO, FolderDTO>({
|
||||
// because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DashboardViewItem } from 'app/features/search/types';
|
||||
|
||||
import { contextSrv } from '../../../core/core';
|
||||
import { AccessControlAction } from '../../../types';
|
||||
import { isSharedWithMe } from '../components/utils';
|
||||
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
@@ -36,7 +37,7 @@ export async function listFolders(
|
||||
title: item.title,
|
||||
parentTitle,
|
||||
parentUID,
|
||||
url: `/dashboards/f/${item.uid}/`,
|
||||
url: isSharedWithMe(item.uid) ? undefined : `/dashboards/f/${item.uid}/`,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,26 +8,31 @@ import { t } from 'app/core/internationalization';
|
||||
|
||||
import { DashboardsTreeCellProps, SelectionState } from '../types';
|
||||
|
||||
import { isSharedWithMe } from './utils';
|
||||
|
||||
export default function CheckboxCell({
|
||||
row: { original: row },
|
||||
isSelected,
|
||||
onItemSelectionChange,
|
||||
}: DashboardsTreeCellProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const item = row.item;
|
||||
|
||||
if (!isSelected) {
|
||||
return <span className={styles.checkboxSpacer} />;
|
||||
return <CheckboxSpacer />;
|
||||
}
|
||||
|
||||
if (item.kind === 'ui') {
|
||||
if (item.uiKind === 'pagination-placeholder') {
|
||||
return <Checkbox disabled value={false} />;
|
||||
} else {
|
||||
return <span className={styles.checkboxSpacer} />;
|
||||
return <CheckboxSpacer />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSharedWithMe(item.uid)) {
|
||||
return <CheckboxSpacer />;
|
||||
}
|
||||
|
||||
const state = isSelected(item);
|
||||
|
||||
return (
|
||||
@@ -41,6 +46,11 @@ export default function CheckboxCell({
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxSpacer() {
|
||||
const styles = useStyles2(getStyles);
|
||||
return <span className={styles.checkboxSpacer} />;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
// Should be the same size as the <IconButton /> so Dashboard name is aligned to Folder name siblings
|
||||
checkboxSpacer: css({
|
||||
|
||||
@@ -5,8 +5,14 @@ import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { assertIsDefined } from 'test/helpers/asserts';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
||||
import {
|
||||
sharedWithMeFolder,
|
||||
wellFormedDashboard,
|
||||
wellFormedEmptyFolder,
|
||||
wellFormedFolder,
|
||||
} from '../fixtures/dashboardsTreeItem.fixture';
|
||||
import { SelectionState } from '../types';
|
||||
|
||||
import { DashboardsTree } from './DashboardsTree';
|
||||
@@ -27,6 +33,10 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
const allItemsAreLoaded = () => true;
|
||||
const requestLoadMore = () => Promise.resolve();
|
||||
|
||||
beforeAll(() => {
|
||||
config.sharedWithMeFolderUID = 'sharedwithme';
|
||||
});
|
||||
|
||||
it('renders a dashboard item', () => {
|
||||
render(
|
||||
<DashboardsTree
|
||||
@@ -82,9 +92,73 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
requestLoadMore={requestLoadMore}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(folder.item.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a folder link', () => {
|
||||
render(
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[folder]}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
onItemSelectionChange={noop}
|
||||
onAllSelectionChange={noop}
|
||||
isItemLoaded={allItemsAreLoaded}
|
||||
requestLoadMore={requestLoadMore}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(folder.item.title)).toHaveAttribute('href', folder.item.url);
|
||||
});
|
||||
|
||||
it("doesn't link to the sharedwithme pseudo-folder", () => {
|
||||
const sharedWithMe = sharedWithMeFolder(2);
|
||||
|
||||
render(
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[sharedWithMe, folder]}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
onItemSelectionChange={noop}
|
||||
onAllSelectionChange={noop}
|
||||
isItemLoaded={allItemsAreLoaded}
|
||||
requestLoadMore={requestLoadMore}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(sharedWithMe.item.title)).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it("doesn't render a checkbox for the sharedwithme pseudo-folder", () => {
|
||||
const sharedWithMe = sharedWithMeFolder(2);
|
||||
|
||||
render(
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[sharedWithMe, folder]}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
onItemSelectionChange={noop}
|
||||
onAllSelectionChange={noop}
|
||||
isItemLoaded={allItemsAreLoaded}
|
||||
requestLoadMore={requestLoadMore}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId(selectors.pages.BrowseDashboards.table.checkbox(sharedWithMe.item.uid))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFolderClick when a folder button is clicked', async () => {
|
||||
const handler = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef } from 'react';
|
||||
import { TableInstance, useTable } from 'react-table';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||
@@ -35,6 +35,7 @@ interface DashboardsTreeProps {
|
||||
|
||||
const HEADER_HEIGHT = 36;
|
||||
const ROW_HEIGHT = 36;
|
||||
const DIVIDER_HEIGHT = 0; // Yes - make it appear as a border on the row rather than a row itself
|
||||
|
||||
export function DashboardsTree({
|
||||
items,
|
||||
@@ -51,6 +52,7 @@ export function DashboardsTree({
|
||||
const treeID = useId();
|
||||
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
const listRef = useRef<List | null>(null);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,6 +62,10 @@ export function DashboardsTree({
|
||||
if (infiniteLoaderRef.current) {
|
||||
infiniteLoaderRef.current.resetloadMoreItemsCache(true);
|
||||
}
|
||||
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
@@ -123,6 +129,18 @@ export function DashboardsTree({
|
||||
[requestLoadMore, items]
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
(rowIndex: number) => {
|
||||
const row = items[rowIndex];
|
||||
if (row.item.kind === 'ui' && row.item.uiKind === 'divider') {
|
||||
return DIVIDER_HEIGHT;
|
||||
}
|
||||
|
||||
return ROW_HEIGHT;
|
||||
},
|
||||
[items]
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...getTableProps()} role="table">
|
||||
{headerGroups.map((headerGroup) => {
|
||||
@@ -154,12 +172,16 @@ export function DashboardsTree({
|
||||
>
|
||||
{({ onItemsRendered, ref }) => (
|
||||
<List
|
||||
ref={ref}
|
||||
ref={(elem) => {
|
||||
ref(elem);
|
||||
listRef.current = elem;
|
||||
}}
|
||||
height={height - HEADER_HEIGHT}
|
||||
width={width}
|
||||
itemCount={items.length}
|
||||
itemData={virtualData}
|
||||
itemSize={ROW_HEIGHT}
|
||||
estimatedItemSize={ROW_HEIGHT}
|
||||
itemSize={getRowHeight}
|
||||
onItemsRendered={onItemsRendered}
|
||||
>
|
||||
{VirtualListRow}
|
||||
@@ -191,13 +213,23 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) {
|
||||
const row = rows[index];
|
||||
prepareRow(row);
|
||||
|
||||
const dashboardItem = row.original.item;
|
||||
|
||||
if (dashboardItem.kind === 'ui' && dashboardItem.uiKind === 'divider') {
|
||||
return (
|
||||
<div {...row.getRowProps({ style })}>
|
||||
<hr className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...row.getRowProps({ style })}
|
||||
className={cx(styles.row, styles.bodyRow)}
|
||||
aria-labelledby={makeRowID(treeID, row.original.item)}
|
||||
aria-labelledby={makeRowID(treeID, dashboardItem)}
|
||||
data-testid={selectors.pages.BrowseDashboards.table.row(
|
||||
'title' in row.original.item ? row.original.item.title : row.original.item.uid
|
||||
'title' in dashboardItem ? dashboardItem.title : dashboardItem.uid
|
||||
)}
|
||||
>
|
||||
{row.cells.map((cell) => {
|
||||
@@ -221,6 +253,12 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
|
||||
divider: css({
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
}),
|
||||
|
||||
headerRow: css({
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
height: HEADER_HEIGHT,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { reportInteraction } from '@grafana/runtime';
|
||||
import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui';
|
||||
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { getIconForKind } from 'app/features/search/service/utils';
|
||||
import { getIconForItem } from 'app/features/search/service/utils';
|
||||
|
||||
import { Indent } from '../../../core/components/Indent/Indent';
|
||||
import { useChildrenByParentUIDState } from '../state';
|
||||
@@ -27,7 +27,7 @@ export function NameCell({ row: { original: data }, onFolderClick, treeID }: Nam
|
||||
const { item, level, isOpen } = data;
|
||||
const childrenByParentUID = useChildrenByParentUIDState();
|
||||
const isLoading = isOpen && !childrenByParentUID[item.uid];
|
||||
const iconName = getIconForKind(data.item.kind, isOpen);
|
||||
const iconName = getIconForItem(data.item, isOpen);
|
||||
|
||||
if (item.kind === 'ui') {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { DashboardViewItemWithUIItems } from '../types';
|
||||
|
||||
export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) {
|
||||
return baseId + item.uid;
|
||||
}
|
||||
|
||||
export function isSharedWithMe(uid: string) {
|
||||
return uid === config.sharedWithMeFolderUID;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ export function wellFormedFolder(
|
||||
};
|
||||
}
|
||||
|
||||
export function sharedWithMeFolder(seed = 1): DashboardsTreeItem<DashboardViewItem> {
|
||||
const folder = wellFormedFolder(seed, undefined, {
|
||||
uid: 'sharedwithme',
|
||||
url: undefined,
|
||||
});
|
||||
return folder;
|
||||
}
|
||||
|
||||
export function wellFormedTree() {
|
||||
let seed = 1;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DashboardViewItem } from 'app/features/search/types';
|
||||
import { useSelector, StoreState, useDispatch } from 'app/types';
|
||||
|
||||
import { PAGE_SIZE } from '../api/services';
|
||||
import { isSharedWithMe } from '../components/utils';
|
||||
import {
|
||||
BrowseDashboardsState,
|
||||
DashboardsTreeItem,
|
||||
@@ -130,7 +131,7 @@ export function useLoadNextChildrenPage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a list of items, with level indicating it's 'nested' in the tree structure
|
||||
* Creates a list of items, with level indicating it's nesting in the tree structure
|
||||
*
|
||||
* @param folderUID The UID of the folder being viewed, or undefined if at root Browse Dashboards page
|
||||
* @param rootItems Array of loaded items at the root level (without a parent). If viewing a folder, we expect this to be empty and unused
|
||||
@@ -180,7 +181,22 @@ export function createFlatTree(
|
||||
isOpen,
|
||||
};
|
||||
|
||||
return [thisItem, ...mappedChildren];
|
||||
const items = [thisItem, ...mappedChildren];
|
||||
|
||||
if (isSharedWithMe(thisItem.item.uid)) {
|
||||
items.push({
|
||||
item: {
|
||||
kind: 'ui',
|
||||
uiKind: 'divider',
|
||||
uid: 'shared-with-me-divider',
|
||||
},
|
||||
parentUID,
|
||||
level: level + 1,
|
||||
isOpen: false,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const isOpen = (folderUID && openFolders[folderUID]) || level === 0;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { sharedWithMeFolder, wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
||||
import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures';
|
||||
import { BrowseDashboardsState } from '../types';
|
||||
|
||||
@@ -19,6 +21,10 @@ function createInitialState(): BrowseDashboardsState {
|
||||
}
|
||||
|
||||
describe('browse-dashboards reducers', () => {
|
||||
beforeAll(() => {
|
||||
config.sharedWithMeFolderUID = 'sharedwithme';
|
||||
});
|
||||
|
||||
describe('fetchNextChildrenPageFulfilled', () => {
|
||||
it('loads first page of root items', () => {
|
||||
const pageSize = 50;
|
||||
@@ -321,11 +327,34 @@ describe('browse-dashboards reducers', () => {
|
||||
|
||||
expect(state.selectedItems.$all).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not allow the sharedwithme folder to be selected', () => {
|
||||
let seed = 1;
|
||||
const folder = wellFormedFolder(seed++).item;
|
||||
const dashboard = wellFormedDashboard(seed++).item;
|
||||
const sharedWithMe = sharedWithMeFolder(seed++).item;
|
||||
const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item;
|
||||
|
||||
const state = createInitialState();
|
||||
state.rootItems = fullyLoadedViewItemCollection([sharedWithMe, folder, dashboard]);
|
||||
state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]);
|
||||
|
||||
setItemSelectionState(state, {
|
||||
type: 'setItemSelectionState',
|
||||
payload: { item: sharedWithMe, isSelected: true },
|
||||
});
|
||||
|
||||
expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAllSelection', () => {
|
||||
let seed = 1;
|
||||
const topLevelDashboard = wellFormedDashboard(seed++).item;
|
||||
|
||||
const sharedWithMe = sharedWithMeFolder(seed++).item;
|
||||
const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item;
|
||||
|
||||
const topLevelFolder = wellFormedFolder(seed++).item;
|
||||
const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item;
|
||||
const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item;
|
||||
@@ -407,5 +436,35 @@ describe('browse-dashboards reducers', () => {
|
||||
panel: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't select the sharedwithme folder when selecting all", () => {
|
||||
const state = createInitialState();
|
||||
|
||||
state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, sharedWithMe]);
|
||||
state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]);
|
||||
state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
|
||||
|
||||
setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: undefined } });
|
||||
|
||||
expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy();
|
||||
expect(state.selectedItems.dashboard[sharedWithMeDashboard.uid]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("doesn't select anything when on the sharedwithme folder page", () => {
|
||||
const state = createInitialState();
|
||||
|
||||
state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]);
|
||||
state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
|
||||
state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]);
|
||||
|
||||
setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: sharedWithMe.uid } });
|
||||
|
||||
expect(state.selectedItems).toEqual({
|
||||
$all: false,
|
||||
dashboard: {},
|
||||
folder: {},
|
||||
panel: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||
|
||||
import { isSharedWithMe } from '../components/utils';
|
||||
import { BrowseDashboardsState } from '../types';
|
||||
|
||||
import { fetchNextChildrenPage, refetchChildren } from './actions';
|
||||
@@ -86,6 +87,11 @@ export function setItemSelectionState(
|
||||
) {
|
||||
const { item, isSelected } = action.payload;
|
||||
|
||||
// UI shouldn't allow it, but also prevent sharedwithme from being selected
|
||||
if (isSharedWithMe(item.uid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Selecting a folder selects all children, and unselecting a folder deselects all children
|
||||
// so propagate the new selection state to all descendants
|
||||
function markChildren(kind: DashboardViewItemKind, uid: string) {
|
||||
@@ -103,26 +109,24 @@ export function setItemSelectionState(
|
||||
|
||||
markChildren(item.kind, item.uid);
|
||||
|
||||
// If all children of a folder are selected, then the folder is also selected.
|
||||
// If *any* child of a folder is unselelected, then the folder is alo unselected.
|
||||
// Reconcile all ancestors to make sure they're in the correct state.
|
||||
let nextParentUID = item.parentUID;
|
||||
// If we're unselecting a child, we also need to unselect all ancestors.
|
||||
if (!isSelected) {
|
||||
let nextParentUID = item.parentUID;
|
||||
|
||||
while (nextParentUID) {
|
||||
const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID);
|
||||
while (nextParentUID) {
|
||||
const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID);
|
||||
|
||||
// This case should not happen, but a find can theortically return undefined, and it
|
||||
// helps limit infinite loops
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
// This case should not happen, but a find can theortically return undefined, and it
|
||||
// helps limit infinite loops
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isSelected) {
|
||||
// A folder cannot be selected if any of it's children are unselected
|
||||
state.selectedItems[parent.kind][parent.uid] = false;
|
||||
}
|
||||
|
||||
nextParentUID = parent.parentUID;
|
||||
nextParentUID = parent.parentUID;
|
||||
}
|
||||
}
|
||||
|
||||
// Check to see if we should mark the header checkbox selected if all root items are selected
|
||||
@@ -135,6 +139,12 @@ export function setAllSelection(
|
||||
) {
|
||||
const { isSelected, folderUID: folderUIDArg } = action.payload;
|
||||
|
||||
// If we're in the folder view for sharedwith me (currently not supported)
|
||||
// bail and don't select anything
|
||||
if (folderUIDArg && isSharedWithMe(folderUIDArg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedItems.$all = isSelected;
|
||||
|
||||
// Search works a bit differently so the state here does different things...
|
||||
@@ -146,6 +156,11 @@ export function setAllSelection(
|
||||
if (isSelected) {
|
||||
// Recursively select the children of the folder in view
|
||||
function selectChildrenOfFolder(folderUID: string | undefined) {
|
||||
// Don't descend into the sharedwithme folder
|
||||
if (folderUID && isSharedWithMe(folderUID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = folderUID ? state.childrenByParentUID[folderUID] : state.rootItems;
|
||||
|
||||
// Bail early if the collection isn't found (not loaded yet)
|
||||
@@ -154,6 +169,11 @@ export function setAllSelection(
|
||||
}
|
||||
|
||||
for (const child of collection.items) {
|
||||
// Don't traverse into the sharedwithme folder
|
||||
if (isSharedWithMe(child.uid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state.selectedItems[child.kind][child.uid] = isSelected;
|
||||
|
||||
if (child.kind !== 'folder') {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface BrowseDashboardsState {
|
||||
|
||||
export interface UIDashboardViewItem {
|
||||
kind: 'ui';
|
||||
uiKind: 'empty-folder' | 'pagination-placeholder';
|
||||
uiKind: 'empty-folder' | 'pagination-placeholder' | 'divider';
|
||||
uid: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = [])
|
||||
navItem = enrichHelpItem({ ...navItem });
|
||||
delete navItem.url;
|
||||
}
|
||||
const { url, target, text, isCreateAction, children, onClick } = navItem;
|
||||
const { url, target, text, isCreateAction, children, onClick, keywords } = navItem;
|
||||
const hasChildren = Boolean(children?.length);
|
||||
|
||||
if (!(url || onClick || hasChildren)) {
|
||||
@@ -44,6 +44,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = [])
|
||||
target,
|
||||
parent: parents.length > 0 && !isCreateAction ? idForNavItem(parents[parents.length - 1]) : undefined,
|
||||
perform: onClick,
|
||||
keywords: keywords?.join(' '),
|
||||
priority: priority,
|
||||
subtitle: isCreateAction ? undefined : subtitle,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { activateFullSceneTree } from '../utils/test-utils';
|
||||
|
||||
import { VERSIONS_FETCH_LIMIT, VersionsEditView } from './VersionsEditView';
|
||||
import { historySrv } from './version-history';
|
||||
|
||||
jest.mock('./version-history/HistorySrv');
|
||||
|
||||
describe('VersionsEditView', () => {
|
||||
describe('Dashboard Versions state', () => {
|
||||
let dashboard: DashboardScene;
|
||||
let versionsView: VersionsEditView;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions());
|
||||
|
||||
const result = await buildTestScene();
|
||||
dashboard = result.dashboard;
|
||||
versionsView = result.versionsView;
|
||||
});
|
||||
|
||||
it('should return the correct urlKey', () => {
|
||||
expect(versionsView.getUrlKey()).toBe('versions');
|
||||
});
|
||||
|
||||
it('should return the dashboard', () => {
|
||||
expect(versionsView.getDashboard()).toBe(dashboard);
|
||||
});
|
||||
|
||||
it('should return the decorated list of versions', () => {
|
||||
const versions = versionsView.versions;
|
||||
|
||||
expect(versions).toHaveLength(2);
|
||||
expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01');
|
||||
expect(versions[0].ageString).toBe('7 years ago');
|
||||
expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01');
|
||||
expect(versions[1].ageString).toBe('7 years ago');
|
||||
});
|
||||
|
||||
it('should bump the start threshold when fetching more versions', async () => {
|
||||
expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT);
|
||||
|
||||
versionsView.fetchVersions(true);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getVersions() {
|
||||
return [
|
||||
{
|
||||
id: 4,
|
||||
dashboardId: 1,
|
||||
dashboardUID: '_U4zObQMz',
|
||||
parentVersion: 3,
|
||||
restoredFrom: 0,
|
||||
version: 4,
|
||||
created: '2017-02-22T17:43:01-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
dashboardId: 1,
|
||||
dashboardUID: '_U4zObQMz',
|
||||
parentVersion: 1,
|
||||
restoredFrom: 1,
|
||||
version: 3,
|
||||
created: '2017-02-22T17:43:01-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function buildTestScene() {
|
||||
const versionsView = new VersionsEditView({ versions: [] });
|
||||
const dashboard = new DashboardScene({
|
||||
$timeRange: new SceneTimeRange({}),
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
meta: {
|
||||
canEdit: true,
|
||||
},
|
||||
body: new SceneGridLayout({
|
||||
children: [
|
||||
new SceneGridItem({
|
||||
key: 'griditem-1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 12,
|
||||
body: undefined,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
editview: versionsView,
|
||||
});
|
||||
|
||||
activateFullSceneTree(dashboard);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
dashboard.onEnterEditMode();
|
||||
versionsView.activate();
|
||||
|
||||
return { dashboard, versionsView };
|
||||
}
|
||||
@@ -1,36 +1,139 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PageLayoutType } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
|
||||
import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
|
||||
import { HorizontalGroup, Spinner } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
|
||||
import { RevisionsModel, VersionHistoryTable, historySrv } from './version-history';
|
||||
|
||||
export interface VersionsEditViewState extends DashboardEditViewState {}
|
||||
export const VERSIONS_FETCH_LIMIT = 10;
|
||||
|
||||
export type DecoratedRevisionModel = RevisionsModel & {
|
||||
createdDateString: string;
|
||||
ageString: string;
|
||||
};
|
||||
|
||||
export interface VersionsEditViewState extends DashboardEditViewState {
|
||||
versions?: DecoratedRevisionModel[];
|
||||
isLoading?: boolean;
|
||||
isAppending?: boolean;
|
||||
}
|
||||
|
||||
export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> implements DashboardEditView {
|
||||
public static Component = VersionsEditorSettingsListView;
|
||||
private _limit: number = VERSIONS_FETCH_LIMIT;
|
||||
private _start = 0;
|
||||
|
||||
constructor(state: VersionsEditViewState) {
|
||||
super({
|
||||
...state,
|
||||
versions: [],
|
||||
isLoading: true,
|
||||
isAppending: true,
|
||||
});
|
||||
|
||||
this.addActivationHandler(() => {
|
||||
this.fetchVersions();
|
||||
});
|
||||
}
|
||||
|
||||
private get _dashboard(): DashboardScene {
|
||||
return getDashboardSceneFor(this);
|
||||
}
|
||||
|
||||
public get versions(): DecoratedRevisionModel[] {
|
||||
return this.state.versions ?? [];
|
||||
}
|
||||
|
||||
public get limit(): number {
|
||||
return this._limit;
|
||||
}
|
||||
|
||||
public get start(): number {
|
||||
return this._start;
|
||||
}
|
||||
|
||||
public getUrlKey(): string {
|
||||
return 'versions';
|
||||
}
|
||||
|
||||
public getDashboard(): DashboardScene {
|
||||
return getDashboardSceneFor(this);
|
||||
return this._dashboard;
|
||||
}
|
||||
|
||||
public getTimeRange() {
|
||||
return sceneGraph.getTimeRange(this._dashboard);
|
||||
}
|
||||
|
||||
public fetchVersions(append = false): void {
|
||||
const uid = this._dashboard.state.uid;
|
||||
|
||||
if (!uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isAppending: append });
|
||||
|
||||
historySrv
|
||||
.getHistoryList(uid, { limit: this._limit, start: this._start })
|
||||
.then((result) => {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)],
|
||||
});
|
||||
this._start += this._limit;
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
.finally(() => this.setState({ isAppending: false }));
|
||||
}
|
||||
|
||||
private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] {
|
||||
const timeZone = this.getTimeRange().getTimeZone();
|
||||
|
||||
return versions.map((version) => {
|
||||
return {
|
||||
...version,
|
||||
createdDateString: dateTimeFormat(version.created, { timeZone: timeZone }),
|
||||
ageString: dateTimeFormatTimeAgo(version.created, { timeZone: timeZone }),
|
||||
checked: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function VersionsEditorSettingsListView({ model }: SceneComponentProps<VersionsEditView>) {
|
||||
const dashboard = model.getDashboard();
|
||||
|
||||
const { isLoading, isAppending } = model.useState();
|
||||
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
|
||||
|
||||
const canCompare = model.versions.filter((version) => version.checked).length === 2;
|
||||
|
||||
return (
|
||||
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
|
||||
<div>TODO</div>
|
||||
{isLoading ? (
|
||||
<VersionsHistorySpinner msg="Fetching history list…" />
|
||||
) : (
|
||||
<VersionHistoryTable
|
||||
versions={model.versions}
|
||||
onCheck={(x, y) => {
|
||||
console.log('todo');
|
||||
}}
|
||||
canCompare={canCompare}
|
||||
/>
|
||||
)}
|
||||
{isAppending && <VersionsHistorySpinner msg="Fetching more entries…" />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
|
||||
<HorizontalGroup>
|
||||
<Spinner />
|
||||
<em>{msg}</em>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { last } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DiffTitle } from './DiffTitle';
|
||||
import { DiffValues } from './DiffValues';
|
||||
import { Diff, getDiffText } from './utils';
|
||||
|
||||
type DiffGroupProps = {
|
||||
diffs: Diff[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const DiffGroup = ({ diffs, title }: DiffGroupProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (diffs.length === 1) {
|
||||
return (
|
||||
<div className={styles.container} data-testid="diffGroup">
|
||||
<DiffTitle title={title} diff={diffs[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="diffGroup">
|
||||
<DiffTitle title={title} />
|
||||
<ul className={styles.list}>
|
||||
{diffs.map((diff: Diff, idx: number) => {
|
||||
return (
|
||||
<li className={styles.listItem} key={`${last(diff.path)}__${idx}`}>
|
||||
<span>{getDiffText(diff)}</span> <DiffValues diff={diff} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
'background-color': theme.colors.background.secondary,
|
||||
'font-size': theme.typography.h6.fontSize,
|
||||
'margin-bottom': theme.spacing(2),
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
list: css({
|
||||
'margin-left': theme.spacing(4),
|
||||
}),
|
||||
listItem: css({
|
||||
'margin-bottom': theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Icon } from '@grafana/ui';
|
||||
|
||||
import { DiffValues } from './DiffValues';
|
||||
import { Diff, getDiffText } from './utils';
|
||||
|
||||
type DiffTitleProps = {
|
||||
diff?: Diff;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 };
|
||||
|
||||
export const DiffTitle = ({ diff, title }: DiffTitleProps) => {
|
||||
const styles = useStyles2(getDiffTitleStyles);
|
||||
|
||||
return diff ? (
|
||||
<>
|
||||
<Icon type="mono" name="circle" className={styles[diff.op]} /> <span className={styles.embolden}>{title}</span>{' '}
|
||||
<span>{getDiffText(diff, diff.path.length > 1)}</span> <DiffValues diff={diff} />
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.withoutDiff}>
|
||||
<Icon type="mono" name="circle" className={styles.replace} /> <span className={styles.embolden}>{title}</span>{' '}
|
||||
<span>{getDiffText(replaceDiff, false)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getDiffTitleStyles = (theme: GrafanaTheme2) => ({
|
||||
embolden: css({
|
||||
'font-weight': `${theme.typography.fontWeightBold}`,
|
||||
}),
|
||||
add: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
replace: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
move: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
copy: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
_get: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
test: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
remove: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
withoutDiff: css({
|
||||
'margin-bottom': theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isArray, isObject, isUndefined } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Icon } from '@grafana/ui';
|
||||
|
||||
import { Diff } from './utils';
|
||||
|
||||
type DiffProps = {
|
||||
diff: Diff;
|
||||
};
|
||||
|
||||
export const DiffValues = ({ diff }: DiffProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const hasLeftValue =
|
||||
!isUndefined(diff.originalValue) && !isArray(diff.originalValue) && !isObject(diff.originalValue);
|
||||
const hasRightValue = !isUndefined(diff.value) && !isArray(diff.value) && !isObject(diff.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLeftValue && <span className={styles}>{String(diff.originalValue)}</span>}
|
||||
{hasLeftValue && hasRightValue ? <Icon name="arrow-right" /> : null}
|
||||
{hasRightValue && <span className={styles}>{String(diff.value)}</span>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) =>
|
||||
css({
|
||||
'background-color': theme.colors.action.hover,
|
||||
'border-radius': theme.shape.radius.default,
|
||||
color: theme.colors.text.primary,
|
||||
'font-size': theme.typography.body.fontSize,
|
||||
margin: `0 ${theme.spacing(0.5)}`,
|
||||
padding: theme.spacing(0.5, 1),
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
const styles = {
|
||||
variables: {
|
||||
// the light theme supplied by ReactDiffViewer is very similar to Grafana
|
||||
// the dark theme needs some tweaks.
|
||||
dark: {
|
||||
diffViewerBackground: theme.colors.background.canvas,
|
||||
diffViewerColor: theme.colors.text.primary,
|
||||
addedBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.3).toString(),
|
||||
addedColor: 'white',
|
||||
removedBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.3).toString(),
|
||||
removedColor: 'white',
|
||||
wordAddedBackground: tinycolor(theme.v1.palette.greenBase).setAlpha(0.4).toString(),
|
||||
wordRemovedBackground: tinycolor(theme.v1.palette.redBase).setAlpha(0.4).toString(),
|
||||
addedGutterBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.2).toString(),
|
||||
removedGutterBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.2).toString(),
|
||||
gutterBackground: theme.colors.background.primary,
|
||||
gutterBackgroundDark: theme.colors.background.primary,
|
||||
highlightBackground: tinycolor(theme.colors.primary.main).setAlpha(0.4).toString(),
|
||||
highlightGutterBackground: tinycolor(theme.colors.primary.shade).setAlpha(0.2).toString(),
|
||||
codeFoldGutterBackground: theme.colors.background.secondary,
|
||||
codeFoldBackground: theme.colors.background.secondary,
|
||||
emptyLineBackground: theme.colors.background.secondary,
|
||||
gutterColor: theme.colors.text.disabled,
|
||||
addedGutterColor: theme.colors.text.primary,
|
||||
removedGutterColor: theme.colors.text.primary,
|
||||
codeFoldContentColor: theme.colors.text.disabled,
|
||||
diffViewerTitleBackground: theme.colors.background.secondary,
|
||||
diffViewerTitleColor: theme.colors.text.disabled,
|
||||
diffViewerTitleBorderColor: theme.colors.border.strong,
|
||||
},
|
||||
},
|
||||
codeFold: {
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
},
|
||||
gutter: `
|
||||
pre {
|
||||
color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()};
|
||||
opacity: 0.61;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
'font-size': theme.typography.bodySmall.fontSize,
|
||||
// prevent global styles interfering with diff viewer
|
||||
pre: {
|
||||
all: 'revert',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ReactDiffViewer
|
||||
styles={styles}
|
||||
oldValue={oldValue}
|
||||
newValue={newValue}
|
||||
splitView={false}
|
||||
compareMethod={DiffMethod.CSS}
|
||||
useDarkTheme={theme.isDark}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
|
||||
|
||||
import { HistorySrv } from './HistorySrv';
|
||||
import { restore, versions } from './__mocks__/dashboardHistoryMocks';
|
||||
|
||||
const getMock = jest.fn().mockResolvedValue({});
|
||||
const postMock = jest.fn().mockResolvedValue({});
|
||||
|
||||
jest.mock('app/core/store');
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const original = jest.requireActual('@grafana/runtime');
|
||||
|
||||
return {
|
||||
...original,
|
||||
getBackendSrv: () => ({
|
||||
post: postMock,
|
||||
get: getMock,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('historySrv', () => {
|
||||
const versionsResponse = versions();
|
||||
const restoreResponse = restore;
|
||||
|
||||
let historySrv = new HistorySrv();
|
||||
|
||||
const dash = createDashboardModelFixture({ uid: '_U4zObQMz' });
|
||||
const emptyDash = createDashboardModelFixture();
|
||||
const historyListOpts = { limit: 10, start: 0 };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getHistoryList', () => {
|
||||
it('should return a versions array for the given dashboard id', () => {
|
||||
getMock.mockImplementation(() => Promise.resolve(versionsResponse));
|
||||
historySrv = new HistorySrv();
|
||||
|
||||
return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => {
|
||||
expect(versions).toEqual(versionsResponse);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array when not given an id', () => {
|
||||
return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => {
|
||||
expect(versions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array when not given a dashboard id', () => {
|
||||
return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => {
|
||||
expect(versions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreDashboard', () => {
|
||||
it('should return a success response given valid parameters', () => {
|
||||
const version = 6;
|
||||
postMock.mockImplementation(() => Promise.resolve(restoreResponse(version)));
|
||||
historySrv = new HistorySrv();
|
||||
return historySrv.restoreDashboard(dash, version).then((response) => {
|
||||
expect(response).toEqual(restoreResponse(version));
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty object when not given an id', async () => {
|
||||
historySrv = new HistorySrv();
|
||||
const rsp = await historySrv.restoreDashboard(emptyDash, 6);
|
||||
expect(rsp).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
|
||||
export interface HistoryListOpts {
|
||||
limit: number;
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface RevisionsModel {
|
||||
id: number;
|
||||
checked: boolean;
|
||||
dashboardUID: string;
|
||||
parentVersion: number;
|
||||
version: number;
|
||||
created: Date;
|
||||
createdBy: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DiffTarget {
|
||||
dashboardUID: string;
|
||||
version: number;
|
||||
unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version
|
||||
}
|
||||
|
||||
export class HistorySrv {
|
||||
getHistoryList(dashboardUID: string, options: HistoryListOpts) {
|
||||
if (typeof dashboardUID !== 'string') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options);
|
||||
}
|
||||
|
||||
getDashboardVersion(uid: string, version: number) {
|
||||
return getBackendSrv().get(`api/dashboards/uid/${uid}/versions/${version}`);
|
||||
}
|
||||
|
||||
restoreDashboard(dashboard: DashboardModel, version: number) {
|
||||
const uid = dashboard && dashboard.uid ? dashboard.uid : void 0;
|
||||
const url = `api/dashboards/uid/${uid}/restore`;
|
||||
|
||||
return uid && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({});
|
||||
}
|
||||
}
|
||||
|
||||
const historySrv = new HistorySrv();
|
||||
export { historySrv };
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
|
||||
import { useDashboardRestore } from './useDashboardRestore';
|
||||
export interface RevertDashboardModalProps {
|
||||
hideModal: () => void;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => {
|
||||
// TODO: how should state.error be handled?
|
||||
const { state, onRestoreDashboard } = useDashboardRestore(version);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.loading && state.value) {
|
||||
hideModal();
|
||||
}
|
||||
}, [state, hideModal]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title="Restore Version"
|
||||
icon="history"
|
||||
onDismiss={hideModal}
|
||||
onConfirm={onRestoreDashboard}
|
||||
body={
|
||||
<p>Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.</p>
|
||||
}
|
||||
confirmText={`Yes, restore to version ${version}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Tooltip, Button, Stack } from '@grafana/ui';
|
||||
|
||||
type VersionsButtonsType = {
|
||||
hasMore: boolean;
|
||||
canCompare: boolean;
|
||||
getVersions: (append: boolean) => void;
|
||||
getDiff: () => void;
|
||||
isLastPage: boolean;
|
||||
};
|
||||
export const VersionsHistoryButtons = ({
|
||||
hasMore,
|
||||
canCompare,
|
||||
getVersions,
|
||||
getDiff,
|
||||
isLastPage,
|
||||
}: VersionsButtonsType) => (
|
||||
<Stack>
|
||||
{hasMore && (
|
||||
<Button type="button" onClick={() => getVersions(true)} variant="secondary" disabled={isLastPage}>
|
||||
Show more versions
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip content="Select two versions to start comparing" placement="bottom">
|
||||
<Button type="button" disabled={!canCompare} onClick={getDiff} icon="code-branch">
|
||||
Compare versions
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { DecoratedRevisionModel } from '../VersionsEditView';
|
||||
|
||||
import { DiffGroup } from './DiffGroup';
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
import { RevertDashboardModal } from './RevertDashboardModal';
|
||||
import { jsonDiff } from './utils';
|
||||
|
||||
type DiffViewProps = {
|
||||
isNewLatest: boolean;
|
||||
newInfo: DecoratedRevisionModel;
|
||||
baseInfo: DecoratedRevisionModel;
|
||||
diffData: { lhs: string; rhs: string };
|
||||
};
|
||||
|
||||
export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => {
|
||||
const diff = jsonDiff(diffData.lhs, diffData.rhs);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.spacer}>
|
||||
<HorizontalGroup justify="space-between" align="center">
|
||||
<div>
|
||||
<p className={styles.versionInfo}>
|
||||
<strong>Version {newInfo.version}</strong> updated by {newInfo.createdBy} {newInfo.ageString} -{' '}
|
||||
{newInfo.message}
|
||||
</p>
|
||||
<p className={cx(styles.versionInfo, styles.noMarginBottom)}>
|
||||
<strong>Version {baseInfo.version}</strong> updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '}
|
||||
{baseInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
{isNewLatest && (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => (
|
||||
<Button
|
||||
variant="destructive"
|
||||
icon="history"
|
||||
onClick={() => {
|
||||
showModal(RevertDashboardModal, {
|
||||
version: baseInfo.version,
|
||||
hideModal,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Restore to version {baseInfo.version}
|
||||
</Button>
|
||||
)}
|
||||
</ModalsController>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={styles.spacer}>
|
||||
{Object.entries(diff).map(([key, diffs]) => (
|
||||
<DiffGroup diffs={diffs} key={key} title={key} />
|
||||
))}
|
||||
</div>
|
||||
<CollapsableSection isOpen={false} label="View JSON Diff">
|
||||
<DiffViewer oldValue={JSON.stringify(diffData.lhs, null, 2)} newValue={JSON.stringify(diffData.rhs, null, 2)} />
|
||||
</CollapsableSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
spacer: css({
|
||||
'margin-bottom': theme.spacing(4),
|
||||
}),
|
||||
versionInfo: css({
|
||||
color: theme.colors.text.secondary,
|
||||
'font-size': theme.typography.bodySmall.fontSize,
|
||||
}),
|
||||
noMarginBottom: css({
|
||||
'margin-bottom': 0,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { noop } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
type VersionHistoryHeaderProps = {
|
||||
onClick?: () => void;
|
||||
baseVersion?: number;
|
||||
newVersion?: number;
|
||||
isNewLatest?: boolean;
|
||||
};
|
||||
|
||||
export const VersionHistoryHeader = ({
|
||||
onClick = noop,
|
||||
baseVersion = 0,
|
||||
newVersion = 0,
|
||||
isNewLatest = false,
|
||||
}: VersionHistoryHeaderProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<h3 className={styles.header}>
|
||||
<IconButton name="arrow-left" size="xl" onClick={onClick} tooltip="Reset version" />
|
||||
<span>
|
||||
Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '}
|
||||
{isNewLatest && <cite className="muted">(Latest)</cite>}
|
||||
</span>
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
header: css({
|
||||
'font-size': theme.typography.h3.fontSize,
|
||||
display: 'flex',
|
||||
gap: theme.spacing(2),
|
||||
'margin-bottom': theme.spacing(3),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui';
|
||||
|
||||
import { DecoratedRevisionModel } from '../VersionsEditView';
|
||||
|
||||
import { RevertDashboardModal } from './RevertDashboardModal';
|
||||
|
||||
type VersionsTableProps = {
|
||||
versions: DecoratedRevisionModel[];
|
||||
canCompare: boolean;
|
||||
onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void;
|
||||
};
|
||||
|
||||
export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => (
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="width-4"></th>
|
||||
<th className="width-4">Version</th>
|
||||
<th className="width-14">Date</th>
|
||||
<th className="width-10">Updated by</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map((version, idx) => (
|
||||
<tr key={version.id}>
|
||||
<td>
|
||||
<Checkbox
|
||||
aria-label={`Toggle selection of version ${version.version}`}
|
||||
className={css({
|
||||
display: 'inline',
|
||||
})}
|
||||
checked={version.checked}
|
||||
onChange={(ev) => onCheck(ev, version.id)}
|
||||
disabled={!version.checked && canCompare}
|
||||
/>
|
||||
</td>
|
||||
<td>{version.version}</td>
|
||||
<td>{version.createdDateString}</td>
|
||||
<td>{version.createdBy}</td>
|
||||
<td>{version.message}</td>
|
||||
<td className="text-right">
|
||||
{idx === 0 ? (
|
||||
<Tag name="Latest" colorIndex={17} />
|
||||
) : (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="history"
|
||||
onClick={() => {
|
||||
showModal(RevertDashboardModal, {
|
||||
version: version.version,
|
||||
hideModal,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</ModalsController>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
export function versions() {
|
||||
return [
|
||||
{
|
||||
id: 4,
|
||||
dashboardId: 1,
|
||||
dashboardUID: '_U4zObQMz',
|
||||
parentVersion: 3,
|
||||
restoredFrom: 0,
|
||||
version: 4,
|
||||
created: '2017-02-22T17:43:01-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
dashboardId: 1,
|
||||
dashboardUID: '_U4zObQMz',
|
||||
parentVersion: 1,
|
||||
restoredFrom: 1,
|
||||
version: 3,
|
||||
created: '2017-02-22T17:43:01-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
dashboardId: 1,
|
||||
dashboardUID: '_U4zObQMz',
|
||||
parentVersion: 0,
|
||||
restoredFrom: -1,
|
||||
version: 2,
|
||||
created: '2017-02-22T17:29:52-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
dashboardId: 1,
|
||||
dashboardUID: '_U4zObQMz',
|
||||
parentVersion: 0,
|
||||
restoredFrom: -1,
|
||||
slug: 'history-dashboard',
|
||||
version: 1,
|
||||
created: '2017-02-22T17:06:37-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function restore(version: number, restoredFrom?: number) {
|
||||
return {
|
||||
dashboard: {
|
||||
meta: {
|
||||
type: 'db',
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canStar: true,
|
||||
slug: 'history-dashboard',
|
||||
expires: '0001-01-01T00:00:00Z',
|
||||
created: '2017-02-21T18:40:45-08:00',
|
||||
updated: '2017-04-11T21:31:22.59219665-07:00',
|
||||
updatedBy: 'admin',
|
||||
createdBy: 'admin',
|
||||
version: version,
|
||||
},
|
||||
dashboard: {
|
||||
annotations: {
|
||||
list: [],
|
||||
},
|
||||
description: 'A random dashboard for implementing the history list',
|
||||
editable: true,
|
||||
gnetId: null,
|
||||
graphTooltip: 0,
|
||||
id: 1,
|
||||
uid: '_U4zObQMz',
|
||||
links: [],
|
||||
restoredFrom: restoredFrom,
|
||||
rows: [
|
||||
{
|
||||
collapse: false,
|
||||
height: '250px',
|
||||
panels: [
|
||||
{
|
||||
aliasColors: {},
|
||||
bars: false,
|
||||
datasource: null,
|
||||
fill: 1,
|
||||
id: 1,
|
||||
legend: {
|
||||
avg: false,
|
||||
current: false,
|
||||
max: false,
|
||||
min: false,
|
||||
show: true,
|
||||
total: false,
|
||||
values: false,
|
||||
},
|
||||
lines: true,
|
||||
linewidth: 1,
|
||||
nullPointMode: 'null',
|
||||
percentage: false,
|
||||
pointradius: 5,
|
||||
points: false,
|
||||
renderer: 'flot',
|
||||
seriesOverrides: [],
|
||||
span: 12,
|
||||
stack: false,
|
||||
steppedLine: false,
|
||||
targets: [{}],
|
||||
thresholds: [],
|
||||
timeFrom: null,
|
||||
timeShift: null,
|
||||
title: 'Panel Title',
|
||||
tooltip: {
|
||||
shared: true,
|
||||
sort: 0,
|
||||
value_type: 'individual',
|
||||
},
|
||||
type: 'graph',
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
name: null,
|
||||
show: true,
|
||||
values: [],
|
||||
},
|
||||
yaxes: [
|
||||
{
|
||||
format: 'short',
|
||||
label: null,
|
||||
logBase: 1,
|
||||
max: null,
|
||||
min: null,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
format: 'short',
|
||||
label: null,
|
||||
logBase: 1,
|
||||
max: null,
|
||||
min: null,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
repeat: null,
|
||||
repeatIteration: null,
|
||||
repeatRowId: null,
|
||||
showTitle: false,
|
||||
title: 'Dashboard Row',
|
||||
titleSize: 'h6',
|
||||
},
|
||||
],
|
||||
schemaVersion: 14,
|
||||
tags: ['development'],
|
||||
templating: {
|
||||
list: [],
|
||||
},
|
||||
time: {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
timepicker: {
|
||||
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
|
||||
time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
|
||||
},
|
||||
timezone: 'utc',
|
||||
title: 'History Dashboard',
|
||||
version: version,
|
||||
},
|
||||
},
|
||||
message: 'Dashboard restored to version ' + version,
|
||||
version: version,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv';
|
||||
export { VersionHistoryTable } from './VersionHistoryTable';
|
||||
export { VersionHistoryHeader } from './VersionHistoryHeader';
|
||||
export { VersionsHistoryButtons } from './VersionHistoryButtons';
|
||||
export { VersionHistoryComparison } from './VersionHistoryComparison';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { useSelector } from 'app/types';
|
||||
|
||||
import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher';
|
||||
|
||||
import { historySrv } from './HistorySrv';
|
||||
|
||||
const restoreDashboard = async (version: number, dashboard: DashboardModel) => {
|
||||
// Skip the watcher logic for this save since it's handled by the hook
|
||||
dashboardWatcher.ignoreNextSave();
|
||||
return await historySrv.restoreDashboard(dashboard, version);
|
||||
};
|
||||
|
||||
export const useDashboardRestore = (version: number) => {
|
||||
const dashboard = useSelector((state) => state.dashboard.getModel());
|
||||
const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []);
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.value) {
|
||||
const location = locationService.getLocation();
|
||||
const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
|
||||
const prevState = (location.state as any)?.routeReloadCounter;
|
||||
locationService.replace({
|
||||
...location,
|
||||
pathname: newUrl,
|
||||
state: { routeReloadCounter: prevState ? prevState + 1 : 1 },
|
||||
});
|
||||
notifyApp.success('Dashboard restored', `Restored from version ${version}`);
|
||||
}
|
||||
}, [state, version, notifyApp]);
|
||||
return { state, onRestoreDashboard };
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
|
||||
import { Diff, getDiffOperationText, getDiffText, jsonDiff } from './utils';
|
||||
|
||||
describe('getDiffOperationText', () => {
|
||||
const cases = [
|
||||
['add', 'added'],
|
||||
['remove', 'deleted'],
|
||||
['replace', 'changed'],
|
||||
['byDefault', 'changed'],
|
||||
];
|
||||
|
||||
test.each(cases)('it returns the correct verb for an operation', (operation, expected) => {
|
||||
expect(getDiffOperationText(operation)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
type DiffTextCase = [Partial<Diff>, string];
|
||||
describe('getDiffText', () => {
|
||||
const addEmptyArray: DiffTextCase = [
|
||||
{ op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 },
|
||||
'added list',
|
||||
];
|
||||
const addArrayNumericProp: DiffTextCase = [
|
||||
{
|
||||
op: 'add',
|
||||
value: ['tag'],
|
||||
path: ['panels', '3'],
|
||||
},
|
||||
'added item 3',
|
||||
];
|
||||
const addArrayProp: DiffTextCase = [
|
||||
{
|
||||
op: 'add',
|
||||
value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
|
||||
path: ['panels', '3', 'targets'],
|
||||
},
|
||||
'added 2 targets',
|
||||
];
|
||||
const addValueNumericProp: DiffTextCase = [
|
||||
{
|
||||
op: 'add',
|
||||
value: 'foo',
|
||||
path: ['panels', '3'],
|
||||
},
|
||||
'added item 3',
|
||||
];
|
||||
const addValueProp: DiffTextCase = [
|
||||
{
|
||||
op: 'add',
|
||||
value: 'foo',
|
||||
path: ['panels', '3', 'targets'],
|
||||
},
|
||||
'added targets',
|
||||
];
|
||||
|
||||
const removeEmptyArray: DiffTextCase = [
|
||||
{ op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 },
|
||||
'deleted list',
|
||||
];
|
||||
const removeArrayNumericProp: DiffTextCase = [
|
||||
{
|
||||
op: 'remove',
|
||||
originalValue: ['tag'],
|
||||
path: ['panels', '3'],
|
||||
},
|
||||
'deleted item 3',
|
||||
];
|
||||
const removeArrayProp: DiffTextCase = [
|
||||
{
|
||||
op: 'remove',
|
||||
originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
|
||||
path: ['panels', '3', 'targets'],
|
||||
},
|
||||
'deleted 2 targets',
|
||||
];
|
||||
const removeValueNumericProp: DiffTextCase = [
|
||||
{
|
||||
op: 'remove',
|
||||
originalValue: 'foo',
|
||||
path: ['panels', '3'],
|
||||
},
|
||||
'deleted item 3',
|
||||
];
|
||||
const removeValueProp: DiffTextCase = [
|
||||
{
|
||||
op: 'remove',
|
||||
originalValue: 'foo',
|
||||
path: ['panels', '3', 'targets'],
|
||||
},
|
||||
'deleted targets',
|
||||
];
|
||||
const replaceValueNumericProp: DiffTextCase = [
|
||||
{
|
||||
op: 'replace',
|
||||
originalValue: 'foo',
|
||||
value: 'bar',
|
||||
path: ['panels', '3'],
|
||||
},
|
||||
'changed item 3',
|
||||
];
|
||||
const replaceValueProp: DiffTextCase = [
|
||||
{
|
||||
op: 'replace',
|
||||
originalValue: 'foo',
|
||||
value: 'bar',
|
||||
path: ['panels', '3', 'targets'],
|
||||
},
|
||||
'changed targets',
|
||||
];
|
||||
|
||||
const cases = [
|
||||
addEmptyArray,
|
||||
addArrayNumericProp,
|
||||
addArrayProp,
|
||||
addValueNumericProp,
|
||||
addValueProp,
|
||||
removeEmptyArray,
|
||||
removeArrayNumericProp,
|
||||
removeArrayProp,
|
||||
removeValueNumericProp,
|
||||
removeValueProp,
|
||||
replaceValueNumericProp,
|
||||
replaceValueProp,
|
||||
];
|
||||
|
||||
test.each(cases)(
|
||||
'returns a semantic message based on the type of diff, the values and the location of the change',
|
||||
(diff: Partial<Diff>, expected: string) => {
|
||||
expect(getDiffText(diff as unknown as Diff)).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('jsonDiff', () => {
|
||||
it('returns data related to each change', () => {
|
||||
const lhs = {
|
||||
annotations: {
|
||||
list: [
|
||||
{
|
||||
builtIn: 1,
|
||||
datasource: '-- Grafana --',
|
||||
enable: true,
|
||||
hide: true,
|
||||
iconColor: 'rgba(0, 211, 255, 1)',
|
||||
name: 'Annotations & Alerts',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
},
|
||||
editable: true,
|
||||
gnetId: null,
|
||||
graphTooltip: 0,
|
||||
id: 141,
|
||||
links: [],
|
||||
panels: [],
|
||||
schemaVersion: 27,
|
||||
tags: [],
|
||||
templating: {
|
||||
list: [],
|
||||
},
|
||||
time: {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
timepicker: {},
|
||||
timezone: '',
|
||||
title: 'test dashboard',
|
||||
uid: '_U4zObQMz',
|
||||
version: 2,
|
||||
};
|
||||
|
||||
const rhs = {
|
||||
annotations: {
|
||||
list: [
|
||||
{
|
||||
builtIn: 1,
|
||||
datasource: '-- Grafana --',
|
||||
enable: true,
|
||||
hide: true,
|
||||
iconColor: 'rgba(0, 211, 255, 1)',
|
||||
name: 'Annotations & Alerts',
|
||||
type: 'dashboard',
|
||||
},
|
||||
],
|
||||
},
|
||||
description: 'a description',
|
||||
editable: true,
|
||||
gnetId: null,
|
||||
graphTooltip: 1,
|
||||
id: 141,
|
||||
links: [],
|
||||
panels: [
|
||||
{
|
||||
type: 'graph',
|
||||
},
|
||||
],
|
||||
schemaVersion: 27,
|
||||
tags: ['the tag'],
|
||||
templating: {
|
||||
list: [],
|
||||
},
|
||||
time: {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
timepicker: {
|
||||
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
|
||||
},
|
||||
timezone: 'utc',
|
||||
title: 'My favourite dashboard',
|
||||
uid: '_U4zObQMz',
|
||||
version: 3,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
description: [
|
||||
{
|
||||
op: 'add',
|
||||
originalValue: undefined,
|
||||
path: ['description'],
|
||||
startLineNumber: 14,
|
||||
value: 'a description',
|
||||
},
|
||||
],
|
||||
graphTooltip: [
|
||||
{
|
||||
op: 'replace',
|
||||
originalValue: 0,
|
||||
path: ['graphTooltip'],
|
||||
startLineNumber: 17,
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
panels: [
|
||||
{
|
||||
op: 'add',
|
||||
originalValue: undefined,
|
||||
path: ['panels', '0'],
|
||||
startLineNumber: 21,
|
||||
value: {
|
||||
type: 'graph',
|
||||
},
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
op: 'add',
|
||||
originalValue: undefined,
|
||||
path: ['tags', '0'],
|
||||
startLineNumber: 27,
|
||||
value: 'the tag',
|
||||
},
|
||||
],
|
||||
timepicker: [
|
||||
{
|
||||
op: 'add',
|
||||
originalValue: undefined,
|
||||
path: ['timepicker', 'refresh_intervals'],
|
||||
startLineNumber: 37,
|
||||
value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
|
||||
},
|
||||
],
|
||||
timezone: [
|
||||
{
|
||||
op: 'replace',
|
||||
originalValue: '',
|
||||
path: ['timezone'],
|
||||
startLineNumber: 51,
|
||||
value: 'utc',
|
||||
},
|
||||
],
|
||||
title: [
|
||||
{
|
||||
op: 'replace',
|
||||
originalValue: 'test dashboard',
|
||||
path: ['title'],
|
||||
startLineNumber: 52,
|
||||
value: 'My favourite dashboard',
|
||||
},
|
||||
],
|
||||
version: [
|
||||
{
|
||||
op: 'replace',
|
||||
originalValue: 2,
|
||||
path: ['version'],
|
||||
startLineNumber: 54,
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(jsonDiff(lhs as unknown as Dashboard, rhs as unknown as Dashboard)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { compare, Operation } from 'fast-json-patch';
|
||||
// @ts-ignore
|
||||
import jsonMap from 'json-source-map';
|
||||
import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash';
|
||||
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
|
||||
export type Diff = {
|
||||
op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move';
|
||||
value: unknown;
|
||||
originalValue: unknown;
|
||||
path: string[];
|
||||
startLineNumber: number;
|
||||
};
|
||||
|
||||
export type Diffs = {
|
||||
[key: string]: Diff[];
|
||||
};
|
||||
|
||||
export type JSONValue = string | Dashboard;
|
||||
|
||||
export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => {
|
||||
const diffs = compare(lhs, rhs);
|
||||
const lhsMap = jsonMap.stringify(lhs, null, 2);
|
||||
const rhsMap = jsonMap.stringify(rhs, null, 2);
|
||||
|
||||
const getDiffInformation = (diffs: Operation[]): Diff[] => {
|
||||
return diffs.map((diff) => {
|
||||
let originalValue = undefined;
|
||||
let value = undefined;
|
||||
let startLineNumber = 0;
|
||||
|
||||
const path = tail(diff.path.split('/'));
|
||||
|
||||
if (diff.op === 'replace' && rhsMap.pointers[diff.path]) {
|
||||
originalValue = get(lhs, path);
|
||||
value = diff.value;
|
||||
startLineNumber = rhsMap.pointers[diff.path].value.line;
|
||||
}
|
||||
if (diff.op === 'add' && rhsMap.pointers[diff.path]) {
|
||||
value = diff.value;
|
||||
startLineNumber = rhsMap.pointers[diff.path].value.line;
|
||||
}
|
||||
if (diff.op === 'remove' && lhsMap.pointers[diff.path]) {
|
||||
originalValue = get(lhs, path);
|
||||
startLineNumber = lhsMap.pointers[diff.path].value.line;
|
||||
}
|
||||
|
||||
return {
|
||||
op: diff.op,
|
||||
value,
|
||||
path,
|
||||
originalValue,
|
||||
startLineNumber,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber');
|
||||
const groupByPath = (diffs: Diff[]) =>
|
||||
diffs.reduce<Record<string, Diff[]>>((acc, value) => {
|
||||
const groupKey: string = value.path[0];
|
||||
if (!acc[groupKey]) {
|
||||
acc[groupKey] = [];
|
||||
}
|
||||
acc[groupKey].push(value);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs);
|
||||
};
|
||||
|
||||
export const getDiffText = (diff: Diff, showProp = true) => {
|
||||
const prop = last(diff.path)!;
|
||||
const propIsNumeric = isNumeric(prop);
|
||||
const val = diff.op === 'remove' ? diff.originalValue : diff.value;
|
||||
let text = getDiffOperationText(diff.op);
|
||||
|
||||
if (showProp) {
|
||||
if (propIsNumeric) {
|
||||
text += ` item ${prop}`;
|
||||
} else {
|
||||
if (isArray(val) && !isEmpty(val)) {
|
||||
text += ` ${val.length} ${prop}`;
|
||||
} else {
|
||||
text += ` ${prop}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const isNumeric = (value: string) => !isNaN(toNumber(value));
|
||||
|
||||
export const getDiffOperationText = (operation: string): string => {
|
||||
if (operation === 'add') {
|
||||
return 'added';
|
||||
}
|
||||
if (operation === 'remove') {
|
||||
return 'deleted';
|
||||
}
|
||||
return 'changed';
|
||||
};
|
||||
@@ -63,7 +63,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
||||
getVersions = (append = false) => {
|
||||
this.setState({ isAppending: append });
|
||||
historySrv
|
||||
.getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start })
|
||||
.getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start })
|
||||
.then((res) => {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
@@ -186,7 +186,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
|
||||
export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
|
||||
<HorizontalGroup>
|
||||
<Spinner />
|
||||
<em>{msg}</em>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
|
||||
|
||||
import { HistorySrv } from './HistorySrv';
|
||||
@@ -39,19 +38,19 @@ describe('historySrv', () => {
|
||||
getMock.mockImplementation(() => Promise.resolve(versionsResponse));
|
||||
historySrv = new HistorySrv();
|
||||
|
||||
return historySrv.getHistoryList(dash, historyListOpts).then((versions) => {
|
||||
return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => {
|
||||
expect(versions).toEqual(versionsResponse);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array when not given an id', () => {
|
||||
return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions) => {
|
||||
return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => {
|
||||
expect(versions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array when not given a dashboard', () => {
|
||||
return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions) => {
|
||||
it('should return an empty array when not given a dashboard id', () => {
|
||||
return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => {
|
||||
expect(versions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,12 @@ export interface DiffTarget {
|
||||
}
|
||||
|
||||
export class HistorySrv {
|
||||
getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) {
|
||||
const uid = dashboard && dashboard.uid ? dashboard.uid : void 0;
|
||||
return uid ? getBackendSrv().get(`api/dashboards/uid/${uid}/versions`, options) : Promise.resolve([]);
|
||||
getHistoryList(dashboardUID: string, options: HistoryListOpts) {
|
||||
if (typeof dashboardUID !== 'string') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options);
|
||||
}
|
||||
|
||||
getDashboardVersion(uid: string, version: number) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceInstanceSettings,
|
||||
DataSourceRef,
|
||||
MetricFindValue,
|
||||
ScopedVars,
|
||||
CoreApp,
|
||||
@@ -117,10 +116,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
||||
return !query.hide;
|
||||
}
|
||||
|
||||
applyTemplateVariables(
|
||||
target: SQLQuery,
|
||||
scopedVars: ScopedVars
|
||||
): Record<string, string | DataSourceRef | SQLQuery['format']> {
|
||||
applyTemplateVariables(target: SQLQuery, scopedVars: ScopedVars) {
|
||||
return {
|
||||
refId: target.refId,
|
||||
datasource: this.getRef(),
|
||||
|
||||
@@ -139,9 +139,14 @@ export class PanelQueryRunner {
|
||||
|
||||
if (withFieldConfig && data.series?.length) {
|
||||
if (lastConfigRev === this.dataConfigSource.configRev) {
|
||||
const streamingDataFrame = data.series.find((data) => isStreamingDataFrame(data)) as
|
||||
| StreamingDataFrame
|
||||
| undefined;
|
||||
let streamingDataFrame: StreamingDataFrame | undefined;
|
||||
|
||||
for (const frame of data.series) {
|
||||
if (isStreamingDataFrame(frame)) {
|
||||
streamingDataFrame = frame;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
streamingDataFrame &&
|
||||
|
||||
@@ -2,9 +2,15 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { ApplyFieldOverrideOptions, dateMath, FieldColorModeId, NavModelItem, PanelData } from '@grafana/data';
|
||||
import {
|
||||
ApplyFieldOverrideOptions,
|
||||
DataConfigSource,
|
||||
dateMath,
|
||||
FieldColorModeId,
|
||||
NavModelItem,
|
||||
PanelData,
|
||||
} from '@grafana/data';
|
||||
import { getPluginExtensions, isPluginExtensionLink } from '@grafana/runtime';
|
||||
import { DataTransformerConfig } from '@grafana/schema';
|
||||
import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { config } from 'app/core/config';
|
||||
@@ -129,8 +135,8 @@ export function getDefaultState(): State {
|
||||
theme: config.theme2,
|
||||
};
|
||||
|
||||
const dataConfig = {
|
||||
getTransformations: () => [] as DataTransformerConfig[],
|
||||
const dataConfig: DataConfigSource = {
|
||||
getTransformations: () => [],
|
||||
getFieldOverrideOptions: () => options,
|
||||
getDataSupport: () => ({ annotations: false, alertStates: false }),
|
||||
};
|
||||
|
||||
@@ -178,6 +178,7 @@ export const generateColumns = (
|
||||
return info ? (
|
||||
<a key={p} href={info.url} className={styles.locationItem}>
|
||||
<Icon name={getIconForKind(info.kind)} />
|
||||
|
||||
<Text variant="body" truncate>
|
||||
{info.name}
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { DataFrameView, IconName } from '@grafana/data';
|
||||
import { isSharedWithMe } from 'app/features/browse-dashboards/components/utils';
|
||||
import { DashboardViewItemWithUIItems } from 'app/features/browse-dashboards/types';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
|
||||
import { DashboardViewItem, DashboardViewItemKind } from '../types';
|
||||
@@ -50,6 +52,33 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
||||
return 'question-circle';
|
||||
}
|
||||
|
||||
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
||||
if (item && isSharedWithMe(item.uid)) {
|
||||
return 'users-alt';
|
||||
} else {
|
||||
return getIconForKind(item.kind, isOpen);
|
||||
}
|
||||
}
|
||||
|
||||
// export function getIconForItem(itemOrKind: string | DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
||||
// const kind = typeof itemOrKind === 'string' ? itemOrKind : itemOrKind.kind;
|
||||
// const item = typeof itemOrKind === 'string' ? undefined : itemOrKind;
|
||||
|
||||
// if (kind === 'dashboard') {
|
||||
// return 'apps';
|
||||
// }
|
||||
|
||||
// if (item && isSharedWithMe(item.uid)) {
|
||||
// return 'users-alt';
|
||||
// }
|
||||
|
||||
// if (kind === 'folder') {
|
||||
// return isOpen ? 'folder-open' : 'folder';
|
||||
// }
|
||||
|
||||
// return 'question-circle';
|
||||
// }
|
||||
|
||||
function parseKindString(kind: string): DashboardViewItemKind {
|
||||
switch (kind) {
|
||||
case 'dashboard':
|
||||
|
||||
@@ -102,9 +102,9 @@ class SimpleStorage implements GrafanaStorage {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
let body: UploadResponse = await res.json();
|
||||
let body = await res.json();
|
||||
if (!body) {
|
||||
body = {} as any;
|
||||
body = {};
|
||||
}
|
||||
body.status = res.status;
|
||||
body.statusText = res.statusText;
|
||||
|
||||
@@ -327,7 +327,8 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa
|
||||
: undefined,
|
||||
yMode: yBucketsCfg.mode,
|
||||
ySize: yBucketsCfg.value ? +yBucketsCfg.value : undefined,
|
||||
yLog: scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as any) : undefined,
|
||||
yLog:
|
||||
scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as 2 | 10 | undefined) : undefined,
|
||||
});
|
||||
|
||||
const frame = {
|
||||
|
||||
+5
-6
@@ -52,16 +52,15 @@ export const WindowOptionsEditor = (props: {
|
||||
});
|
||||
};
|
||||
|
||||
const onWindowSizeModeChange = (val: string) => {
|
||||
const mode = val as WindowSizeMode;
|
||||
const onWindowSizeModeChange = (val: WindowSizeMode) => {
|
||||
updateWindowOptions({
|
||||
...window!,
|
||||
windowSize: window?.windowSize
|
||||
? mode === WindowSizeMode.Percentage
|
||||
? val === WindowSizeMode.Percentage
|
||||
? window!.windowSize! / 100
|
||||
: window!.windowSize! * 100
|
||||
: undefined,
|
||||
windowSizeMode: mode,
|
||||
windowSizeMode: val,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -71,10 +70,10 @@ export const WindowOptionsEditor = (props: {
|
||||
updateWindowOptions({ ...window, reducer });
|
||||
};
|
||||
|
||||
const onTypeChange = (val: string) => {
|
||||
const onTypeChange = (val: WindowAlignment) => {
|
||||
updateWindowOptions({
|
||||
...window!,
|
||||
windowAlignment: val as WindowAlignment,
|
||||
windowAlignment: val,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -149,7 +149,12 @@ const createAdHocVariable = (options: AdHocTableOptions): ThunkResult<void> => {
|
||||
const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel | undefined => {
|
||||
const key = getLastKey(state);
|
||||
const templatingState = getVariablesState(key, state);
|
||||
return Object.values(templatingState.variables).find(
|
||||
(v) => isAdHoc(v) && v.datasource?.uid === options.datasource.uid
|
||||
) as AdHocVariableModel;
|
||||
let result: AdHocVariableModel | undefined;
|
||||
for (const v of Object.values(templatingState.variables)) {
|
||||
if (isAdHoc(v) && v.datasource?.uid === options.datasource.uid) {
|
||||
result = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@kusto/monaco-kusto": "^7.4.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"i18next": "^22.0.0",
|
||||
"immer": "10.0.2",
|
||||
"immer": "10.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"monaco-editor": "0.34.0",
|
||||
"prismjs": "1.29.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user