Merge branch 'main' of github.com:grafana/grafana into scene-share-query-rethink

This commit is contained in:
Torkel Ödegaard
2024-01-15 15:14:33 +00:00
110 changed files with 3762 additions and 865 deletions
+21 -58
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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'));
});
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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"
+1
View File
@@ -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>
+5 -5
View File
@@ -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",
+2 -2
View File
@@ -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"`
}
+10
View File
@@ -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
}
+8
View File
@@ -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),
},
}
)
+1
View File
@@ -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
1 Name Stage Owner Created requiresDevMode RequiresLicense RequiresRestart FrontendOnly
155 enablePluginsTracingByDefault experimental @grafana/plugins-platform-backend 2024-01-09 false false true false
156 cloudRBACRoles experimental @grafana/identity-access-team 2024-01-10 false false true false
157 alertingQueryOptimization GA @grafana/alerting-squad 2024-01-10 false false false false
158 newFolderPicker experimental @grafana/grafana-frontend-platform 2024-01-12 false false false true
+4
View File
@@ -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"
)
+1
View File
@@ -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")
}
}
+82
View File
@@ -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} />;
@@ -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',
-21
View File
@@ -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);
+5 -6
View File
@@ -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;
})
);
}
+11 -5
View File
@@ -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 {
-121
View File
@@ -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>
+71 -4
View File
@@ -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);
// }
// };
// }
+49 -4
View File
@@ -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>
@@ -322,6 +322,7 @@ const getStyles = (theme: GrafanaTheme2) => {
`,
noMatchersWarning: css`
padding: ${theme.spacing(1)} ${theme.spacing(2)};
margin-bottom: ${theme.spacing(1)};
`,
};
};
@@ -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);
});
});
});
@@ -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&hellip;" />
) : (
<VersionHistoryTable
versions={model.versions}
onCheck={(x, y) => {
console.log('todo');
}}
canCompare={canCompare}
/>
)}
{isAppending && <VersionsHistorySpinner msg="Fetching more entries&hellip;" />}
</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}`}
/>
);
};
@@ -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>
);
@@ -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>
);
@@ -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 &&
+10 -4
View File
@@ -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':
+2 -2
View File
@@ -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 = {
@@ -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