diff --git a/.betterer.results b/.betterer.results index 99d10079c92..634b449cdd5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -143,6 +143,14 @@ exports[`better eslint`] = { "packages/grafana-data/src/panel/registryFactories.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-data/src/table/amendTimeSeries.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, "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"] + ], "packages/grafana-data/src/themes/colorManipulator.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -2838,6 +2846,10 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], + "public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -4604,14 +4616,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], - "public/app/features/live/data/amendTimeSeries.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, "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"] - ], "public/app/features/logs/components/InfiniteScroll.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], @@ -4699,8 +4703,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "31"], [0, 0, 0, "Styles should be written using objects.", "32"], [0, 0, 0, "Styles should be written using objects.", "33"], - [0, 0, 0, "Styles should be written using objects.", "34"], - [0, 0, 0, "Styles should be written using objects.", "35"] + [0, 0, 0, "Styles should be written using objects.", "34"] ], "public/app/features/logs/components/log-context/LogContextButtons.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -6132,10 +6135,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not re-export imported variable (\`./thirdArgAfterSearchQuery\`)", "4"], [0, 0, 0, "Do not re-export imported variable (\`./withinStringQuery\`)", "5"] ], - "public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/plugins/datasource/cloudwatch/components/ConfigEditor/XrayLinkConfig.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], @@ -6658,9 +6657,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/jaeger/components/QueryEditor.tsx:5381": [ [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] ], - "public/app/plugins/datasource/jaeger/configuration/ConfigEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/plugins/datasource/jaeger/configuration/TraceIdTimeParams.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -6876,9 +6872,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/tempo/webpack.config.ts:5381": [ [0, 0, 0, "Do not re-export imported variable (\`config\`)", "0"] ], - "public/app/plugins/datasource/zipkin/ConfigEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/plugins/datasource/zipkin/QueryField.tsx:5381": [ [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -7986,11 +7979,6 @@ exports[`no gf-form usage`] = { "public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], - "public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx:5381": [ - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], - [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] - ], "public/app/plugins/datasource/influxdb/components/editor/annotation/AnnotationEditor.tsx:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], diff --git a/.drone.yml b/.drone.yml index 658e2d4adde..d60fecea08a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -593,6 +593,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-dashboards-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/dashboards-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/dashboards-suite - commands: - ./bin/build e2e-tests --port 3001 --suite smoke-tests-suite depends_on: @@ -601,6 +609,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-smoke-tests-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/smoke-tests-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/smoke-tests-suite - commands: - ./bin/build e2e-tests --port 3001 --suite panels-suite depends_on: @@ -609,6 +625,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-panels-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/panels-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/panels-suite - commands: - ./bin/build e2e-tests --port 3001 --suite various-suite depends_on: @@ -617,6 +641,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-various-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/various-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/various-suite - commands: - cd / - ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH} @@ -865,7 +897,7 @@ services: - commands: - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + image: grafana/mimir-alpine:r295-a23e559 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -1312,7 +1344,7 @@ services: - commands: - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + image: grafana/mimir-alpine:r295-a23e559 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -1908,6 +1940,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-dashboards-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/dashboards-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/dashboards-suite - commands: - ./bin/build e2e-tests --port 3001 --suite smoke-tests-suite depends_on: @@ -1916,6 +1956,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-smoke-tests-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/smoke-tests-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/smoke-tests-suite - commands: - ./bin/build e2e-tests --port 3001 --suite panels-suite depends_on: @@ -1924,6 +1972,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-panels-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/panels-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/panels-suite - commands: - ./bin/build e2e-tests --port 3001 --suite various-suite depends_on: @@ -1932,6 +1988,14 @@ steps: HOST: grafana-server image: cypress/included:13.10.0 name: end-to-end-tests-various-suite +- commands: + - ./bin/build e2e-tests --port 3001 --suite scenes/various-suite + depends_on: + - grafana-server + environment: + HOST: grafana-server + image: cypress/included:13.10.0 + name: end-to-end-tests-scenes/various-suite - commands: - cd / - ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH} @@ -2299,7 +2363,7 @@ services: - commands: - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + image: grafana/mimir-alpine:r295-a23e559 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -2921,6 +2985,40 @@ volumes: path: /var/run/docker.sock name: docker --- +clone: + retries: 3 +depends_on: [] +image_pull_secrets: +- gcr +- gar +kind: pipeline +name: verify-grafanacom-artifacts +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - apk add curl bash + - "\n for i in {1..5}; do\n if ./scripts/drone/verify-grafanacom.sh; + then\n exit 0\n elif [ $i -eq 5 ]; then\n exit + 1\n else\n sleep 60\n fi\n done\n + \ " + depends_on: [] + image: node:20.9.0-alpine + name: verify-grafanacom +trigger: + event: + - promote + target: verify-grafanacom-artifacts +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- clone: retries: 3 depends_on: @@ -3001,6 +3099,16 @@ steps: from_secret: grafana_api_key image: grafana/grafana-ci-deploy:1.3.3 name: publish-grafanacom +- commands: + - apk add curl bash + - "\n for i in {1..5}; do\n if ./scripts/drone/verify-grafanacom.sh; + then\n exit 0\n elif [ $i -eq 5 ]; then\n exit + 1\n else\n sleep 60\n fi\n done\n + \ " + depends_on: + - publish-grafanacom + image: node:20.9.0-alpine + name: verify-grafanacom trigger: event: - promote @@ -4154,7 +4262,7 @@ services: - commands: - /bin/mimir -target=backend -alertmanager.grafana-alertmanager-compatibility-enabled environment: {} - image: us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + image: grafana/mimir-alpine:r295-a23e559 name: mimir_backend - environment: {} image: redis:6.2.11-alpine @@ -4673,7 +4781,7 @@ steps: - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM plugins/slack - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM python:3.8 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM postgres:12.3-alpine - - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/mimir-alpine:r295-a23e559 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:5.7.39 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM mysql:8.0.32 - trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM redis:6.2.11-alpine @@ -4708,7 +4816,7 @@ steps: - trivy --exit-code 1 --severity HIGH,CRITICAL plugins/slack - trivy --exit-code 1 --severity HIGH,CRITICAL python:3.8 - trivy --exit-code 1 --severity HIGH,CRITICAL postgres:12.3-alpine - - trivy --exit-code 1 --severity HIGH,CRITICAL us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + - trivy --exit-code 1 --severity HIGH,CRITICAL grafana/mimir-alpine:r295-a23e559 - trivy --exit-code 1 --severity HIGH,CRITICAL mysql:5.7.39 - trivy --exit-code 1 --severity HIGH,CRITICAL mysql:8.0.32 - trivy --exit-code 1 --severity HIGH,CRITICAL redis:6.2.11-alpine @@ -4952,6 +5060,6 @@ kind: secret name: gcr_credentials --- kind: signature -hmac: 043028c50d984e1ea98a294c6746df1388cb0b7d7976f82f3dd0004fc493bafc +hmac: a916fba452c568a0e1d392702db3423fa52652d8d60f65f7b7aa43a7c1e952f4 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6136bd07605..2b3f0c836ab 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,13 +71,14 @@ /scripts/modowners/ @grafana/grafana-backend-services-squad /hack/ @grafana/grafana-app-platform-squad +/apps/alerting/ @grafana/alerting-backend /pkg/api/ @grafana/grafana-backend-group /pkg/apis/ @grafana/grafana-app-platform-squad /pkg/bus/ @grafana/grafana-search-and-storage /pkg/cmd/ @grafana/grafana-backend-group /pkg/cmd/grafana/apiserver @grafana/grafana-app-platform-squad -/pkg/components/apikeygen/ @grafana/identity-access-team -/pkg/components/satokengen/ @grafana/identity-access-team +/pkg/components/apikeygen/ @grafana/identity-squad +/pkg/components/satokengen/ @grafana/identity-squad /pkg/components/dashdiffs/ @grafana/grafana-app-platform-squad /pkg/components/imguploader/ @grafana/alerting-backend /pkg/components/loki/ @grafana/grafana-backend-group @@ -104,11 +105,12 @@ /pkg/server/ @grafana/grafana-backend-group /pkg/apiserver @grafana/grafana-app-platform-squad /pkg/apimachinery @grafana/grafana-app-platform-squad -/pkg/apimachinery/identity/ @grafana/identity-access-team +/pkg/apimachinery/identity/ @grafana/identity-squad /pkg/apimachinery/errutil/ @grafana/grafana-backend-group /pkg/promlib @grafana/observability-metrics +/pkg/storage/ @grafana/grafana-search-and-storage /pkg/services/annotations/ @grafana/grafana-search-and-storage -/pkg/services/apikey/ @grafana/identity-access-team +/pkg/services/apikey/ @grafana/identity-squad /pkg/services/cleanup/ @grafana/grafana-backend-group /pkg/services/contexthandler/ @grafana/grafana-backend-group /pkg/services/correlations/ @grafana/explore-squad @@ -136,21 +138,21 @@ /pkg/services/secrets/ @grafana/grafana-operator-experience-squad /pkg/services/shorturls/ @grafana/grafana-backend-group /pkg/services/sqlstore/ @grafana/grafana-search-and-storage -/pkg/services/ssosettings/ @grafana/identity-access-team +/pkg/services/ssosettings/ @grafana/identity-squad /pkg/services/star/ @grafana/grafana-search-and-storage /pkg/services/stats/ @grafana/grafana-backend-group /pkg/services/tag/ @grafana/grafana-search-and-storage -/pkg/services/team/ @grafana/identity-access-team +/pkg/services/team/ @grafana/access-squad /pkg/services/temp_user/ @grafana/grafana-backend-group /pkg/services/updatechecker/ @grafana/grafana-backend-group -/pkg/services/user/ @grafana/identity-access-team +/pkg/services/user/ @grafana/access-squad /pkg/services/validations/ @grafana/grafana-backend-group /pkg/setting/ @grafana/grafana-backend-services-squad /pkg/tests/ @grafana/grafana-backend-services-squad /pkg/tests/apis/ @grafana/grafana-app-platform-squad /pkg/tests/api/correlations/ @grafana/explore-squad /pkg/tsdb/grafanads/ @grafana/grafana-backend-group -/pkg/tsdb/opentsdb/ @grafana/grafana-backend-group +/pkg/tsdb/opentsdb/ @grafana/partner-datasources /pkg/util/ @grafana/grafana-backend-group /pkg/web/ @grafana/grafana-backend-group @@ -180,11 +182,11 @@ /devenv/docker/blocks/collectd/ @grafana/observability-metrics /devenv/docker/blocks/etcd @grafana/grafana-app-platform-squad /devenv/docker/blocks/grafana/ @grafana/grafana-as-code -/devenv/docker/blocks/graphite/ @grafana/observability-metrics -/devenv/docker/blocks/graphite09/ @grafana/observability-metrics -/devenv/docker/blocks/graphite1/ @grafana/observability-metrics -/devenv/docker/blocks/influxdb/ @grafana/observability-metrics -/devenv/docker/blocks/influxdb1/ @grafana/observability-metrics +/devenv/docker/blocks/graphite/ @grafana/partner-datasources +/devenv/docker/blocks/graphite09/ @grafana/partner-datasources +/devenv/docker/blocks/graphite1/ @grafana/partner-datasources +/devenv/docker/blocks/influxdb/ @grafana/partner-datasources +/devenv/docker/blocks/influxdb1/ @grafana/partner-datasources /devenv/docker/blocks/jaeger/ @grafana/observability-traces-and-profiling /devenv/docker/blocks/maildev/ @grafana/alerting-frontend /devenv/docker/blocks/mariadb/ @grafana/oss-big-tent @@ -198,7 +200,7 @@ /devenv/docker/blocks/mysql_exporter/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_opendata/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_tests/ @grafana/oss-big-tent -/devenv/docker/blocks/opentsdb/ @grafana/observability-metrics +/devenv/docker/blocks/opentsdb/ @grafana/partner-datasources /devenv/docker/blocks/postgres/ @grafana/oss-big-tent /devenv/docker/blocks/postgres_tests/ @grafana/oss-big-tent /devenv/docker/blocks/prometheus/ @grafana/observability-metrics @@ -251,9 +253,7 @@ # Observability backend code /pkg/tsdb/prometheus/ @grafana/observability-metrics -/pkg/tsdb/influxdb/ @grafana/observability-metrics /pkg/tsdb/elasticsearch/ @grafana/observability-logs -/pkg/tsdb/graphite/ @grafana/observability-metrics /pkg/tsdb/loki/ @grafana/observability-logs /pkg/tsdb/tempo/ @grafana/observability-traces-and-profiling /pkg/tsdb/grafana-pyroscope-datasource/ @grafana/observability-traces-and-profiling @@ -265,6 +265,8 @@ # Partner Datasources backend code /pkg/tsdb/mssql/ @grafana/partner-datasources +/pkg/tsdb/influxdb/ @grafana/partner-datasources +/pkg/tsdb/graphite/ @grafana/partner-datasources # Database migrations /pkg/services/sqlstore/migrations/ @grafana/grafana-search-and-storage @@ -313,37 +315,47 @@ /e2e/ @grafana/grafana-frontend-platform /e2e/cloud-plugins-suite/ @grafana/partner-datasources /e2e/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend + +# Packages /packages/ @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend +/packages/grafana-data/src/**/*logs* @grafana/observability-logs +/packages/grafana-data/src/transformations/ @grafana/dataviz-squad /packages/grafana-e2e-selectors/ @grafana/grafana-frontend-platform +/packages/grafana-flamegraph/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/ @grafana/observability-logs +/packages/grafana-o11y-ds-frontend/src/IntervalInput/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/NodeGraph/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/pyroscope/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/SpanBar/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToLogs/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/ @grafana/observability-traces-and-profiling +/packages/grafana-plugin-configs/ @grafana/plugins-platform-frontend +/packages/grafana-prometheus/ @grafana/observability-metrics +/packages/grafana-schema/src/**/*canvas* @grafana/dataviz-squad +/packages/grafana-schema/src/**/*tempo* @grafana/observability-traces-and-profiling +/packages/grafana-sql/ @grafana/partner-datasources @grafana/oss-big-tent /packages/grafana-ui/.storybook/ @grafana/plugins-platform-frontend /packages/grafana-ui/src/components/ @grafana/grafana-frontend-platform +/packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad +/packages/grafana-ui/src/components/DataLinks/ @grafana/dataviz-squad /packages/grafana-ui/src/components/DateTimePickers/ @grafana/grafana-frontend-platform +/packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad +/packages/grafana-ui/src/components/Sparkline/ @grafana/grafana-frontend-platform @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 -/packages/grafana-ui/src/components/DataLinks/ @grafana/dataviz-squad /packages/grafana-ui/src/components/ValuePicker/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizLayout/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizLegend/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizRepeater/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizTooltip/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/Sparkline/ @grafana/grafana-frontend-platform @grafana/app-o11y-visualizations /packages/grafana-ui/src/graveyard/Graph/ @grafana/dataviz-squad /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/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 -/packages/grafana-flamegraph/ @grafana/observability-traces-and-profiling + /plugins-bundled/ @grafana/plugins-platform-frontend -/packages/grafana-plugin-configs/ @grafana/plugins-platform-frontend -/packages/grafana-prometheus/ @grafana/observability-metrics -/packages/grafana-o11y-ds-frontend/ @grafana/observability-logs @grafana/observability-traces-and-profiling -/packages/grafana-sql/ @grafana/partner-datasources @grafana/oss-big-tent # root files, mostly frontend /.browserslistrc @grafana/frontend-ops @@ -390,19 +402,19 @@ playwright.config.ts @grafana/plugins-platform-frontend # Temp owners until Enterprise team takes over /public/app/features/migrate-to-cloud @grafana/grafana-frontend-platform -/public/app/features/auth-config/ @grafana/identity-access-team +/public/app/features/auth-config/ @grafana/identity-squad /public/app/features/annotations/ @grafana/dashboards-squad -/public/app/features/api-keys/ @grafana/identity-access-team +/public/app/features/api-keys/ @grafana/identity-squad /public/app/features/canvas/ @grafana/dataviz-squad /public/app/features/geo/ @grafana/dataviz-squad /public/app/features/visualization/data-hover/ @grafana/dataviz-squad /public/app/features/commandPalette/ @grafana/grafana-frontend-platform -/public/app/features/connections/ @grafana/plugins-platform-frontend @mikkancso +/public/app/features/connections/ @grafana/plugins-platform-frontend /public/app/features/correlations/ @grafana/explore-squad /public/app/features/dashboard/ @grafana/dashboards-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/datasources/ @grafana/plugins-platform-frontend /public/app/features/dimensions/ @grafana/dataviz-squad /public/app/features/dataframe-import/ @grafana/dataviz-squad /public/app/features/explore/ @grafana/explore-squad @@ -427,14 +439,14 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/features/sandbox/ @grafana/grafana-frontend-platform /public/app/features/browse-dashboards/ @grafana/grafana-frontend-platform /public/app/features/search/ @grafana/grafana-frontend-platform -/public/app/features/serviceaccounts/ @grafana/identity-access-team +/public/app/features/serviceaccounts/ @grafana/identity-squad /public/app/features/storage/ @grafana/grafana-app-platform-squad -/public/app/features/teams/ @grafana/identity-access-team +/public/app/features/teams/ @grafana/access-squad /public/app/features/templating/ @grafana/dashboards-squad /public/app/features/trails/ @torkelo /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/users/ @grafana/access-squad /public/app/features/variables/ @grafana/dashboards-squad /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend /public/app/plugins/panel/annolist/ @grafana/grafana-frontend-platform @@ -511,7 +523,7 @@ playwright.config.ts @grafana/plugins-platform-frontend -/scripts/benchmark-access-control.sh @grafana/identity-access-team +/scripts/benchmark-access-control.sh @grafana/access-squad /scripts/check-breaking-changes.sh @grafana/plugins-platform-frontend /scripts/ci-* @grafana/grafana-release-guild /scripts/circle-* @grafana/grafana-release-guild @@ -561,14 +573,14 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/plugins/datasource/grafana/ @grafana/grafana-frontend-platform /public/app/plugins/datasource/grafana-testdata-datasource/ @grafana/plugins-platform-frontend /public/app/plugins/datasource/azuremonitor/ @grafana/partner-datasources -/public/app/plugins/datasource/graphite/ @grafana/observability-metrics -/public/app/plugins/datasource/influxdb/ @grafana/observability-metrics +/public/app/plugins/datasource/graphite/ @grafana/partner-datasources +/public/app/plugins/datasource/influxdb/ @grafana/partner-datasources /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/partner-datasources /public/app/plugins/datasource/mysql/ @grafana/oss-big-tent -/public/app/plugins/datasource/opentsdb/ @grafana/observability-metrics +/public/app/plugins/datasource/opentsdb/ @grafana/partner-datasources /public/app/plugins/datasource/grafana-postgresql-datasource/ @grafana/oss-big-tent /public/app/plugins/datasource/prometheus/ @grafana/observability-metrics /public/app/plugins/datasource/cloud-monitoring/ @grafana/partner-datasources @@ -595,23 +607,23 @@ playwright.config.ts @grafana/plugins-platform-frontend /grafana-mixin/ @grafana/grafana-backend-services-squad # Grafana authentication and authorization -/pkg/login/ @grafana/identity-access-team -/pkg/services/accesscontrol/ @grafana/identity-access-team +/pkg/login/ @grafana/identity-squad +/pkg/services/accesscontrol/ @grafana/access-squad /pkg/services/anonymous/ @grafana/identity-access-team -/pkg/services/auth/ @grafana/identity-access-team -/pkg/services/authn/ @grafana/identity-access-team -/pkg/services/authz/ @grafana/identity-access-team -/pkg/services/signingkeys/ @grafana/identity-access-team -/pkg/services/dashboards/accesscontrol.go @grafana/identity-access-team -/pkg/services/datasources/guardian/ @grafana/identity-access-team -/pkg/services/guardian/ @grafana/identity-access-team -/pkg/services/ldap/ @grafana/identity-access-team -/pkg/services/login/ @grafana/identity-access-team -/pkg/services/loginattempt/ @grafana/identity-access-team +/pkg/services/auth/ @grafana/identity-squad +/pkg/services/authn/ @grafana/identity-squad +/pkg/services/authz/ @grafana/access-squad +/pkg/services/signingkeys/ @grafana/identity-squad +/pkg/services/dashboards/accesscontrol.go @grafana/access-squad +/pkg/services/datasources/guardian/ @grafana/access-squad +/pkg/services/guardian/ @grafana/access-squad +/pkg/services/ldap/ @grafana/identity-squad +/pkg/services/login/ @grafana/identity-squad +/pkg/services/loginattempt/ @grafana/identity-squad /pkg/services/extsvcauth/ @grafana/identity-access-team -/pkg/services/oauthtoken/ @grafana/identity-access-team -/pkg/services/serviceaccounts/ @grafana/identity-access-team -/public/app/core/components/RolePicker/ @grafana/identity-access-team +/pkg/services/oauthtoken/ @grafana/identity-squad +/pkg/services/serviceaccounts/ @grafana/identity-squad +/public/app/core/components/RolePicker/ @grafana/access-squad # Support bundles /public/app/features/support-bundles/ @grafana/identity-access-team @@ -715,9 +727,9 @@ embed.go @grafana/grafana-as-code # Conf /conf/defaults.ini @torkelo /conf/sample.ini @torkelo -/conf/ldap.toml @grafana/identity-access-team -/conf/ldap_multiple.toml @grafana/identity-access-team -/conf/provisioning/access-control/ @grafana/identity-access-team +/conf/ldap.toml @grafana/identity-squad +/conf/ldap_multiple.toml @grafana/identity-squad +/conf/provisioning/access-control/ @grafana/access-squad /conf/provisioning/alerting/ @grafana/alerting-backend /conf/provisioning/dashboards/ @grafana/dashboards-squad /conf/provisioning/datasources/ @grafana/plugins-platform-backend diff --git a/.github/pr-checks.json b/.github/pr-checks.json index b97dbc137ec..3566f6a65df 100644 --- a/.github/pr-checks.json +++ b/.github/pr-checks.json @@ -1,11 +1,4 @@ [ - { - "type": "check-milestone", - "title": "Milestone Check", - "targetUrl": "https://github.com/grafana/grafana/blob/main/contribute/merge-pull-request.md#assign-a-milestone", - "success": "Milestone set", - "failure": "Milestone not set" - }, { "type": "check-changelog", "title": "Changelog Check", diff --git a/.github/workflows/auto-milestone.yml b/.github/workflows/auto-milestone.yml index 22226f1158b..7b38cb0b01d 100644 --- a/.github/workflows/auto-milestone.yml +++ b/.github/workflows/auto-milestone.yml @@ -1,39 +1,26 @@ name: Auto-milestone on: - pull_request: + pull_request_target: types: - opened - reopened - closed + - ready_for_review +permissions: + pull-requests: write + +# Note: this action runs with write permissions on GITHUB_TOKEN even from forks +# so it must not run untrusted code (such as checking out the pull request) jobs: - config: - runs-on: "ubuntu-latest" - outputs: - has-secrets: ${{ steps.check.outputs.has-secrets }} - steps: - - name: "Check for secrets" - id: check - shell: bash - run: | - if [ -n "${{ (secrets.GRAFANA_DELIVERY_BOT_APP_ID != '' && secrets.GRAFANA_DELIVERY_BOT_APP_PEM != '') || '' }}" ]; then - echo "has-secrets=1" >> "$GITHUB_OUTPUT" - fi - main: - needs: config - if: needs.config.outputs.has-secrets runs-on: ubuntu-latest + if: github.event.pull_request.draft == false steps: - - name: "Generate token" - id: generate_token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 - with: - app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} - private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} - + # Note: Github will not trigger other actions from this because it uses + # the GITHUB_TOKEN token - name: Run auto-milestone uses: grafana/grafana-github-actions-go/auto-milestone@main with: pr: ${{ github.event.pull_request.number }} - token: ${{ steps.generate_token.outputs.token }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 70717ef079a..5f0b7cf3020 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -5,74 +5,39 @@ on: version: description: 'Needs to match, exactly, the name of a milestone. The version to be released please respect: major.minor.patch, major.minor.patch-preview or major.minor.patch-preview format. example: 7.4.3, 7.4.3-preview or 7.4.3-preview1' required: true -env: - YARN_ENABLE_IMMUTABLE_INSTALLS: false + push: + default: true + required: false + dry_run: + default: false + required: false jobs: main: runs-on: ubuntu-latest steps: - # This is a basic workflow to help you get started with Actions - - uses: actions-ecosystem/action-regex-match@v2.0.2 - if: ${{ github.event.inputs.version != '' }} - id: regex-match - with: - text: ${{ github.event.inputs.version }} - regex: '^(\d+.\d+).\d+(?:-(?:(preview\d?)|(pre)))?$' - - uses: actions-ecosystem/action-regex-match@v2.0.2 - if: ${{ inputs.version_call != '' }} - id: regex-match-version-call - with: - text: ${{ inputs.version_call }} - regex: '^(\d+.\d+).\d+(?:-(?:(preview\d?)|(pre)))?$' - - name: Validate input version - if: ${{ steps.regex-match.outputs.match == '' && github.event.inputs.version != '' }} - run: | - echo "The input version format is not correct, please respect:\ - major.minor.patch, major.minor.patch-preview or major.minor.patch-preview format. \ - example: 7.4.3, 7.4.3-preview or 7.4.3-preview1" - exit 1 - - name: Validate input version call - if: ${{ inputs.version_call != '' && steps.regex-match-version-call.outputs.match == '' }} - run: | - echo "The input version format is not correct, please respect:\ - major.minor.patch, major.minor.patch-preview or major.minor.patch-preview format. \ - example: 7.4.3, 7.4.3-preview or 7.4.3-preview1" - exit 1 - - - uses: actions/checkout@v4 - - - name: Set intermedia variables - id: intermedia - run: | - echo "short_ref=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - echo "check_passed=false" >> $GITHUB_OUTPUT - echo "branch_name=v${{steps.regex-match.outputs.group1}}" >> $GITHUB_OUTPUT - echo "branch_exist=$(git ls-remote --heads https://github.com/grafana/grafana.git v${{ steps.regex-match.outputs.group1 }}.x | wc -l)" >> $GITHUB_OUTPUT - - - name: Checkout Actions + - name: Checkout Grafana uses: actions/checkout@v4 + - name: Update package.json versions + uses: ./pkg/build/actions/bump-version with: - repository: "grafana/grafana-github-actions" - path: ./actions - ref: main - # Go is required for also updating the schema versions as part of the precommit hook: - - uses: actions/setup-go@v4 - with: - go-version-file: go.mod - - uses: actions/setup-node@v4 - with: - node-version: '18' - - name: Install Actions - run: npm install --production --prefix ./actions - - name: "Generate token" + version: ${{ inputs.version }} + - if: ${{ inputs.push }} + name: Generate token id: generate_token uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 with: app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} - - name: Run bump version (manually invoked) - uses: ./actions/bump-version - with: - token: ${{ steps.generate_token.outputs.token }} - metricsWriteAPIKey: ${{ secrets.GRAFANA_MISC_STATS_API_KEY }} - precommit_make_target: gen-cue + - if: ${{ inputs.push }} + name: Push & Create PR + run: | + git config --local user.name "github-actions[bot]" + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local --add --bool push.autoSetupRemote true + git checkout -b "bump-version/${{ github.run_id }}/${{ inputs.version }}" + git add . + git commit -m "bump version ${{ inputs.version }}" + git push + gh pr create --dry-run=${{ inputs.dry_run }} -l "type/ci" -l "no-changelog" -B "${{ github.ref_name }}" --title "Release: Bump version to ${{ inputs.version }}" --body "Updated version to ${{ inputs.version }}" + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/i18n-crowdin-download.yml b/.github/workflows/i18n-crowdin-download.yml index c442dc14048..58fca79a3fc 100644 --- a/.github/workflows/i18n-crowdin-download.yml +++ b/.github/workflows/i18n-crowdin-download.yml @@ -18,6 +18,14 @@ jobs: with: ref: ${{ github.head_ref }} + - name: Generate token + if: steps.crowdin-download.outputs.pull_request_url + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_ID }} + private_key: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_PEM }} + - name: Download sources id: crowdin-download uses: crowdin/github-action@v1 @@ -53,18 +61,10 @@ jobs: github_user_name: "github-actions[bot]" github_user_email: "41898282+github-actions[bot]@users.noreply.github.com" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} - - name: Generate token - if: steps.crowdin-download.outputs.pull_request_url - id: generate_token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 - with: - app_id: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_ID }} - private_key: ${{ secrets.GRAFANA_PR_AUTOMATION_APP_PEM }} - - name: Get pull request ID if: steps.crowdin-download.outputs.pull_request_url shell: bash @@ -74,7 +74,7 @@ jobs: pr_id=$(gh pr view ${{ steps.crowdin-download.outputs.pull_request_url }} --json id -q .id) echo "PULL_REQUEST_ID=$pr_id" >> "$GITHUB_ENV" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - name: Get project board ID uses: octokit/graphql-action@v2.x @@ -119,4 +119,4 @@ jobs: if: steps.crowdin-download.outputs.pull_request_url with: pr: ${{ steps.crowdin-download.outputs.pull_request_number }} - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 6373612eca6..9b0db4be754 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -22,6 +22,7 @@ on: description: Branch to backport these changes to dry_run: required: false + default: false type: bool jobs: @@ -42,21 +43,31 @@ jobs: run: | git config --local user.name "github-actions[bot]" git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local --add --bool push.autoSetupRemote true - name: Create branch run: git checkout -b "release/${{ github.run_id }}/${{ inputs.version }}" - name: Generate changelog run: git commit --allow-empty -m "Update changelog placeholder" - - name: Update package.json - run: git commit --allow-empty -m "Update version in package.json(s) (and yarn install) placeholder" + - name: Update package.json versions + uses: ./pkg/build/actions/bump-version + with: + version: ${{ inputs.version }} + - name: add package.json changes + run: | + git add . + git commit -m "Update version to ${{ inputs.version }}" + - name: git push + if: ${{ inputs.dry_run }} != true + run: git push - name: Create PR without backports - if: "${{ github.event.inputs.backport == '' }}" + if: "${{ inputs.backport == '' }}" run: > - gh pr create --dry-run=${{ inputs.dry_run }} -H "release/${{ inputs.version }}" -B "${{ inputs.target }}" --title "Release: ${{ inputs.version }}" --body "These code changes must be merged after a release is complete" + gh pr create --dry-run=${{ inputs.dry_run }} -B "${{ inputs.target }}" --title "Release: ${{ inputs.version }}" --body "These code changes must be merged after a release is complete" env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} - name: Create PR with backports - if: "${{ github.event.inputs.backport != '' }}" + if: "${{ inputs.backport != '' }}" run: > - gh pr create -l "backport ${{ inputs.backport }}" --dry-run=${{ inputs.dry_run }} -H "release/${{ inputs.version }}" -B "${{ inputs.target }}" --title "Release: ${{ inputs.version }}" --body "These code changes must be merged after a release is complete" + gh pr create -l "backport ${{ inputs.backport }}" -l "product-approved" --dry-run=${{ inputs.dry_run }} -B "${{ inputs.target }}" --title "Release: ${{ inputs.version }}" --body "These code changes must be merged after a release is complete" env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index b68da3bc327..06d56a1ef7c 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -1,9 +1,14 @@ name: Trivy Scan on: pull_request: + # only run on PRs where go.mod/go.sum/etc have been updated + paths: + - go.* push: branches: - main + paths: + - go.* jobs: trivy-scan: @@ -25,6 +30,8 @@ jobs: vuln-type: 'os,library' severity: 'CRITICAL,HIGH' trivyignores: .trivyignore + # for the PR check, ignore JS-related issues + skip-files: 'yarn.lock,package.json' - name: Run Trivy vulnerability scanner (SARIF) uses: aquasecurity/trivy-action@0.22.0 with: diff --git a/apps/alerting/notifications/cue.mod/module.cue b/apps/alerting/notifications/cue.mod/module.cue new file mode 100644 index 00000000000..d5f6f45c25e --- /dev/null +++ b/apps/alerting/notifications/cue.mod/module.cue @@ -0,0 +1 @@ +module:"notifications" \ No newline at end of file diff --git a/apps/alerting/notifications/timeInterval.cue b/apps/alerting/notifications/timeInterval.cue new file mode 100644 index 00000000000..04bff2c995e --- /dev/null +++ b/apps/alerting/notifications/timeInterval.cue @@ -0,0 +1,37 @@ +package core + +timeInterval: { + kind: "TimeInterval" + group: "notifications" + apiResource: { + groupOverride: "notifications.alerting.grafana.app" + } + codegen: { + frontend: false + backend: true + } + pluralName: "TimeIntervals" + current: "v0alpha1" + versions: { + "v0alpha1": { + schema: { + #TimeRange: { + start_time: string + end_time: string + } + #Interval: { + times?: [...#TimeRange] + weekdays?: [...string] + days_of_month?: [...string] + months?: [...string] + years?: [...string] + location?: string + } + spec: { + name: string + time_intervals: [...#Interval] + } + } + } + } +} \ No newline at end of file diff --git a/conf/defaults.ini b/conf/defaults.ini index 898b0261fa2..f9e230c580e 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -519,6 +519,9 @@ user_invite_max_lifetime_duration = 24h # The duration in time a verification email, used to update the email address of a user, remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 1h (1 hour). verification_email_max_lifetime_duration = 1h +# Frequency of updating a user's last seen time. The minimum supported duration is 5m (5 minutes). The maximum supported duration is 1h (1 hour) +last_seen_update_interval = 15m + # Enter a comma-separated list of usernames to hide them in the Grafana UI. These users are shown to Grafana admins and to themselves. hidden_users = @@ -1304,6 +1307,9 @@ disable_jitter = false # Retention period for Alertmanager notification log entries. notification_log_retention = 5d +# Duration for which a resolved alert state transition will continue to be sent to the Alertmanager. +resolved_alert_retention = 15m + [unified_alerting.screenshots] # Enable screenshots in notifications. You must have either installed the Grafana image rendering # plugin, or set up Grafana to use a remote rendering service. diff --git a/conf/sample.ini b/conf/sample.ini index b1d61c44187..5257c046add 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -520,6 +520,9 @@ # The duration in time a verification email, used to update the email address of a user, remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 1h (1 hour). ;verification_email_max_lifetime_duration = 1h +# Frequency of updating a user's last seen time. The minimum supported duration is 5m (5 minutes). The maximum supported duration is 1h (1 hour). +;last_seen_update_interval = 15m + # Enter a comma-separated list of users login to hide them in the Grafana UI. These users are shown to Grafana admins and themselves. ; hidden_users = @@ -792,7 +795,7 @@ ;role_attribute_strict = false ;groups_attribute_path = ;id_token_attribute_name = -;team_ids_attribute_path +;team_ids_attribute_path ;auth_url = https://foo.bar/login/oauth/authorize ;token_url = https://foo.bar/login/oauth/access_token ;api_url = https://foo.bar/user @@ -1290,6 +1293,9 @@ # Retention period for Alertmanager notification log entries. ;notification_log_retention = 5d +# Duration for which a resolved alert state transition will continue to be sent to the Alertmanager. +;resolved_alert_retention = 15m + [unified_alerting.screenshots] # Enable screenshots in notifications. You must have either installed the Grafana image rendering # plugin, or set up Grafana to use a remote rendering service. @@ -1837,4 +1843,4 @@ timeout = 30s #################################### Public Dashboards ##################################### [public_dashboards] # Set to false to disable public dashboards -;enabled = true \ No newline at end of file +;enabled = true diff --git a/contribute/internationalization.md b/contribute/internationalization.md index 5ca730ab835..8729f8175f5 100644 --- a/contribute/internationalization.md +++ b/contribute/internationalization.md @@ -22,14 +22,40 @@ Grafana uses the [i18next](https://www.i18next.com/) framework for managing tran ```jsx import { Trans } from 'app/core/internationalization'; +const SearchTitle = ({ term }) => Results for {{ term }}; +``` + +Prefer using `` for JSX children, and `t()` for props and other JavaScript usage. + +There may be cases where you need to interpolate variables inside other components in the translation. + +If the nested component is displaying the variable only (e.g. to add emphasis or color), the best solution is to create a new wrapping component: + +```jsx +import { Trans } from 'app/core/internationalization'; +import { Text } from '@grafana/ui'; + +const SearchTerm = ({ term }) => {term}; + const SearchTitle = ({ term }) => ( - Results for {{ term }} + Results for ); ``` -Prefer using `` for JSX children, and `t()` for props and other JavaScript usage. +However there are also cases where the nested component might be displaying additional text which also needs to be translated. In this case, you can use the `values` prop to explicitly pass variables to the translation, and reference them as templated strings in the markup. For example: + +```jsx +import { Trans } from 'app/core/internationalization'; +import { Text } from '@grafana/ui'; + +const SearchTitle = ({ term }) => ( + + Results for {'{{ myVariable }}'} and this translated text is also in green + +); +``` When translating in `grafana-ui`, use a relative path to import `` and `t()` from `src/utils/i18n`. diff --git a/devenv/docker/blocks/mimir_backend/docker-compose.yaml b/devenv/docker/blocks/mimir_backend/docker-compose.yaml index 76a19755d9f..2297d16acc6 100644 --- a/devenv/docker/blocks/mimir_backend/docker-compose.yaml +++ b/devenv/docker/blocks/mimir_backend/docker-compose.yaml @@ -1,5 +1,5 @@ mimir_backend: - image: us.gcr.io/kubernetes-dev/mimir:santihernandezc-validate_grafana_am_config-1e903e462-WIP + image: grafana/mimir-alpine:r295-a23e559 container_name: mimir_backend command: - -target=backend diff --git a/devenv/docker/blocks/prometheus/alertmanager.yml b/devenv/docker/blocks/prometheus/alertmanager.yml new file mode 100644 index 00000000000..7bd5e0c5d30 --- /dev/null +++ b/devenv/docker/blocks/prometheus/alertmanager.yml @@ -0,0 +1,16 @@ +route: + group_by: ['alertname'] + group_wait: 30s + group_interval: 5m + repeat_interval: 1h + receiver: 'web.hook' +receivers: + - name: 'web.hook' + webhook_configs: + - url: 'http://127.0.0.1:5001/' +inhibit_rules: + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'dev', 'instance'] diff --git a/devenv/docker/blocks/prometheus/docker-compose.yaml b/devenv/docker/blocks/prometheus/docker-compose.yaml index 416064bd196..6cda0259dc6 100644 --- a/devenv/docker/blocks/prometheus/docker-compose.yaml +++ b/devenv/docker/blocks/prometheus/docker-compose.yaml @@ -25,7 +25,11 @@ FD_DATASOURCE: prom alertmanager: - image: quay.io/prometheus/alertmanager + image: prom/alertmanager + volumes: + - ${PWD}/docker/blocks/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: > + --config.file=/etc/alertmanager/alertmanager.yml ports: - "9093:9093" diff --git a/docs/sources/administration/announcement-banner/_index.md b/docs/sources/administration/announcement-banner/_index.md new file mode 100644 index 00000000000..2d62de69734 --- /dev/null +++ b/docs/sources/administration/announcement-banner/_index.md @@ -0,0 +1,61 @@ +--- +draft: true +keywords: + - grafana + - announcement +labels: + products: + - cloud + - enterprise +menuTitle: Announcement banner +title: Create and configure announcement banner +description: How to create an announcement banner to show important updates and information at the top of every Grafana page. +--- + +# Create an announcement banner + +An announcement banner shows at the top of every page in Grafana. You can use the announcement banner to communicate information to your users, such as maintenance windows, new features, or other important updates. + +## Create or update an announcement banner + +By default, only organization administrators can create announcement banners. You can customize who can create announcement banners with [Role-based access control](/docs/grafana//administration/roles-and-permissions/access-control/). + +To create or update an announcement banner, follow these steps: + +1. Click **Administration > General > Announcement banner** in the side navigation menu. + + The **Announcement banner** page allows you to view, create, and update the settings for an announcement banner. + Only one banner can be active at a time. + +1. Toggle the **Enable** switch on to enable the announcement banner. + You can disable the banner at any time with this toggle. +1. Enter the **Message** for the announcement banner. + The message field supports Markdown. + + To add a header, use the following syntax: + + ```markdown + ### Header + ``` + + To add a link, use the following syntax: + + ```markdown + [link text](https://www.example.com) + ``` + + The preview of the configured banner appears on top of the form, under the **Preview** section. + +1. Select the banner's start date and time in the **Starts at** field. + By default, the banner starts being displayed immediately. + You can set a future date and time for the banner to start displaying. +1. Select the banner's end date and time in the **Ends at** field. + By default, the banner is displayed indefinitely. + You can set a date and time for the banner to stop displaying. +1. Select the banner's visibility. + **Everyone** - The banner is visible to all users, including on login page. + **Authenticated users** - The banner is visible to only authenticated users. +1. Select the type of banner in the **Variant** field. + This determines the color of the banner's background. +1. Click **Save** to save the banner settings. + The banner displays at the top of every page in Grafana between the start and end dates. diff --git a/docs/sources/administration/api-keys/index.md b/docs/sources/administration/api-keys/index.md index 2134e38efeb..fa3bb643b08 100644 --- a/docs/sources/administration/api-keys/index.md +++ b/docs/sources/administration/api-keys/index.md @@ -49,7 +49,7 @@ To follow these instructions, you need at least one of the following: ### Steps -To create an API, complete the following steps: +To create an API key, complete the following steps: 1. Sign in to Grafana. 1. Click **Administration** in the left-side menu, **Users and access**, and select **API Keys**. diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 3f75bca900a..1ec8a8a0b26 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -33,81 +33,84 @@ The following tables list permissions associated with basic and fixed roles. ## Fixed role definitions -| Fixed role | Permissions | Description | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fixed:alerting.instances:writer` | All permissions from `fixed:alerting.instances:reader` and
`alert.instances:create`
`alert.instances:write` for organization scope
`alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) | -| `fixed:alerting.instances:reader` | `alert.instances:read` for organization scope
`alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) | -| `fixed:alerting.notifications:writer` | All permissions from `fixed:alerting.notifications:reader` and
`alert.notifications:write`for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) | -| `fixed:alerting.notifications:reader` | `alert.notifications:read` for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting.rules:writer` | All permissions from `fixed:alerting.rules:reader` and
`alert.rule:create`
`alert.rule:write`
`alert.rule:delete`
`alert.silences:create`
`alert.silences:write` for scope `folders:*`
`alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and manage rule-specific silences | -| `fixed:alerting.rules:reader` | `alert.rule:read`, `alert.silences:read` for scope `folders:*`
`alert.rules.external:read` for scope `datasources:*`
`alert.notifications.time-intervals:read`
`alert.notifications.receivers:list` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and read rule-specific silences | -| `fixed:alerting:writer` | All permissions from `fixed:alerting.rules:writer`
`fixed:alerting.instances:writer`
`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting:reader` | All permissions from `fixed:alerting.rules:reader`
`fixed:alerting.instances:reader`
`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting.provisioning.secrets:reader` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) | -| `fixed:alerting.provisioning:writer` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) | -| `fixed:alerting.provisioning.status:writer` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) | -| `fixed:annotations.dashboard:writer` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. | -| `fixed:annotations:reader` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. | -| `fixed:annotations:writer` | All permissions from `fixed:annotations:reader`
`annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. | -| `fixed:apikeys:reader` | `apikeys:read` for scope `apikeys:*` | Read all api keys. | -| `fixed:apikeys:writer` | All permissions from `fixed:apikeys:reader` and
`apikeys:create`
`apikeys:delete` for scope `apikeys:*` | Read, create, delete all api keys. | -| `fixed:authentication.config:writer` | `settings:read` for scope `settings:auth.saml:*`
`settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. | -| `fixed:dashboards:creator` | `dashboards:create`
`folders:read` | Create dashboards. | -| `fixed:dashboards.insights:reader` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. | -| `fixed:dashboards.permissions:reader` | `dashboards.permissions:read` | Read all dashboard permissions. | -| `fixed:dashboards.permissions:writer` | All permissions from `fixed:dashboards.permissions:reader` and
`dashboards.permissions:write` | Read and update all dashboard permissions. | -| `fixed:dashboards.public:writer` | `dashboards.public:write` | Create, update, delete or pause a public dashboard. | -| `fixed:dashboards:reader` | `dashboards:read` | Read all dashboards. | -| `fixed:dashboards:writer` | All permissions from `fixed:dashboards:reader` and
`dashboards:write`
`dashboards:edit`
`dashboards:delete`
`dashboards:create`
`dashboards.permissions:read`
`dashboards.permissions:write` | Read, create, update, and delete all dashboards. | -| `fixed:datasources.caching:reader` | `datasources.caching:read` | Read data source query caching settings. | -| `fixed:datasources.caching:writer` | `datasources.caching:read`
`datasources.caching:write` | Enable, disable, or update query caching settings. | -| `fixed:datasources:explorer` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | -| `fixed:datasources.id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. | -| `fixed:datasources.insights:reader` | `datasources.insights:read` | Read data source insights data. | -| `fixed:datasources.permissions:reader` | `datasources.permissions:read` | Read data source permissions. | -| `fixed:datasources.permissions:writer` | All permissions from `fixed:datasources.permissions:reader` and
`datasources.permissions:write` | Create, read, or delete permissions of a data source. | -| `fixed:datasources:creator` | `datasources:create` | Create data sources. | -| `fixed:datasources:reader` | `datasources:read`
`datasources:query` | Read and query data sources. | -| `fixed:datasources:writer` | All permissions from `fixed:datasources:reader` and
`datasources:create`
`datasources:write`
`datasources:delete` | Read, query, create, delete, or update a data source. | -| `fixed:folders.permissions:reader` | `folders.permissions:read` | Read all folder permissions. | -| `fixed:folders.permissions:writer` | All permissions from `fixed:folders.permissions:reader` and
`folders.permissions:write` | Read and update all folder permissions. | -| `fixed:folders:creator` | `folders:create` | Create folders in the root level. If granted together with `folders:write` permission, also allows creating subfolders under all folders. | -| `fixed:folders:reader` | `folders:read`
`dashboards:read` | Read all folders and dashboards. | -| `fixed:folders:writer` | All permissions from `fixed:dashboards:writer` and
`folders:read`
`folders:write`
`folders:create`
`folders:delete`
`folders.permissions:read`
`folders.permissions:write` | Read, create, update, and delete all folders and dashboards. If granted together with `fixed:folders:creator`, allows creating subfolders under all folders. | -| `fixed:ldap:reader` | `ldap.user:read`
`ldap.status:read` | Read the LDAP configuration and LDAP status information. | -| `fixed:ldap:writer` | All permissions from `fixed:ldap:reader` and
`ldap.user:sync`
`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. | -| `fixed:library.panels:creator` | `library.panels:create`
`folders:read` | Create library panel at the root level. | -| `fixed:library.panels:reader` | `library.panels:read` | Read all library panels. | -| `fixed:library.panels:general.reader` | `library.panels:read` | Read all library panels at the root level. | -| `fixed:library.panels:writer` | All permissions from `fixed:library.panels:reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions. | -| `fixed:library.panels:general.writer` | All permissions from `fixed:library.panels:general.reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. | -| `fixed:licensing:reader` | `licensing:read`
`licensing.reports:read` | Read licensing information and licensing reports. | -| `fixed:licensing:writer` | All permissions from `fixed:licensing:viewer` and
`licensing:write`
`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. | -| `fixed:org.users:reader` | `org.users:read` | Read users within a single organization. | -| `fixed:org.users:writer` | All permissions from `fixed:org.users:reader` and
`org.users:add`
`org.users:remove`
`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. | -| `fixed:organization:maintainer` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs:create`
`orgs:delete`
`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. | -| `fixed:organization:reader` | `orgs:read`
`orgs.quotas:read` | Read an organization and its quotas. | -| `fixed:organization:writer` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs.preferences:read`
`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. | -| `fixed:plugins.app:reader` | `plugins.app:access` | Access application plugins (still enforcing the organization role). | -| `fixed:plugins:maintainer` | `plugins:install` | Install and uninstall plugins. Needs to be assigned globally. | -| `fixed:plugins:writer` | `plugins:write` | Enable and disable plugins and edit plugins' settings. | -| `fixed:provisioning:writer` | `provisioning:reload` | Reload provisioning. | -| `fixed:reports:reader` | `reports:read`
`reports:send`
`reports.settings:read` | Read all reports and shared report settings. | -| `fixed:reports:writer` | All permissions from `fixed:reports:reader` and
`reports:create`
`reports:write`
`reports:delete`
`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. | -| `fixed:roles:reader` | `roles:read`
`teams.roles:read`
`users.roles:read`
`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. | -| `fixed:roles:writer` | All permissions from `fixed:roles:reader` and
`roles:write`
`roles:delete`
`teams.roles:add`
`teams.roles:remove`
`users.roles:add`
`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. | -| `fixed:roles:resetter` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. | -| `fixed:serviceaccounts:reader` | `serviceaccounts:read` | Read Grafana service accounts. | -| `fixed:serviceaccounts:creator` | `serviceaccounts:create` | Create Grafana service accounts. | -| `fixed:serviceaccounts:writer` | `serviceaccounts:read`
`serviceaccounts:create`
`serviceaccounts:write`
`serviceaccounts:delete`
`serviceaccounts.permissions:read`
`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. | -| `fixed:settings:reader` | `settings:read` | Read Grafana instance settings. | -| `fixed:settings:writer` | All permissions from `fixed:settings:reader` and
`settings:write` | Read and update Grafana instance settings. | -| `fixed:stats:reader` | `server.stats:read` | Read Grafana instance statistics. | -| `fixed:teams:reader` | `teams:read` | List all teams. | -| `fixed:teams:creator` | `teams:create`
`org.users:read` | Create a team and list organization users (required to manage the created team). | -| `fixed:teams:writer` | `teams:create`
`teams:delete`
`teams:read`
`teams:write`
`teams.permissions:read`
`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. | -| `fixed:users:reader` | `users:read`
`users.quotas:read`
`users.authtoken:read`
` | Read all users and their information, such as team memberships, authentication tokens, and quotas. | -| `fixed:users:writer` | All permissions from `fixed:users:reader` and
`users:write`
`users:create`
`users:delete`
`users:enable`
`users:disable`
`users.password:write`
`users.permissions:write`
`users:logout`
`users.authtoken:write`
`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. | +The following table has the existing built-in fixed role definitions. Other fixed roles might be added by plugins installed in Grafana. +The UUID presented here can be used as an identifier for [Terraform provisioning](../rbac-terraform-provisioning). + +| Fixed role | UUID | Permissions | Description | +| -------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fixed:alerting:reader` | `fixed_O2oP1_uBFozI2i93klAkcvEWR30` | All permissions from `fixed:alerting.rules:reader`
`fixed:alerting.instances:reader`
`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting:writer` | `fixed_-PAZgSJsDlRD8NUg-PFSeH_BkJY` | All permissions from `fixed:alerting.rules:writer`
`fixed:alerting.instances:writer`
`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting.instances:reader` | `fixed_ut5fVS-Ulh_ejFoskFhJT_rYg0Y` | `alert.instances:read` for organization scope
`alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) | +| `fixed:alerting.instances:writer` | `fixed_pKOBJE346uyqMLdgWbk1NsQfEl0` | All permissions from `fixed:alerting.instances:reader` and
`alert.instances:create`
`alert.instances:write` for organization scope
`alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) | +| `fixed:alerting.notifications:reader` | `fixed_hmBn0lX5h1RZXB9Vaot420EEdA0` | `alert.notifications:read` for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting.notifications:writer` | `fixed_XplK6HPNxf9AP5IGTdB5Iun4tJc` | All permissions from `fixed:alerting.notifications:reader` and
`alert.notifications:write`for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) | +| `fixed:alerting.provisioning:writer` | `fixed_y7pFjdEkxpx5ETdcxPvp0AgRuUo` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) | +| `fixed:alerting.provisioning.secrets:reader` | `fixed_9fmzXXZZG-Od0Amy2ofEG8Uk--c` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) | +| `fixed:alerting.provisioning.status:writer` | `fixed_eAxlzfkTuobvKEgXHveFMBZrOj8` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) | +| `fixed:alerting.rules:reader` | `fixed_fRGKL_vAqUsmUWq5EYKnOha9DcA` | `alert.rule:read`, `alert.silences:read` for scope `folders:*`
`alert.rules.external:read` for scope `datasources:*`
`alert.notifications.time-intervals:read`
`alert.notifications.receivers:list` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and read rule-specific silences | +| `fixed:alerting.rules:writer` | `fixed_YJJGwAalUwDZPrXSyFH8GfYBXAc` | All permissions from `fixed:alerting.rules:reader` and
`alert.rule:create`
`alert.rule:write`
`alert.rule:delete`
`alert.silences:create`
`alert.silences:write` for scope `folders:*`
`alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and manage rule-specific silences | +| `fixed:annotations:reader` | `fixed_hpZnoizrfAJsrceNcNQqWYV-xNU` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. | +| `fixed:annotations:writer` | `fixed_ZVW-Aa9Tzle6J4s2aUFcq1StKWE` | All permissions from `fixed:annotations:reader`
`annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. | +| `fixed:annotations.dashboard:writer` | `fixed_8A775xenXeKaJk4Cr7bchP9yXOA` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. | +| `fixed:apikeys:reader` | `fixed_kYZ7UEkwEvGmCCjTrq07cFAVFws` | `apikeys:read` for scope `apikeys:*` | Read all api keys. | +| `fixed:apikeys:writer` | `fixed_anTrcpRkm21NBO1Q2CsX8y0fiCQ` | All permissions from `fixed:apikeys:reader` and
`apikeys:create`
`apikeys:delete` for scope `apikeys:*` | Read, create, delete all api keys. | +| `fixed:authentication.config:writer` | `fixed_0rYhZ2Qnzs8AdB1nX7gexk3fHDw` | `settings:read` for scope `settings:auth.saml:*`
`settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. | +| `fixed:dashboards:creator` | `fixed_ZorKUcEPCM01A1fPakEzGBUyU64` | `dashboards:create`
`folders:read` | Create dashboards. | +| `fixed:dashboards:reader` | `fixed_Sgr67JTOhjQGFlzYRahOe45TdWM` | `dashboards:read` | Read all dashboards. | +| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and
`dashboards:write`
`dashboards:edit`
`dashboards:delete`
`dashboards:create`
`dashboards.permissions:read`
`dashboards.permissions:write` | Read, create, update, and delete all dashboards. | +| `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. | +| `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. | +| `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and
`dashboards.permissions:write` | Read and update all dashboard permissions. | +| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a public dashboard. | +| `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. | +| `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | +| `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`
`datasources:query` | Read and query data sources. | +| `fixed:datasources:writer` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | All permissions from `fixed:datasources:reader` and
`datasources:create`
`datasources:write`
`datasources:delete` | Read, query, create, delete, or update a data source. | +| `fixed:datasources.caching:reader` | `fixed_D2ddpGxJYlw0mbsTS1ek9fj0kj4` | `datasources.caching:read` | Read data source query caching settings. | +| `fixed:datasources.caching:writer` | `fixed_JtFjHr7jd7hSqUYcktKvRvIOGRE` | `datasources.caching:read`
`datasources.caching:write` | Enable, disable, or update query caching settings. | +| `fixed:datasources.id:reader` | `fixed_entg--fHmDqWY2-69N0ocawK0Os` | `datasources.id:read` | Read the ID of a data source based on its name. | +| `fixed:datasources.insights:reader` | `fixed_EBZ3NwlfecNPp2p0XcZRC1nfEYk` | `datasources.insights:read` | Read data source insights data. | +| `fixed:datasources.permissions:reader` | `fixed_ErYA-cTN3yn4h4GxaVPcawRhiOY` | `datasources.permissions:read` | Read data source permissions. | +| `fixed:datasources.permissions:writer` | `fixed_aiQh9YDfLOKjQhYasF9_SFUjQiw` | All permissions from `fixed:datasources.permissions:reader` and
`datasources.permissions:write` | Create, read, or delete permissions of a data source. | +| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. If granted together with `folders:write` permission, also allows creating subfolders under all folders. | +| `fixed:folders:reader` | `fixed_yeW-5QPeo-i5PZUIUXMlAA97GnQ` | `folders:read`
`dashboards:read` | Read all folders and dashboards. | +| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and
`folders:read`
`folders:write`
`folders:create`
`folders:delete`
`folders.permissions:read`
`folders.permissions:write` | Read, create, update, and delete all folders and dashboards. If granted together with `fixed:folders:creator`, allows creating subfolders under all folders. | +| `fixed:folders.permissions:reader` | `fixed_E06l4cx0JFm47EeLBE4nmv3pnSo` | `folders.permissions:read` | Read all folder permissions. | +| `fixed:folders.permissions:writer` | `fixed_3GAgpQ_hWG8o7-lwNb86_VB37eI` | All permissions from `fixed:folders.permissions:reader` and
`folders.permissions:write` | Read and update all folder permissions. | +| `fixed:ldap:reader` | `fixed_lMcOPwSkxKY-qCK8NMJc5k6izLE` | `ldap.user:read`
`ldap.status:read` | Read the LDAP configuration and LDAP status information. | +| `fixed:ldap:writer` | `fixed_p6AvnU4GCQyIh7-hbwI-bk3GYnU` | All permissions from `fixed:ldap:reader` and
`ldap.user:sync`
`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. | +| `fixed:library.panels:creator` | `fixed_6eX6ItfegCIY5zLmPqTDW8ZV7KY` | `library.panels:create`
`folders:read` | Create library panel at the root level. | +| `fixed:library.panels:general.reader` | `fixed_ct0DghiBWR_2BiQm3EvNPDVmpio` | `library.panels:read` | Read all library panels at the root level. | +| `fixed:library.panels:general.writer` | `fixed_DgprkmqfN_1EhZ2v1_d1fYG8LzI` | All permissions from `fixed:library.panels:general.reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. | +| `fixed:library.panels:reader` | `fixed_tvTr9CnZ6La5vvUO_U_X1LPnhUs` | `library.panels:read` | Read all library panels. | +| `fixed:library.panels:writer` | `fixed_JTljAr21LWLTXCkgfBC4H0lhBC8` | All permissions from `fixed:library.panels:reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions. | +| `fixed:licensing:reader` | `fixed_OADpuXvNEylO2Kelu3GIuBXEAYE` | `licensing:read`
`licensing.reports:read` | Read licensing information and licensing reports. | +| `fixed:licensing:writer` | `fixed_gzbz3rJpQMdaKHt-E4q0PVaKMoE` | All permissions from `fixed:licensing:viewer` and
`licensing:write`
`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. | +| `fixed:org.users:reader` | `fixed_oCqNwlVHLOpw7-jAlwp4HzYqwGY` | `org.users:read` | Read users within a single organization. | +| `fixed:org.users:writer` | `fixed_VERj5nayasjgf_Yh0sWqqCkxWlw` | All permissions from `fixed:org.users:reader` and
`org.users:add`
`org.users:remove`
`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. | +| `fixed:organization:maintainer` | `fixed_CMm-uuBaPUBf4r8XG3jIvxo55bg` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs:create`
`orgs:delete`
`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. | +| `fixed:organization:reader` | `fixed_0SZPJlTHdNEe8zO91zv7Zwiwa2w` | `orgs:read`
`orgs.quotas:read` | Read an organization and its quotas. | +| `fixed:organization:writer` | `fixed_Y4jGqDd8w1yCrPwlik8z5Iu8-3M` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs.preferences:read`
`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. | +| `fixed:plugins:maintainer` | `fixed_yEOKidBcWgbm74x-nTa3lW5lOyY` | `plugins:install` | Install and uninstall plugins. Needs to be assigned globally. | +| `fixed:plugins:writer` | `fixed_MRYpGk7kpNNwt2VoVOXFiPnQziE` | `plugins:write` | Enable and disable plugins and edit plugins' settings. | +| `fixed:plugins.app:reader` | `fixed_AcZRiNYx7NueYkUqzw1o2OGGUAA` | `plugins.app:access` | Access application plugins (still enforcing the organization role). | +| `fixed:provisioning:writer` | `fixed_bgk1FCyR6OEDwhgirZlQgu5LlCA` | `provisioning:reload` | Reload provisioning. | +| `fixed:reports:reader` | `fixed_72_8LU_0ukfm6BdblOw8Z9q-GQ8` | `reports:read`
`reports:send`
`reports.settings:read` | Read all reports and shared report settings. | +| `fixed:reports:writer` | `fixed_jBW3_7g1EWOjGVBYeVRwtFxhUNw` | All permissions from `fixed:reports:reader` and
`reports:create`
`reports:write`
`reports:delete`
`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. | +| `fixed:roles:reader` | `fixed_GkfG-1NSwEGb4hpK3-E3qHyNltc` | `roles:read`
`teams.roles:read`
`users.roles:read`
`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. | +| `fixed:roles:resetter` | `fixed_WgPpC3qJRmVpVTJavFNwfS5RuzQ` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. | +| `fixed:roles:writer` | `fixed_W5aFaw8isAM27x_eWfElBhZ0iOc` | All permissions from `fixed:roles:reader` and
`roles:write`
`roles:delete`
`teams.roles:add`
`teams.roles:remove`
`users.roles:add`
`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. | +| `fixed:serviceaccounts:creator` | `fixed_Ikw60fckA0MyiiZ73BawSfOULy4` | `serviceaccounts:create` | Create Grafana service accounts. | +| `fixed:serviceaccounts:reader` | `fixed_QFjJAZ88iawMLInYOxPA1DB1w6I` | `serviceaccounts:read` | Read Grafana service accounts. | +| `fixed:serviceaccounts:writer` | `fixed_iBvUNUEZBZ7PUW0vdkN5iojc2sk` | `serviceaccounts:read`
`serviceaccounts:create`
`serviceaccounts:write`
`serviceaccounts:delete`
`serviceaccounts.permissions:read`
`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. | +| `fixed:settings:reader` | `fixed_0LaUt1x6PP8hsZzEBhqPQZFUd8Q` | `settings:read` | Read Grafana instance settings. | +| `fixed:settings:writer` | `fixed_joIHDgMrGg790hMhUufVzcU4j44` | All permissions from `fixed:settings:reader` and
`settings:write` | Read and update Grafana instance settings. | +| `fixed:stats:reader` | `fixed_OnRCXxZVINWpcKvTF5A1gecJ7pA` | `server.stats:read` | Read Grafana instance statistics. | +| `fixed:teams:creator` | `fixed_nzVQoNSDSn0fg1MDgO6XnZX2RZI` | `teams:create`
`org.users:read` | Create a team and list organization users (required to manage the created team). | +| `fixed:teams:reader` | `fixed_3SNL15gkRtJ7XeEKpMVJyQjYbjg` | `teams:read` | List all teams. | +| `fixed:teams:writer` | `fixed_xw1T0579h620MOYi4L96GUs7fZY` | `teams:create`
`teams:delete`
`teams:read`
`teams:write`
`teams.permissions:read`
`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. | +| `fixed:users:reader` | `fixed_buZastUG3reWyQpPemcWjGqPAd0` | `users:read`
`users.quotas:read`
`users.authtoken:read`
` | Read all users and their information, such as team memberships, authentication tokens, and quotas. | +| `fixed:users:writer` | `fixed_wjzgHHo_Ux25DJuELn_oiAdB_yM` | All permissions from `fixed:users:reader` and
`users:write`
`users:create`
`users:delete`
`users:enable`
`users:disable`
`users.password:write`
`users.permissions:write`
`users:logout`
`users.authtoken:write`
`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. | ### Alerting roles diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md index 593037a02dc..cfbfa21f5f6 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md @@ -91,7 +91,7 @@ roles: # force deletion revoking all grants of the role. force: true - uid: 'basic_editor' - # always apply the specified changes to the role, regardless of the role version in the data base + # always apply the specified changes to the role, regardless of the role version in the database overrideRole: true global: true # list of roles to copy permissions from. diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-telegram.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-telegram.md index e0c237211de..4b95b3c52ac 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-telegram.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-telegram.md @@ -22,6 +22,11 @@ Use the Grafana Alerting - Telegram integration to send [Telegram](https://teleg ## Before you begin +### Telegram limitation + +Telegram messages are limited to 4096 UTF-8 characters. If you use a `parse_mode` other than `None`, truncation may result in an invalid message, causing the notification to fail. +For longer messages, we recommend using an alternative contact method. + ### Telegram bot API token and chat ID To integrate Grafana with Telegram, you need to obtain a Telegram **bot API token** and a **chat ID** (i.e., the ID of the Telegram chat where you want to receive the alert notifications). diff --git a/docs/sources/dashboards/assess-dashboard-usage/index.md b/docs/sources/dashboards/assess-dashboard-usage/index.md index 195e64b132c..260ed8baaf0 100644 --- a/docs/sources/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/dashboards/assess-dashboard-usage/index.md @@ -50,9 +50,11 @@ refs: Usage insights enables you to have a better understanding of how your Grafana instance is used. -> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). -> Grafana Cloud insights logs include additional fields with their own dashboards. -> Read more in the [Grafana Cloud documentation](/docs/grafana-cloud/usage-insights/). +{{< admonition type="note" >}} +Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](https://grafana.com/docs/grafana-cloud/). +Grafana Cloud insights logs include additional fields with their own dashboards. +Read more in the [Grafana Cloud documentation](https://grafana.com/docs/grafana-cloud/account-management/usage-insights/). +{{< /admonition >}} The usage insights feature collects a number of aggregated data and stores them in the database: @@ -77,7 +79,7 @@ For every dashboard and data source, you can access usage information. To see dashboard usage information, click the dashboard insights icon in the header. -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights.png" max-width="400px" class="docs-image--no-shadow" alt="Dashboard insights icon" >}} +{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-11.2.png" alt="Dashboard insights icon" >}} Dashboard insights show the following information: diff --git a/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md b/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md index 838b37fe6d2..c7862d79151 100644 --- a/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md +++ b/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md @@ -21,12 +21,12 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//datasources/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/ + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ annotations-api: - pattern: /docs/grafana/ destination: /docs/grafana//developers/http_api/annotations/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//developers/http_api/annotations/ + destination: /docs/grafana-cloud/developer-resources/api-reference/http-api/annotations/ --- # Annotate visualizations @@ -70,34 +70,44 @@ Watch the following video for a quick tutorial on creating annotations: To add an annotation, complete the following steps: -1. In the dashboard click the panel to which you're adding the annotation. A context menu will appear. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click the panel to which you're adding the annotation. + + A context menu will appear. + 1. In the context menu, click **Add annotation**. ![Add annotation context menu](/static/img/docs/time-series-panel/time-series-annotations-context-menu.png) 1. Add an annotation description and tags (optional). -1. Click **Save**. +1. Click **Save dashboard**. +1. Click **Exit edit**. -Alternatively, to add an annotation, press Ctrl/Cmd and click the panel, and the **Add annotation** popover will appear. +Alternatively, to add an annotation, press Ctrl/Cmd and click the panel, and the **Add annotation** context menu will appear. ### Add a region annotation -1. In the dashboard press Ctrl/Cmd and click and drag on the panel. +1. Click **Edit** in the top-right corner of the dashboard. +1. Press Ctrl/Cmd and click and drag on the panel. ![Add annotation popover](/static/img/docs/time-series-panel/time-series-annotations-add-region-annotation.gif) 1. Add an annotation description and tags (optional). -1. Click **Save**. +1. Click **Save dashboard**. +1. Click **Exit edit**. ### Edit an annotation -1. In the dashboard, hover over an annotation indicator on the Time series panel. - -1. Click on the pencil icon in the annotation tooltip. -1. Modify the description and/or tags. -1. Click save. +1. Click **Edit** in the top-right corner of the dashboard. +1. Hover over the annotation indicator on the panel. +1. Click the pencil icon in the annotation tooltip. +1. Modify the description and tags. +1. Click **Save dashboard**. +1. Click **Exit edit**. ### Delete an annotation -1. In the dashboard hover over an annotation indicator on a panel. - -1. Click on the trash icon in the annotation tooltip. +1. Click **Edit** in the top-right corner of the dashboard. +1. Hover over the annotation indicator on the panel. +1. Click the trash icon in the annotation tooltip. +1. Click **Save dashboard**. +1. Click **Exit edit**. ## Fetch annotations through dashboard settings @@ -109,10 +119,11 @@ Check out the video below for a quick tutorial. ### Add new annotation queries -To add a new annotation query to a dashboard, take the following steps: +To add a new annotation query to a dashboard, follow these steps: -1. Click the dashboard settings (gear) icon in the dashboard header to open the settings menu. -1. Select **Annotations**. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. +1. On the **Settings** page, go to the **Annotations** tab. 1. Click **Add annotation query**. If you've added a query before, the **+ New query** button is displayed. @@ -140,6 +151,9 @@ To add a new annotation query to a dashboard, take the following steps: The annotation query options are different for each data source. For information about annotations in a specific data source, refer to the specific [data source](ref:data-source) topic. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. + ## Built-in query After you add an annotation, they will still be visible. This is due to the built-in annotation query that exists on all dashboards. This annotation query will fetch all annotation events that originate from the current dashboard, which are stored in Grafana, and show them on the panel where they were created. This includes alert state history annotations. @@ -150,8 +164,9 @@ To add annotations directly to the dashboard, this query must be enabled. To confirm if the built-in query is enabled, take the following steps: -1. Click the dashboard settings (gear) icon in the dashboard header to open the dashboard settings menu. -1. Click **Annotations**. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. +1. On the **Settings** page, go to the **Annotations** tab. 1. Find the **Annotations & Alerts (Built-in)** query. If it says **Disabled** before the name of the query, then you'll need to click the query name to open it and update the setting. @@ -162,6 +177,8 @@ You can stop annotations from being fetched and drawn by taking the following st 1. Click **Annotations**. 1. Find and click the **Annotations & Alerts (Built-in)** query to open it. 1. Click the **Enabled** toggle to turn it off. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. When you copy a dashboard using the **Save As** feature it will get a new dashboard id, so annotations created on the source dashboard will no longer be visible on the copy. You can still show them if you add a new **Annotation Query** and filter by tags. However, this only works if the annotations on the source dashboard had tags to filter by. diff --git a/docs/sources/dashboards/build-dashboards/create-dashboard-url-variables/index.md b/docs/sources/dashboards/build-dashboards/create-dashboard-url-variables/index.md index 5e8a7ae4002..85ccb5423b2 100644 --- a/docs/sources/dashboards/build-dashboards/create-dashboard-url-variables/index.md +++ b/docs/sources/dashboards/build-dashboards/create-dashboard-url-variables/index.md @@ -21,28 +21,28 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/add-template-variables/#add-ad-hoc-filters - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/add-template-variables/#add-ad-hoc-filters + destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#add-ad-hoc-filters manage-dashboard-links: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/manage-dashboard-links/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/manage-dashboard-links/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/ linking-overview: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/ template-and-variables: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/ + destination: /docs/grafana-cloud/visualizations/dashboards/variables/ --- # Dashboard URL variables Grafana can apply variable values passed as query parameters in dashboard URLs. -For more information, refer to [Manage dashboard links](ref:manage-dashboard-links) and [Templates and variables][]. +For more information, refer to [Manage dashboard links](ref:manage-dashboard-links) and [Templates and variables](ref:template-and-variables). ## Passing variables as query parameters diff --git a/docs/sources/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/dashboards/build-dashboards/create-dashboard/index.md index bd4b2534295..aacf8f483ef 100644 --- a/docs/sources/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/dashboards/build-dashboards/create-dashboard/index.md @@ -19,22 +19,22 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//datasources/#special-data-sources - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/#special-data-sources + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/#special-data-sources visualization-specific-options: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/visualizations/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/visualizations/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ configure-standard-options: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-standard-options/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/ configure-value-mappings: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-value-mappings/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-value-mappings/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ generative-ai-features: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards @@ -44,12 +44,12 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-thresholds/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-thresholds/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-thresholds/ data-sources: - pattern: /docs/grafana/ destination: /docs/grafana//datasources/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/ + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ add-a-data-source: - pattern: /docs/grafana/ destination: /docs/grafana//administration/data-source-management/#add-a-data-source @@ -69,17 +69,12 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-panel-options/#configure-repeating-panels - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-panel-options/#configure-repeating-panels + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options/#configure-repeating-panels override-field-values: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-overrides/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-overrides/ - dashboard: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/#special-data-sources - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/#special-data-sources + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ --- ## Create a dashboard @@ -95,7 +90,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee **To create a dashboard**: -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Click **New** and select **New Dashboard**. 1. On the empty dashboard, click **+ Add visualization**. @@ -104,7 +99,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee 1. In the dialog box that opens, do one of the following: - Select one of your existing data sources. - - Select one of the Grafana's [built-in special data sources](ref:built-in-special-data-sources). + - Select one of the Grafana [built-in special data sources](ref:built-in-special-data-sources). - Click **Configure a new data source** to set up a new one (Admins only). {{< figure class="float-right" src="/media/docs/grafana/dashboards/screenshot-data-source-selector-10.0.png" max-width="800px" alt="Select data source modal" >}} @@ -115,14 +110,10 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee For more information about data sources, refer to [Data sources](ref:data-sources) for specific guidelines. 1. Write or construct a query in the query language of your data source. - -1. Click the Refresh dashboard icon to query the data source. - - ![Refresh dashboard icon](/media/docs/grafana/dashboards/screenshot-refresh-dashboard-9.5.png) - +1. Click **Refresh** to query the data source. 1. In the visualization list, select a visualization type. - ![Visualization selector](/media/docs/grafana/dashboards/screenshot-select-visualization-9-5.png) + ![Visualization selector](/media/docs/grafana/dashboards/screenshot-select-visualization-11-2.png) Grafana displays a preview of your query results with the visualization applied. @@ -139,30 +130,35 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee - [Configure thresholds](ref:configure-thresholds) - [Configure standard options](ref:configure-standard-options) -1. When you've finished editing your panel, click **Save** to save the dashboard. +1. When you've finished editing your panel, click **Save dashboard**. - Alternatively, click **Apply** if you want to see your changes applied to the dashboard first. Then click the save icon in the dashboard header. + Alternatively, click **Back to dashboard** if you want to see your changes applied to the dashboard first. Then click **Save dashboard** when you're ready. 1. Enter a title and description for your dashboard or have Grafana create them using [generative AI features](ref:generative-ai-features). 1. Select a folder, if applicable. 1. Click **Save**. -1. To add more panels to the dashboard, click **Add** in the dashboard header and select **Visualization** in the drop-down. +1. To add more panels to the dashboard, click **Back to dashboard**. + Then click **Add** in the dashboard header and select **Visualization** in the drop-down. - ![Add drop-down](/media/docs/grafana/dashboards/screenshot-add-dropdown-10.0.png) + ![Add drop-down](/media/docs/grafana/dashboards/screenshot-add-dropdown-11.2.png) When you add additional panels to the dashboard, you're taken straight to the **Edit panel** view. -## Copy an existing dashboard +1. When you've saved all the changes you want to make to the dashboard, click **Exit edit**. -To copy an existing dashboard, follow these steps: + Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner. -1. Click **Dashboards** in the primary menu. -1. Open the dashboard to be copied. -1. Click **Settings** (gear icon) in the top right of the dashboard. -1. Click **Save as** in the top-right corner of the dashboard. +## Copy a dashboard + +To copy a dashboard, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Open the dashboard you want to copy. +1. Click **Edit** in top-right corner. +1. Click the **Save dashboard** drop-down and select **Save as copy**. 1. (Optional) Specify the name, folder, description, and whether or not to copy the original dashboard tags for the copied dashboard. - By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is located in the same folder. + By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. 1. Click **Save**. @@ -178,7 +174,7 @@ To see an example of repeating rows, refer to [Dashboard with repeating rows](ht **To configure repeating rows:** -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Navigate to the dashboard you want to work on. 1. At the top of the dashboard, click **Add** and select **Row** in the drop-down. @@ -192,7 +188,7 @@ To provide context to dashboard users, add the variable to the row title. ### Repeating rows and the Dashboard special data source -If a row includes panels using the special [Dashboard](ref:dashboard) data source—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. For example, in a dashboard: @@ -205,14 +201,24 @@ For example, in a dashboard: You can place a panel on a dashboard in any location. -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Navigate to the dashboard you want to work on. +1. Click **Edit** in the top-right corner. 1. Click the panel title and drag the panel to the new location. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. ## Resize a panel You can size a dashboard panel to suits your needs. -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Navigate to the dashboard you want to work on. +1. Click **Edit** in the top-right corner. 1. To adjust the size of the panel, click and drag the lower-right corner of the panel. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. diff --git a/docs/sources/dashboards/build-dashboards/import-dashboards/index.md b/docs/sources/dashboards/build-dashboards/import-dashboards/index.md index 1c919212a2a..6378fe4288a 100644 --- a/docs/sources/dashboards/build-dashboards/import-dashboards/index.md +++ b/docs/sources/dashboards/build-dashboards/import-dashboards/index.md @@ -48,7 +48,6 @@ To import a dashboard, follow these steps: 1. (Optional) Change the dashboard name, folder, or UID, and specify metric prefixes, if the dashboard uses any. 1. Select a data source, if required. 1. Click **Import**. -1. Save the dashboard. ## Discover dashboards on grafana.com diff --git a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md index 0960cceea09..d0917600909 100644 --- a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -26,19 +26,19 @@ weight: 500 refs: data-links: - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-data-links/#data-links + destination: /docs/grafana//panels-visualizations/configure-data-links/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-data-links/#data-links + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-data-links/ data-link-variables: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-data-links/#data-link-variables - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/configure-data-links/#data-link-variables + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-data-links/#data-link-variables dashboard-url-variables: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/create-dashboard-url-variables/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/create-dashboard-url-variables/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard-url-variables/ --- # Manage dashboard links @@ -83,51 +83,64 @@ Once you've added a dashboard link, it appears in the upper right corner of your Add links to other dashboards at the top of your current dashboard. -1. While viewing the dashboard you want to link, click the gear at the top of the screen to open **Dashboard settings**. -1. Click **Links** and then click **Add Dashboard Link** or **New**. -1. In **Type**, select **dashboards**. -1. Select link options: - - **With tags** – Enter tags to limit the linked dashboards to only the ones with the tags you enter. Otherwise, Grafana includes links to all other dashboards. - - **As dropdown** – If you are linking to lots of dashboards, then you probably want to select this option and add an optional title to the dropdown. Otherwise, Grafana displays the dashboard links side by side across the top of your dashboard. - - **Time range** – Select this option to include the dashboard time range in the link. When the user clicks the link, the linked dashboard opens with the indicated time range already set. **Example:** https://play.grafana.org/d/000000010/annotations?orgId=1&from=now-3h&to=now - - **Variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. For more information, see [Dashboard URL variables](ref:dashboard-url-variables). - - **Open in new tab** – Select this option if you want the dashboard link to open in a new tab or window. -1. Click **Add**. +1. In the dashboard you want to link, click **Edit**. +1. Click **Settings**. +1. Go to the **Links** tab and then click **Add dashboard link**. + + The default link type is **Dashboards**. + +1. In the **With tags** drop-down, enter tags to limit the linked dashboards to only the ones with the tags you enter. + + If you don't add any tags, Grafana includes links to all other dashboards. + +1. Set link options: + + - **Show as dropdown** – If you are linking to lots of dashboards, then you probably want to select this option and add an optional title to the dropdown. Otherwise, Grafana displays the dashboard links side by side across the top of your dashboard. + - **Include current time range** – Select this option to include the dashboard time range in the link. When the user clicks the link, the linked dashboard opens with the indicated time range already set. **Example:** https://play.grafana.org/d/000000010/annotations?orgId=1&from=now-3h&to=now + - **Include current template variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. For more information, see [Dashboard URL variables](ref:dashboard-url-variables). + - **Open link in new tab** – Select this option if you want the dashboard link to open in a new tab or window. + +1. Click **Save dashboard** in the top-right corner. +1. Click **Back to dashboard** and then **Exit edit**. ### Add a URL link to a dashboard Add a link to a URL at the top of your current dashboard. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana. -1. While viewing the dashboard you want to link, click the gear at the top of the screen to open **Dashboard settings**. -1. Click **Links** and then click **Add Dashboard Link** or **New**. -1. In **Type**, select **link**. -1. Select link options: - - **Url** – Enter the URL you want to link to. Depending on the target, you might want to include field values. **Example:** https://github.com/grafana/grafana/issues/new?title=Dashboard%3A%20HTTP%20Requests - - **Title** – Enter the title you want the link to display. - - **Tooltip** – Enter the tooltip you want the link to display when the user hovers their mouse over it. - - **Icon** – Choose the icon you want displayed with the link. - - **Time range** – Select this option to include the dashboard time range in the link. When the user clicks the link, the linked dashboard opens with the indicated time range already set. **Example:** https://play.grafana.org/d/000000010/annotations?orgId=1&from=now-3h&to=now - - `from` - Defines the lower limit of the time range, specified in ms epoch. - - `to` - Defines the upper limit of the time range, specified in ms epoch. - - `time` and `time.window` - Define a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000. - - **Variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. Here is the variable format: `https://${you-domain}/path/to/your/dashboard?var-${template-variable1}=value1&var-{template-variable2}=value2` **Example:** https://play.grafana.org/d/000000074/alerting?var-app=backend&var-server=backend_01&var-server=backend_03&var-interval=1h - - **Open in new tab** – Select this option if you want the dashboard link to open in a new tab or window. -1. Click **Add**. +1. In the dashboard you want to link, click **Edit**. +1. Click **Settings**. +1. Go to the **Links** tab and then click **Add dashboard link**. +1. In the **Type** drop-down, select **Link**. +1. In the **URL** field, enter the URL to which you want to link. + + Depending on the target, you might want to include field values. **Example:** https://github.com/grafana/grafana/issues/new?title=Dashboard%3A%20HTTP%20Requests + +1. In the **Tooltip** field, enter the tooltip you want the link to display when the user hovers their mouse over it. +1. In the **Icon** drop-down, choose the icon you want displayed with the link. +1. Set link options; by default, these options are enabled for URL links: + + - **Include current time range** – Select this option to include the dashboard time range in the link. When the user clicks the link, the linked dashboard opens with the indicated time range already set. **Example:** https://play.grafana.org/d/000000010/annotations?orgId=1&from=now-3h&to=now + - **Include current template variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. + - **Open link in new tab** – Select this option if you want the dashboard link to open in a new tab or window. + +1. Click **Save dashboard** in the top-right corner. +1. Click **Back to dashboard** and then **Exit edit**. ### Update a dashboard link -To change or update an existing dashboard link, follow this procedure. +To edit, duplicate, or delete dashboard link, follow these steps: -1. In Dashboard Settings, on the Links tab, click the existing link that you want to edit. -1. Change the settings and then click **Update**. +1. In the dashboard you want to link, click **Edit**. +1. Click **Settings**. +1. Go to the **Links** tab. +1. Do one of the following: -## Duplicate a dashboard link + - **Edit** - Click the name of the link and update the link settings. + - **Duplicate** - Click the copy link icon next to the link that you want to duplicate. + - **Delete** - Click the red **X** next to the link that you want to delete, and then **Delete**. -To duplicate an existing dashboard link, click the duplicate icon next to the existing link that you want to duplicate. - -### Delete a dashboard link - -To delete an existing dashboard link, click the trash icon next to the duplicate icon that you want to delete. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. ## Panel links @@ -144,7 +157,7 @@ Click the icon next to the panel title to see available panel links. To use a keyboard shortcut to open the panel, hover over the panel and press `e`. -1. Expand the **Panel options** section, scroll down to Panel links. +1. Expand the **Panel options** section, scroll down to **Panel links**. 1. Click **Add link**. 1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI. 1. Enter the **URL** you want to link to. @@ -152,9 +165,10 @@ Click the icon next to the panel title to see available panel links. - `from` - Defines the lower limit of the time range, specified in ms epoch. - `to` - Defines the upper limit of the time range, specified in ms epoch. - `time` and `time.window` - Define a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000. -1. If you want the link to open in a new tab, then select **Open in a new tab**. -1. Click **Save** to save changes and close the window. -1. Click **Save** in the upper right to save your changes to the dashboard. +1. If you want the link to open in a new tab, then select **Open in new tab**. +1. Click **Save** to save changes and close the dialog box. +1. Click **Save dashboard** in the top-right corner. +1. Click **Back to dashboard** and then **Exit edit**. ### Update a panel link @@ -167,8 +181,9 @@ Click the icon next to the panel title to see available panel links. 1. Find the link that you want to make changes to. 1. Click the Edit (pencil) icon to open the Edit link window. 1. Make any necessary changes. -1. Click **Save** to save changes and close the window. -1. Click **Save** in the upper right to save your changes to the dashboard. +1. Click **Save** to save changes and close the dialog box. +1. Click **Save dashboard** in the top-right corner. +1. Click **Back to dashboard** and then **Exit edit**. ### Delete a panel link @@ -180,4 +195,5 @@ Click the icon next to the panel title to see available panel links. 1. Expand the **Panel options** section, scroll down to Panel links. 1. Find the link that you want to delete. 1. Click the **X** icon next to the link you want to delete. -1. Click **Save** in the upper right to save your changes to the dashboard. +1. Click **Save dashboard** in the top-right corner. +1. Click **Back to dashboard** and then **Exit edit**. diff --git a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md index 8de65f86b05..838c194956c 100644 --- a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md @@ -39,52 +39,75 @@ You can control permissions for library panels using [role-based access control When you create a library panel, the panel on the source dashboard is converted to a library panel as well. You need to save the original dashboard once a panel is converted. -1. Open a panel in edit mode. -1. In the panel display options, click the down arrow option to bring changes to the visualization. - {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-create-lib-panel-from-edit-9-5.png" class="docs-image--no-shadow" max-width= "800px" alt="Library panels tab of the panel editor pane" >}} -1. Click **Library panels**, and then click **+ Create library panel** to open the create dialog. +1. Click **Edit** in the top-right corner of the dashboard. +1. On the panel you want to update, hover over any part of the panel to display the menu icon on the top-right corner. +1. Click the menu icon and select **More > Create library panel**. 1. In **Library panel name**, enter the name. 1. In **Save in folder**, select the folder to save the library panel. -1. Click **Create library panel** to save your changes. -1. Save the dashboard. +1. Click **Create library panel**. +1. Click **Save dashboard** and **Exit edit**. Once created, you can modify the library panel using any dashboard on which it appears. After you save the changes, all instances of the library panel reflect these modifications. -You can also create a library panel directly from the edit menu of any panel. - -{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-create-from-more-9-5.png" class="docs-image--no-shadow" max-width= "900px" alt="Create library panel option in the panel menu" >}} - ## Add a library panel to a dashboard Add a Grafana library panel to a dashboard when you want to provide visualizations to other dashboard users. -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Click **New** and select **New Dashboard** in the dropdown. -1. On the empty dashboard, click **+ Import library panel**. +1. On the empty dashboard, click **+ Add library panel**. - You will see a list of your library panels. + You'll see a list of your library panels. 1. Filter the list or search to find the panel you want to add. 1. Click a panel to add it to the dashboard. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. ## Unlink a library panel Unlink a library panel when you want to make a change to the panel and not affect other instances of the library panel. -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. +1. Click **Library panels**. +1. Select a library panel that is being used in dashboards. +1. Click the panel you want to unlink. +1. In the dialog box, select the dashboard from which you want to unlink the panel. +1. Click **View panel in \**. +1. Click **Edit** in the top-right corner of the dashboard. +1. Hover over any part of the panel you want to unlink to display the menu icon on the top-right corner. +1. Click the menu icon and select **More > Unlink library panel**. +1. Click **Yes, unlink**. +1. Click **Save dashboard** and **Exit edit**. + +Alternatively, if you know where the library panel is being used, you can go directly to that dashboard and start at step 7. + +## Replace a library panel + +To replace a library panel with a different one, follow these steps: + +1. Click **Dashboards** in the main menu. 1. Click **Library panels**. 1. Select a library panel that is being used in different dashboards. -1. Select the panel you want to unlink. -1. Hover over any part of the panel to display the actions menu on the top right corner. -1. Click the menu and select **Edit**. -1. Click **Unlink** on the top right corner of the page. -1. Click **Yes, unlink**. +1. Click the panel you want to unlink. +1. In the dialog box, select the dashboard from which you want to unlink the panel. +1. Click **View panel in \**. +1. Click **Edit** in the top-right corner of the dashboard. +1. Hover over any part of the panel you want to unlink to display the menu icon on the top-right corner. +1. Click the menu icon and select **More > Replace library panel**. +1. Select the replacement library panel. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save** and **Exit edit**. + +Alternatively, if you know where the library panel that you want to replace is being used, you can go directly to that dashboard and start at step 7. ## View a list of library panels -You can view a list of available library panels and search for a library panel. +You can view a list of available library panels and see where those panels are being used. -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Click **Library panels**. You can see a list of previously defined library panels. @@ -94,10 +117,14 @@ You can view a list of available library panels and search for a library panel. You can also filter the panels by folder or type. +1. Click the panel to see if it's being used in any dashboards. +1. (Optional) If the library panel is in use, select one of the dashboards using it. +1. (Optional) Click **View panel in \** to see the panel in context. + ## Delete a library panel Delete a library panel when you no longer need it. -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Click **Library panels**. 1. Click the delete icon next to the library panel name. diff --git a/docs/sources/dashboards/build-dashboards/manage-version-history/index.md b/docs/sources/dashboards/build-dashboards/manage-version-history/index.md index ffd4fa349d2..d1ed28efc3f 100644 --- a/docs/sources/dashboards/build-dashboards/manage-version-history/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-version-history/index.md @@ -21,28 +21,38 @@ weight: 400 # Manage dashboard version history -Whenever you save a version of your dashboard, a copy of that version is saved so that previous versions of your dashboard are never lost. A list of these versions is available by entering the dashboard settings and then selecting "Versions" in the left side menu. +Whenever you save a version of your dashboard, a copy of that version is saved so that previous versions of your dashboard are never lost. You can see a list of dashboard versions in the **Versions** tab of the dashboard settings: -![Dashboards versions list](/media/docs/grafana/dashboards/screenshot-dashboard-versions-list.png) +![Dashboards versions list](/media/docs/grafana/dashboards/screenshot-dashboard-version-list-11.2.png) The dashboard version history feature lets you compare and restore to previously saved dashboard versions. -## Comparing two dashboard versions +## Compare two dashboard versions -To compare two dashboard versions, select the two versions from the list that you wish to compare. Once selected, the "Compare versions" button will become clickable. Click the button to view the diff between the two versions. +To compare two dashboard versions, follow these steps: -![Dashboard versions selected](/media/docs/grafana/dashboards/screenshot-dashboard-versions-select.png) +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. +1. Go to the **Versions** tab. +1. Select the two dashboard versions that you want to compare. +1. Click **Compare versions** to view the diff between the two versions. +1. Review the text descriptions of the differences between the versions. +1. (Optional) Expand the **View JSON Diff** section of the page to see the diff of the raw JSON that represents your dashboard. +1. When you've finished comparing versions, click **Back to dashboard** and **Exit edit**. -Upon clicking the button, you'll be brought to the diff view. By default, you'll see a textual summary of the changes, like in the image below. +When you're comparing versions, if one of the versions you've selected is the latest version, a button to restore the previous version is also displayed, so you can restore a version from the compare view: -![Dashboards versions diff](/media/docs/grafana/dashboards/screenshot-dashboard-versions-diff-basic.png) +![Dashboards versions diff](/media/docs/grafana/dashboards/screenshot-dashboard-compare-versions-restore-11.2.png) -If you want to view the diff of the raw JSON that represents your dashboard, you can do that as well by clicking the expand icon for the View JSON Diff section at the bottom. +## Restore a previously dashboard version -## Restoring to a previously saved dashboard version +To restore to a previously saved dashboard version, follow these steps: -If you need to restore to a previously saved dashboard version, you can do so by either clicking the "Restore" button on the right of a row in the dashboard version list, or by clicking the **Restore to version \** button appearing in the diff view. Clicking the button will bring up the following popup prompting you to confirm the restoration. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. +1. Go to the **Versions** tab. +1. Click the **Restore** button next to the version. -![Restore dashboard version](/media/docs/grafana/dashboards/screenshot-dashboard-versions-restore.png) +When you restore a version, the dashboard is immediately saved and you're no longer in edit mode. -After restoring to a previous version, a new version will be created containing the same exact data as the previous version, only with a different version number. This is indicated in the "Notes column" for the row in the new dashboard version. This is done simply to ensure your previous dashboard versions are not affected by the change. +After you restore a previous version, a new version of the dashboard is created containing the same exact data as the previous version, but with a different version number. This is indicated in the **Notes column** in the **Versions** tab of the dashboard settings. This is done simply to ensure your previous dashboard versions are not affected by the change. diff --git a/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md b/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md index c251a6063a6..a58f0332e50 100644 --- a/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md +++ b/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md @@ -19,17 +19,22 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/ + destination: /docs/grafana-cloud/visualizations/dashboards/variables/ json-fields: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/view-dashboard-json-model/#json-fields - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/view-dashboard-json-model/#json-fields + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/view-dashboard-json-model/#json-fields data-source: - pattern: /docs/grafana/ destination: /docs/grafana//datasources/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/ + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ + dashboard-links: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/manage-dashboard-links/#dashboard-links + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/#dashboard-links --- # Modify dashboard settings @@ -44,25 +49,29 @@ The dashboard settings page allows you to: To access the dashboard setting page: -1. Open a dashboard in edit mode. -1. Click **Dashboard settings** (gear icon) located at the top of the page. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. ## Modify dashboard time settings Adjust dashboard time settings when you want to change the dashboard timezone, the local browser time, and specify auto-refresh time intervals. -1. On the **Dashboard settings** page, click **General**. -1. Navigate to the **Time Options** section. +1. On the **Settings** page, scroll down to the **Time Options** section of the **General** tab. 1. Specify time settings as follows. - - **Timezone:** Specify the local time zone of the service or system that you are monitoring. This can be helpful when monitoring a system or service that operates across several time zones. + - **Time zone:** Specify the local time zone of the service or system that you are monitoring. This can be helpful when monitoring a system or service that operates across several time zones. - **Default:** Grafana uses the default selected time zone for the user profile, team, or organization. If no time zone is specified for the user profile, a team the user is a member of, or the organization, then Grafana uses the local browser time. - - **Local browser time:** The time zone configured for the viewing user browser is used. This is usually the same time zone as set on the computer. + - **Browser time:** The time zone configured for the viewing user browser is used. This is usually the same time zone as set on the computer. - Standard [ISO 8601 time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), including UTC. - - **Auto-refresh:** Customize the options displayed for relative time and the auto-refresh options Entries are comma separated and accept any valid time unit. + - **Auto refresh:** Customize the options displayed for relative time and the auto-refresh options Entries are comma separated and accept any valid time unit. - **Now delay:** Override the `now` time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. - **Hide time picker:** Select this option if you do not want Grafana to display the time picker. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + ## Add tags You can add metadata to your dashboards using tags. Tags also give you the ability to filter the list of dashboards. @@ -71,13 +80,16 @@ Tags can be up to 50 characters long, including spaces. To add tags to a dashboard, follow these steps: -1. On the **Dashboard settings** page, scroll down to the **Tags** section. +1. On the **Settings** page, scroll down to the **Tags** section of the **General** tab. 1. In the field, enter a new or existing tag. If you're entering an existing tag, make sure that you spell it the same way or a new tag is created. 1. Click **Add** or press the Enter key. -1. Save the dashboard. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. When you're on the **Dashboards** page, any tags you've entered show up under the **Tags** column. @@ -86,10 +98,14 @@ When you're on the **Dashboards** page, any tags you've entered show up under th An annotation query is a query that queries for events. These events can be visualized in graphs across the dashboard as vertical lines along with a small icon you can hover over to see the event information. -1. On the **Dashboard settings** page, click **Annotations**. +1. On the **Settings** page, go to the **Annotations** tab. 1. Click **Add annotation query**. 1. Enter a name and select a data source. 1. Complete the rest of the form to build a query and annotation. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. The query editor UI changes based on the data source you select. Refer to the [Data source](ref:data-source) documentation for details on how to construct a query. @@ -101,33 +117,43 @@ the dashboard. These dropdowns make it easy to change the data being displayed i For more information about variables, refer to [Variables](ref:variables). -1. On the **Dashboard settings** page, click **Variable** in the left side section menu and then the **Add variable** button. -1. In the **General** section, the name of the variable. This is the name that you will later use in queries. -1. Select a variable **Type**. +1. On the **Settings** page, go to the **Variables** tab. +1. Click **+ New variable**. +1. In the **Select variable type** drop-down, choose an option. - > **Note:** The variable type you select impacts which fields you populate on the page. + The variable type you select impacts which fields you populate on the page. -1. Define the variable and click **Update**. +1. In the **General** section, enter the name of the variable. + + This is the name that you'll use later in queries. + +1. Set the rest of the variable options. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. ## Add a link Dashboard links enable you to place links to other dashboards and web sites directly below the dashboard header. Links provide for easy navigation to other, related dashboards and content. -1. On the **Dashboard settings** page, click **Links** in the left side section menu and then the **Add link** button. -1. Enter title and in the **Type** field, select **Dashboard** or **Link**. -1. To add a dashboard link: - a. Add an optional tag. Tags are useful creating a dynamic dropdown of dashboards that all have a specific tag. - b. Select any of the dashboard link **Options**. - c. Click **Apply**. -1. To add a link: - a. Add a URL and tooltip text that appears when the user hovers over the link. - b. Select an icon that appears next to the link. - c. Select any of the dashboard link **Options**. +1. On the **Settings** page, click the **Links** tab. +1. Click **+ New link**. +1. Enter title for the link. +1. In the **Type** drop-down, select **Dashboards** or **Link**. +1. Set the rest of the link options. + + For more detailed directions on creating links, refer to [Dashboard links](ref:dashboard-links) + +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. ## View dashboard JSON model A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from panels, template variables, panel queries, and so on. -To view a dashboard JSON model, on the **Dashboard settings** page, click **JSON**. +To view a dashboard JSON model, on the **Settings** page, click the **JSON Model** tab. For more information about the JSON fields, refer to [JSON fields](ref:json-fields). diff --git a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md index e639d4d54a7..58190abe04e 100644 --- a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md +++ b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md @@ -21,7 +21,7 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/ --- # Dashboard JSON model @@ -30,9 +30,10 @@ A dashboard in Grafana is represented by a JSON object, which stores metadata of To view the JSON of a dashboard: -1. Navigate to a dashboard. -1. In the top navigation menu, click the **Dashboard settings** (gear) icon. -1. Click **JSON Model**. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. +1. Go to the **JSON Model** tab. +1. When you've finished viewing the JSON, click **Back to dashboard** and **Exit edit**. ## JSON fields diff --git a/docs/sources/dashboards/dashboard-public/index.md b/docs/sources/dashboards/dashboard-public/index.md index 252bdfba6d2..d4b69d5ed57 100644 --- a/docs/sources/dashboards/dashboard-public/index.md +++ b/docs/sources/dashboards/dashboard-public/index.md @@ -15,7 +15,7 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/share-dashboards-panels/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/share-dashboards-panels/ + destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ custom-branding: - pattern: /docs/grafana/ destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ @@ -25,7 +25,7 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/assess-dashboard-usage/#dashboard-insights - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/assess-dashboard-usage/#dashboard-insights + destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ caching: - pattern: /docs/grafana/ destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching @@ -56,7 +56,7 @@ You can see a list of all your public dashboards in one place by navigating to * ## Make a dashboard public -1. Click the sharing icon in the dashboard header. +1. Click **Share** in the top-right corner of the dashboard. 1. Click the **Public dashboard** tab. 1. Acknowledge the implications of making the dashboard public by selecting all the checkboxes. 1. Click **Generate public URL** to make the dashboard public and make your link live. @@ -66,7 +66,7 @@ Once you've made the dashboard public, a **Public** tag is displayed in the head ## Pause access -1. Click the sharing icon in the dashboard header. +1. Click **Share** in the top-right corner of the dashboard. 1. Click the **Public dashboard** tab. 1. Enable the **Pause sharing dashboard** toggle. @@ -74,7 +74,7 @@ The dashboard is no longer accessible, even with the link, until you make it sha ## Revoke access -1. Click the sharing icon in the dashboard header. +1. Click **Share** in the top-right corner of the dashboard. 1. Click the **Public dashboard** tab. 1. Click **Revoke public URL** to delete the public dashboard. @@ -94,7 +94,7 @@ Email sharing allows you to share your public dashboard with only specific peopl ### Invite a viewer -1. Click the sharing icon in the dashboard header. +1. Click **Share** in the top-right corner of the dashboard. 1. Click the **Public dashboard** tab. 1. Acknowledge the implications of making the dashboard public by selecting all the checkboxes. 1. Click **Generate public URL** to make the dashboard public and make your link live. @@ -111,7 +111,7 @@ If the viewer doesn't have an invitation or it's been revoked, you won't be noti ### Revoke access for a viewer -1. Click the sharing icon in the dashboard header. +1. Click **Share** in the top-right corner of the dashboard. 1. Click the **Public dashboard** tab. 1. Click **Revoke** on the viewer you'd like to revoke access for. @@ -119,7 +119,7 @@ Immediately, the viewer no longer has access to the public dashboard, nor can th ### Reinvite a viewer -1. Click the sharing icon in the dashboard header. +1. Click **Share** in the top-right corner of the dashboard. 1. Click the **Public dashboard** tab. 1. Click **Resend** on the viewer you'd like to re-share the public dashboard with. @@ -149,7 +149,7 @@ If a Grafana user has read access to the parent dashboard, they can view the pub You can check usage analytics about your public dashboard by clicking the insights icon in the dashboard header: -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights.png" max-width="400px" class="docs-image--no-shadow" alt="Dashboard insights icon" >}} +{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-11.2.png" max-width="400px" class="docs-image--no-shadow" alt="Dashboard insights icon" >}} Learn more about the kind of information provided in the [dashboard insights documentation](ref:dashboard-insights-documentation). diff --git a/docs/sources/dashboards/share-dashboards-panels/index.md b/docs/sources/dashboards/share-dashboards-panels/index.md index 5aacb8fde45..01ef05c1923 100644 --- a/docs/sources/dashboards/share-dashboards-panels/index.md +++ b/docs/sources/dashboards/share-dashboards-panels/index.md @@ -59,9 +59,9 @@ You must have an authorized viewer permission to see an image rendered by a dire The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. -{{% admonition type="note" %}} +{{< admonition type="note" >}} As of Grafana 8.0, anonymous access permission is not available in Grafana Cloud. -{{% /admonition %}} +{{< /admonition >}} When you share a panel or dashboard as a snapshot, a snapshot (which is a panel or dashboard at the moment you take the snapshot) is publicly available on the web. Anyone with a link to it can access it. Because snapshots do not require any authorization to view, Grafana removes information related to the account it came from, as well as any sensitive data from the snapshot. @@ -69,13 +69,13 @@ When you share a panel or dashboard as a snapshot, a snapshot (which is a panel You can share a dashboard as a direct link or as a snapshot. You can also export a dashboard. -{{% admonition type="note" %}} +{{< admonition type="note" >}} If you change a dashboard, ensure that you save the changes before sharing. -{{% /admonition %}} +{{< /admonition >}} -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Click the dashboard you want to share. -1. Click the **Share** button at the top right of the screen. +1. Click **Share** in the top-right corner. The share dialog opens and shows the Link tab. @@ -108,7 +108,7 @@ If you created a snapshot by mistake, click **Delete snapshot** in the dialog bo To delete existing snapshots, follow these steps: -1. In the primary menu, click **Dashboards**. +1. Click **Dashboards** in the main menu. 1. Click **Snapshots** to go to the snapshots management page. 1. Click the red **x** next to the snapshot URL that you want to delete. @@ -120,7 +120,7 @@ The dashboard export action creates a Grafana JSON file that contains everything 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Share** icon in the top navigation bar. +1. Click **Share** in the top-right corner. 1. Click **Export**. If you're exporting the dashboard to use in another instance, with different data source UIDs, enable the **Export for sharing externally** switch. @@ -139,12 +139,14 @@ A template variable of the type `Constant` is automatically hidden in the dashbo You can generate and save PDF files of any dashboard. -> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< admonition type="note" >}} +Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -1. Click **Dashboards** in the left-side menu. +1. Click **Dashboards** in the main menu. 1. Click the dashboard you want to share. -1. Click the **Share** button at the top right of the screen. -1. On the PDF tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. +1. Click **Share** in the top-right corner. +1. On the **PDF** tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. 1. Click **Save as PDF** to render the dashboard as a PDF file. Grafana opens the PDF file in a new window or browser tab. @@ -210,7 +212,7 @@ If you created a snapshot by mistake, click **Delete snapshot** in the dialog bo To delete existing snapshots, follow these steps: -1. In the primary menu, click **Dashboards**. +1. Click **Dashboards** in the main menu. 1. Click **Snapshots** to go to the snapshots management page. 1. Click the red **x** next to the snapshot URL that you want to delete. @@ -220,7 +222,9 @@ The snapshot is immediately deleted. You may need to clear your browser cache or You can embed a panel using an iframe on another web site. A viewer must be signed into Grafana to view the graph. -**> Note:** As of Grafana 8.0, anonymous access permission is no longer available for Grafana Cloud. +{{< admonition type="note" >}} +As of Grafana 8.0, anonymous access permission is no longer available for Grafana Cloud. +{{< /admonition >}} Here is an example of the HTML code: @@ -243,4 +247,4 @@ To create a library panel from the **Share Panel** dialog: 1. In **Library panel name**, enter the name. 1. In **Save in folder**, select the folder in which to save the library panel. By default, the root level is selected. 1. Click **Create library panel** to save your changes. -1. Save the dashboard. +1. Click **Save dashboard**. diff --git a/docs/sources/dashboards/use-dashboards/index.md b/docs/sources/dashboards/use-dashboards/index.md index 6ae3d7b1b70..a2b2559d390 100644 --- a/docs/sources/dashboards/use-dashboards/index.md +++ b/docs/sources/dashboards/use-dashboards/index.md @@ -26,7 +26,7 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/assess-dashboard-usage/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/assess-dashboard-usage/ + destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ generative-ai-features: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/manage-dashboards/#set-up-generative-ai-features-for-dashboards @@ -36,12 +36,37 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/modify-dashboard-settings/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/modify-dashboard-settings/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/modify-dashboard-settings/ repeating-rows: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeating-rows - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/create-dashboard/#configure-repeating-rows + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows + variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/variables/ + dashboard-folders: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/manage-dashboards/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/manage-dashboards/ + sharing: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ + dashboard-links: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/manage-dashboard-links/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/manage-dashboard-links/ + panel-overview: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/panel-overview/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-overview/ --- # Use dashboards @@ -56,32 +81,30 @@ The dashboard user interface provides a number of features that you can use to c The following image and descriptions highlight all dashboard features. -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-annotated-9-5-0.png" width="700px" alt="An annotated image of a dashboard" >}} +![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-annotated-11.2.png) -- (1) **Grafana home**: Click **Home** in the breadcrumb to be redirected to the home page configured in the Grafana instance. -- (2) **Dashboard title**: When you click the dashboard title, you can search for dashboards contained in the current folder. You can create your own dashboard titles or have Grafana create them for you using [generative AI features](ref:generative-ai-features). -- (3) **Share dashboard or panel**: Use this option to share the current dashboard or panel using a link or snapshot. You can also export the dashboard definition from the share modal. -- (4) **Add**: Use this option to add a panel, dashboard row, or library panel to the current dashboard. -- (5) **Save dashboard**: Click to save changes to your dashboard. -- (6) **Dashboard insights**: Click to view analytics about your dashboard including information about users, activity, query counts. Learn more about [dashboard analytics](ref:dashboard-analytics). -- (7) **Dashboard settings**: Use this option to change dashboard name, folder, and tags and manage variables and annotation queries. Learn more about [dashboard settings](ref:dashboard-settings). -- (8) **Time picker dropdown**: Click to select relative time range options and set custom absolute time ranges. - - You can change the **Timezone** and **fiscal year** settings from the time range controls by clicking the **Change time settings** button. - - Time settings are saved on a per-dashboard basis. -- (9) **Zoom out time range**: Click to zoom out the time range. Learn more about how to use [common time range controls](#common-time-range-controls). -- (10) **Refresh dashboard**: Click to immediately trigger queries and refresh dashboard data. -- (11) **Refresh dashboard time interval**: Click to select a dashboard auto refresh time interval. -- (12) **View mode**: Click to display the dashboard on a large screen such as a TV or a kiosk. View mode hides irrelevant information such as navigation menus. Learn more about view mode in our [How to Create Kiosks to Display Dashboards on a TV blog post](https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv/). -- (13) **Dashboard panel**: The primary building block of a dashboard is the panel. To add a new panel, dashboard row, or library panel, click **Add panel**. - - Library panels can be shared among many dashboards. - - To move a panel, drag the panel header to another location. - - To resize a panel, click and drag the lower right corner of the panel. - - Use [generative AI features](ref:generative-ai-features) to create panel titles and descriptions. -- (14) **Graph legend**: Change series colors, y-axis and series visibility directly from the legend. -- (15) **Dashboard row**: A dashboard row is a logical divider within a dashboard that groups panels together. - - Rows can be collapsed or expanded allowing you to hide parts of the dashboard. - - Panels inside a collapsed row do not issue queries. - - Use [repeating rows](ref:repeating-rows) to dynamically create rows based on a template variable. +1. **Grafana home** - Click **Home** in the breadcrumb to go to the home page configured in the Grafana instance. +1. **Dashboard folder** - When you click the dashboard folder name, you can search for other dashboards contained in the folder and perform other [folder management tasks](ref:dashboard-folders). +1. **Dashboard title** - You can create your own dashboard titles or have Grafana create them for you using [generative AI features](ref:generative-ai-features). +1. **Mark as favorite** - Mark the dashboard as one of your favorites so it's included in your list of **Starred** dashboards in the main menu. +1. **Dashboard insights** - Click to view analytics about your dashboard including information about users, activity, query counts. Learn more about [dashboard analytics](ref:dashboard-analytics). +1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. +1. **Edit** - Click to leave view-only mode and enter edit mode, where you can make changes directly to the dashboard and access dashboard settings, as well as several panel editing functions. +1. **Kiosk mode** - Click to display the dashboard on a large screen such as a TV or a kiosk. Kiosk mode hides elements such as navigation menus. Learn more about kiosk mode in our [How to Create Kiosks to Display Dashboards on a TV blog post](https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv/). Press `Enter` to leave kiosk mode. +1. **Variables** - Use [variables](ref:variables) to create more interactive and dynamic dashboards. +1. **Dashboard links** - Link to other dashboards, panels, and external websites. Learn more about [dashboard links](ref:dashboard-links). +1. **Current dashboard time range and time picker** - Click to select [relative time range](#relative-time-range) options and set custom [absolute time ranges](#absolute-time-range). + - You can change the **Timezone** and **Fiscal year** settings from the time range controls by clicking the **Change time settings** button. + - Time settings are saved on a per-dashboard basis. +1. **Time range zoom out** - Click to zoom out the time range. Learn more about how to use [common time range controls](#common-time-range-controls). +1. **Refresh dashboard** - Click to immediately trigger queries and refresh dashboard data. +1. **Auto refresh control** - Click to select a dashboard auto refresh time interval. +1. **Dashboard row** - A dashboard row is a logical divider within a dashboard that groups panels together. + - Rows can be collapsed or expanded allowing you to hide parts of the dashboard. + - Panels inside a collapsed row do not issue queries. + - Use [repeating rows](ref:repeating-rows) to dynamically create rows based on a template variable. +1. **Dashboard panel** - The [panel](ref:panel-overview) is the primary building block of a dashboard. +1. **Panel legend** - Change series colors as well as y-axis and series visibility directly from the legend. ## Keyboard shortcuts @@ -148,7 +171,7 @@ Grafana Alerting does not support the following syntaxes at this time: The dashboard and panel time controls have a common UI. - +![Common time controls](/media/docs/grafana/dashboards/screenshot-common-time-controls-11.2.png) The following sections define common time range controls. @@ -158,11 +181,11 @@ The current time range, also called the _time picker_, shows the time range curr Hover your cursor over the field to see the exact time stamps in the range and their source (such as the local browser). - +![Time picker](/media/docs/grafana/dashboards/screenshot-time-picker-11.2.png) Click the current time range to change it. You can change the current time using a _relative time range_, such as the last 15 minutes, or an _absolute time range_, such as `2020-05-14 00:00:00 to 2020-05-15 23:59:59`. - +![Current time range](/media/docs/grafana/dashboards/screenshot-current-time-range-11.2.png) #### Relative time range diff --git a/docs/sources/dashboards/variables/add-template-variables/index.md b/docs/sources/dashboards/variables/add-template-variables/index.md index b7769ef6509..ee2e77bace2 100644 --- a/docs/sources/dashboards/variables/add-template-variables/index.md +++ b/docs/sources/dashboards/variables/add-template-variables/index.md @@ -49,32 +49,32 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/add-template-variables/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/add-template-variables/ + destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/ inspect: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/inspect-variable/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/inspect-variable/ + destination: /docs/grafana-cloud/visualizations/dashboards/variables/inspect-variable/ prometheus-query-variables: - pattern: /docs/grafana/ destination: /docs/grafana//datasources/prometheus/template-variables/#use-**rate_interval - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/prometheus/template-variables/#use-**rate_interval + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/prometheus/template-variables/#use-**rate_interval raw-variable-format: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/variable-syntax/#raw - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/variable-syntax/#raw + destination: /docs/grafana-cloud/visualizations/dashboards/variables/variable-syntax/#raw data-source: - pattern: /docs/grafana/ destination: /docs/grafana//datasources/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/ + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/ raw-format: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/variable-syntax/#raw - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/variables/variable-syntax/#raw + destination: /docs/grafana-cloud/visualizations/dashboards/variables/variable-syntax/#raw add-a-data-source: - pattern: /docs/grafana/ destination: /docs/grafana//administration/data-source-management/#add-a-data-source @@ -102,18 +102,20 @@ The following table lists the types of variables shipped with Grafana. You must enter general options for any type of variable that you create. -1. Navigate to the dashboard you want to make a variable for and click the **Dashboard settings** (gear) icon at the top of the page. -1. On the **Variables** tab, click **New variable**. +1. Click **Edit** in the top-right corner of the dashboard. +1. Click **Settings**. +1. Go to the **Variables** tab. +1. Click **Add variable**. 1. Enter a **Name** for the variable. -1. In the **Type** list, select **Query**. +1. In the **Select variable type** drop-down, choose **Query**. 1. (Optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label is the variable name. -1. Choose a **Hide** option: - - **No selection (blank):** The variable dropdown displays the variable **Name** or **Label** value. This is the default. - - **Label:** The variable dropdown only displays the selected variable value and a down arrow. - - **Variable:** No variable dropdown is displayed on the dashboard. +1. Choose a **Show on dashboard** option: + - **Label and value** - The variable drop-down displays the variable **Name** or **Label** value. This is the default. + - **Value:** The variable drop-down only displays the selected variable value and a down arrow. + - **Nothing:** No variable drop-down is displayed on the dashboard. ## Add a query variable @@ -123,7 +125,9 @@ Query variables are generally only supported for strings. If your query returns Query expressions can contain references to other variables and in effect create linked variables. Grafana detects this and automatically refreshes a variable when one of its linked variables change. -> **Note:** Query expressions are different for each data source. For more information, refer to the documentation for your [data source](ref:data-source). +{{< admonition type="note" >}} +Query expressions are different for each data source. For more information, refer to the documentation for your [data source](ref:data-source). +{{< /admonition >}} 1. [Enter general options](#enter-general-options). 1. In the **Data source** list, select the target data source for the query. For more information about data sources, refer to [Add a data source](ref:add-a-data-source). @@ -131,14 +135,18 @@ Query expressions can contain references to other variables and in effect create - **On Dashboard Load:** Queries the data source every time the dashboard loads. This slows down dashboard loading, because the variable query needs to be completed before dashboard can be initialized. - **On Time Range Change:** Queries the data source every time the dashboard loads and when the dashboard time range changes. Use this option if your variable options query contains a time range filter or is dependent on the dashboard time range. 1. In the **Query** field, enter a query. + - The query field varies according to your data source. Some data sources have custom query editors. - - Make sure that the query returns values named `__text` and `__value` as appropriate in your query syntax. For example, in SQL, you can use a query such as `SELECT hostname AS __text, id AS __value FROM MyTable`. Queries for other languages will vary depending on syntax. + - Each data source defines how the variable values are extracted. The typical implementation uses every string value returned from the data source response as a variable value. Make sure to double-check the documentation for the data source. + - Some data sources let you provide custom "display names" for the values. For instance, the PostgreSQL, MySQL, and Microsoft SQL Server plugins handle this by looking for fields named `__text` and `__value` in the result. Other data sources may look for `text` and `value` or use a different approach. Always remember to double-check the documentation for the data source. - If you need more room in a single input field query editor, then hover your cursor over the lines in the lower right corner of the field and drag downward to expand. + 1. (Optional) In the **Regex** field, type a regex expression to filter or capture specific parts of the names returned by your data source query. To see examples, refer to [Filter variables with regex](#filter-variables-with-regex). 1. In the **Sort** list, select the sort order for values to be displayed in the dropdown list. The default option, **Disabled**, means that the order of options returned by your data source query will be used. 1. (Optional) Enter [Selection Options](#configure-variable-selection-options). 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ## Add a custom variable @@ -150,7 +158,8 @@ For example, if you have server names or region names that never change, then yo 1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, or key/value pairs separated by a space and a colon. For example, `key1 : value1,key2 : value2`. 1. (Optional) Enter [Selection Options](#configure-variable-selection-options). 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ## Add a text box variable @@ -161,7 +170,8 @@ For more information about cardinality, refer to [What are cardinality spikes an 1. [Enter general options](#enter-general-options). 1. (Optional) In the **Default value** field, select the default value for the variable. If you do not enter anything in this field, then Grafana displays an empty text box for users to type text into. 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ## Add a constant variable @@ -174,7 +184,8 @@ Constant variables are useful when you have complex values that you need to incl 1. [Enter general options](#enter-general-options). 1. In the **Value** field, enter the variable value. You can enter letters, numbers, and symbols. You can even use wildcards if you use [raw format](ref:raw-format). 1. In **Preview of values**, Grafana displays the current variable value. Review it to ensure it matches what you expect. -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ## Add a data source variable @@ -188,7 +199,8 @@ _Data source_ variables enable you to quickly change the data source for an enti 1. (Optional) In **Instance name filter**, enter a regex filter for which data source instances to choose from in the variable value drop-down list. Leave this field empty to display all instances. 1. (Optional) Enter [Selection Options](#configure-variable-selection-options). 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ## Add an interval variable @@ -202,7 +214,8 @@ You can use an interval variable as a parameter to group by time (for InfluxDB), - **Step count -** Select the number of times the current time range will be divided to calculate the value, similar to the **Max data points** query option. For example, if the current visible time range is 30 minutes, then the `auto` interval groups the data into 30 one-minute increments. The default value is 30 steps. - **Min Interval -** The minimum threshold below which the step count intervals will not divide the time. To continue the 30 minute example, if the minimum interval is set to 2m, then Grafana would group the data into 15 two-minute increments. 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ### Interval variable examples @@ -231,7 +244,8 @@ Ad hoc filter variables only work with Prometheus, Loki, InfluxDB, and Elasticse You can also click **Open advanced data source picker** to see more options, including adding a data source (Admins only). For more information about data sources, refer to [Add a data source](ref:add-a-data-source). -1. Click **Add** to add the variable to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. ### Create ad hoc filters @@ -392,7 +406,8 @@ Extremely complex linked templated dashboards are possible, 5 or 10 levels deep. ### Grafana Play dashboard examples -The following Grafana Play dashboards contain fairly simple chained variables, only two layers deep. To view the variables and their settings, click **Dashboard settings** (gear icon) and then click **Variables**. Both examples are expanded in the following section. +The following Grafana Play dashboards contain fairly simple chained variables, only two layers deep. To view the variables and their settings, click **Edit** +and then **Settings**; then go to the **Variables** tab. Both examples are expanded in the following section. - [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1&var-app=country&var-server=All&var-interval=1h) - [InfluxDB Templated](https://play.grafana.org/d/e7bad3ef-db0c-4bbd-8245-b85c0b2ca2b9/influx-2-73a-hourly-electric-grid-monitor-for-us?orgId=1&refresh=1m) @@ -658,4 +673,6 @@ enp216s0f0np2 0000:d7:00_0_0000:d8:00_2 enp216s0f0np3 0000:d7:00_0_0000:d8:00_3 ``` -**Note:** Only `text` and `value` capture group names are supported. +{{< admonition type="note" >}} +Only `text` and `value` capture group names are supported. +{{< /admonition >}} diff --git a/docs/sources/dashboards/variables/inspect-variable/index.md b/docs/sources/dashboards/variables/inspect-variable/index.md index 2f3bd8ff9b0..1e0464020b7 100644 --- a/docs/sources/dashboards/variables/inspect-variable/index.md +++ b/docs/sources/dashboards/variables/inspect-variable/index.md @@ -28,20 +28,22 @@ weight: 200 # Manage and inspect variables -The variables page lets you [add](ref:add) variables and [manage](#manage-variables) existing variables. It also allows you to [inspect](#inspect-variables) variables and identify whether a variable is being referenced (or used) in other variables or dashboard. +In the **Variables** tab, you can [add](ref:add) variables and [manage](#manage-variables) existing variables. You can also [inspect](#inspect-variables) variables to identify any dependencies between them. ## Manage variables -You can take the following actions on the variables page: +You can take the following actions in the **Variables** tab: -**Move:** You can move a variable up or down the list using drag and drop. - -**Clone:** To clone a variable, click the clone icon from the set of icons on the right. This creates a copy of the variable with the name of the original variable prefixed with `copy_of_`. - -**Delete:** To delete a variable, click the trash icon from the set of icons on the right. +- **Move** - Move a variable up or down the list using drag and drop. +- **Clone** - Clone a variable by clicking the clone icon in the set of icons on the right. This creates a copy of the variable with the name of the original variable prefixed with `copy_of_`. +- **Delete** - Delete a variable by clicking the trash icon in the set of icons on the right. ## Inspect variables +In addition to [managing variables](#manage-variables), the **Variables** tab lets you easily identify whether variables have any dependencies. To check, click **Show dependencies** at the bottom of the list, which opens the dependencies diagram: + + ![Variables list](/static/img/docs/variables-templates/dependancy-map-7-4.png) + +{{% admonition type="note" %}} +This feature is available in Grafana 7.4 and later versions. +{{% /admonition %}} diff --git a/docs/sources/datasources/influxdb/query-editor/index.md b/docs/sources/datasources/influxdb/query-editor/index.md index 1c72886c783..76f1a50d733 100644 --- a/docs/sources/datasources/influxdb/query-editor/index.md +++ b/docs/sources/datasources/influxdb/query-editor/index.md @@ -19,16 +19,18 @@ refs: query-transform-data: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/query-transform-data/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/ panel-inspector: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/panel-inspector/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/panel-inspector/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-inspector/ logs: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/visualizations/logs/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//panels-visualizations/visualizations/logs/ + destination: grafana-cloud/visualizations/panels-visualizations/visualizations/logs/ --- # InfluxDB query editor diff --git a/docs/sources/datasources/prometheus/_index.md b/docs/sources/datasources/prometheus/_index.md index 94895b902c3..e5fa61ef1d6 100644 --- a/docs/sources/datasources/prometheus/_index.md +++ b/docs/sources/datasources/prometheus/_index.md @@ -89,7 +89,7 @@ For instructions on how to add a data source to Grafana, refer to the [administr Only users with the organization `administrator` role can add data sources and edit existing data sources. Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning system. -Once you've added the Prometheus data source, you can [configure it](ref:configure-prometheus-data-source) so that your Grafana instance's users can create queries in its [query editor]({{< relref "./query-editor" >}}) when they [build dashboards](ref:build-dashboards), use [Explore](ref:explore), and [annotate visualizations][annotate visualizations]. +Once you've added the Prometheus data source, you can [configure it](ref:configure-prometheus-data-source) so that your Grafana instance's users can create queries in its [query editor]({{< relref "./query-editor" >}}) when they [build dashboards](ref:build-dashboards), use [Explore](ref:explore), and [annotate visualizations](ref:annotate-visualizations). The following guides will help you get started with the Prometheus data source: @@ -152,7 +152,7 @@ We also bundle a dashboard within Grafana so you can start viewing your metrics **To import the bundled dashboard:** -1. Navigate to the data source's [configuration page](#configure-the-data-source). +1. Navigate to the data source's [configuration page](ref:configure-prometheus-data-source). 1. Select the **Dashboards** tab. This displays dashboards for Grafana and Prometheus. diff --git a/docs/sources/datasources/tempo/configure-tempo-data-source.md b/docs/sources/datasources/tempo/configure-tempo-data-source.md index cc54fae4c44..93fe169a7ba 100644 --- a/docs/sources/datasources/tempo/configure-tempo-data-source.md +++ b/docs/sources/datasources/tempo/configure-tempo-data-source.md @@ -273,8 +273,8 @@ datasources: # Field with an internal link pointing to a logs data source in Grafana. # datasourceUid value must match the uid value of the logs data source. datasourceUid: 'loki' - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' + spanStartTimeShift: '-1h' + spanEndTimeShift: '1h' tags: ['job', 'instance', 'pod', 'namespace'] filterByTraceID: false filterBySpanID: false diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 3dfd8a17358..647a3fe485d 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -72,6 +72,10 @@ "pr": { "type": "number", "description": "GitHub pull request the plugin was built from" + }, + "build": { + "type": "number", + "description": "Build job number used to build this plugin." } } }, diff --git a/docs/sources/getting-started/build-first-dashboard.md b/docs/sources/getting-started/build-first-dashboard.md index 6b21909b9a5..0f1c4025b4c 100644 --- a/docs/sources/getting-started/build-first-dashboard.md +++ b/docs/sources/getting-started/build-first-dashboard.md @@ -14,7 +14,7 @@ weight: 200 # Build your first dashboard -This topic helps you get started with Grafana and build your first dashboard using the built-in `Grafana` data source. To learn more about Grafana, refer to [Introduction to Grafana]({{< relref "../introduction" >}}). +This topic helps you get started with Grafana and build your first dashboard using the built-in `Grafana` data source. To learn more about Grafana, refer to [Introduction to Grafana](https://grafana.com/docs/grafana//introduction/). {{% admonition type="note" %}} Grafana also offers a [free account with Grafana Cloud](/signup/cloud/connect-account?pg=gsdocs) to help getting started even easier and faster. You can install Grafana to self-host or get a free Grafana Cloud account. @@ -22,7 +22,7 @@ Grafana also offers a [free account with Grafana Cloud](/signup/cloud/connect-ac #### Install Grafana -Grafana can be installed on many different operating systems. For a list of the minimum hardware and software requirements, as well as instructions on installing Grafana, refer to [Install Grafana]({{< relref "../setup-grafana/installation" >}}). +Grafana can be installed on many different operating systems. For a list of the minimum hardware and software requirements, as well as instructions on installing Grafana, refer to [Install Grafana](https://grafana.com/docs/grafana//setup-grafana/installation/). #### Sign in to Grafana @@ -39,18 +39,18 @@ To sign in to Grafana for the first time: 1. Click **OK** on the prompt and change your password. -{{% admonition type="note" %}} +{{< admonition type="note" >}} We strongly recommend that you change the default administrator password. -{{% /admonition %}} +{{< /admonition >}} #### Create a dashboard -If you've already set up a data source that you know how to query, refer to [Create a dashboard]({{< relref "../dashboards/build-dashboards/create-dashboard" >}}) instead. +If you've already set up a data source that you know how to query, refer to [Create a dashboard](https://grafana.com/docs/grafana//dashboards/build-dashboards/create-dashboard/) instead. To create your first dashboard using the built-in `-- Grafana --` data source: -1. Click **Dashboards** in the left-side menu. -1. On the Dashboards page, click **New** and select **New Dashboard** from the drop-down menu. +1. Click **Dashboards** in the main menu. +1. On the **Dashboards** page, click **New** and select **New Dashboard** from the drop-down menu. 1. On the dashboard, click **+ Add visualization**. ![Empty dashboard state](/media/docs/grafana/dashboards/empty-dashboard-10.2.png) @@ -59,35 +59,33 @@ To create your first dashboard using the built-in `-- Grafana --` data source: {{< figure class="float-right" src="/media/docs/grafana/dashboards/screenshot-data-source-selector-10.0.png" max-width="800px" alt="Select data source dialog box" >}} - This configures your [query]({{< relref "../panels-visualizations/query-transform-data#add-a-query" >}}) and generates the Random Walk dashboard. + This configures your [query](https://grafana.com/docs/grafana//panels-visualizations/query-transform-data/#add-a-query) and generates the Random Walk dashboard. -1. Click the Refresh dashboard icon to query the data source. +1. Click **Refresh** to query the data source. +1. When you've finished editing your panel, click **Save dashboard**. - ![Refresh dashboard icon](/media/docs/grafana/dashboards/screenshot-refresh-dashboard-9.5.png) - -1. When you've finished editing your panel, click **Save** to save the dashboard. - - Alternatively, click **Apply** if you want to see your changes applied to the dashboard first. Then click the save icon in the dashboard header. + Alternatively, click **Back to dashboard** if you want to see your changes applied to the dashboard first. Then click **Save dashboard** when you're ready. 1. Add a descriptive title for the dashboard, or have Grafana create one using [generative AI features](https://grafana.com/docs/grafana//dashboards/manage-dashboards#set-up-generative-ai-features-for-dashboards), and then click **Save**. +1. Click **Back to dashboard** and then **Exit edit**. Congratulations, you have created your first dashboard and it's displaying results. #### Next steps -Continue to experiment with what you have built, try the [explore workflow]({{< relref "../explore" >}}) or another visualization feature. Refer to [Data sources]({{< relref "../datasources" >}}) for a list of supported data sources and instructions on how to [add a data source]({{< relref "../datasources#add-a-data-source" >}}). The following topics will be of interest to you: +Continue to experiment with what you have built, try the [explore workflow](https://grafana.com/docs/grafana//explore/) or another visualization feature. Refer to [Data sources](https://grafana.com/docs/grafana//datasources/) for a list of supported data sources and instructions on how to [add a data source](https://grafana.com/docs/grafana//datasources/#add-a-data-source). The following topics will be of interest to you: -- [Panels and visualizations]({{< relref "../panels-visualizations" >}}) -- [Dashboards]({{< relref "../dashboards" >}}) -- [Keyboard shortcuts]({{< relref "../dashboards/use-dashboards#keyboard-shortcuts" >}}) +- [Panels and visualizations](https://grafana.com/docs/grafana//panels-visualizations/) +- [Dashboards](https://grafana.com/docs/grafana//dashboards/) +- [Keyboard shortcuts](https://grafana.com/docs/grafana//dashboards/use-dashboards/#keyboard-shortcuts) - [Plugins](/grafana/plugins?orderBy=weight&direction=asc) ##### Admins The following topics are of interest to Grafana server admin users: -- [Grafana configuration]({{< relref "../setup-grafana/configure-grafana" >}}) -- [Authentication]({{< relref "../setup-grafana/configure-security/configure-authentication" >}}) -- [User permissions and roles]({{< relref "../administration/roles-and-permissions" >}}) -- [Provisioning]({{< relref "../administration/provisioning" >}}) -- [Grafana CLI]({{< relref "../cli" >}}) +- [Grafana configuration](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/) +- [Authentication](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/) +- [User permissions and roles](https://grafana.com/docs/grafana//administration/roles-and-permissions/) +- [Provisioning](https://grafana.com/docs/grafana//administration/provisioning/) +- [Grafana CLI](https://grafana.com/docs/grafana//cli/) diff --git a/docs/sources/panels-visualizations/configure-data-links/index.md b/docs/sources/panels-visualizations/configure-data-links/index.md index a6f644da36e..1b454b46848 100644 --- a/docs/sources/panels-visualizations/configure-data-links/index.md +++ b/docs/sources/panels-visualizations/configure-data-links/index.md @@ -250,5 +250,5 @@ If you want to add all of the current dashboard's variables to the URL, then use 1. If you want the link to open in a new tab, then toggle the **Open in a new tab** switch. 1. Click **Save** to save changes and close the dialog box. -1. Click **Apply** to see your changes in the dashboard. -1. Click the **Save dashboard** icon to save your changes to the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. diff --git a/docs/sources/panels-visualizations/configure-overrides/index.md b/docs/sources/panels-visualizations/configure-overrides/index.md index c9ad3f7a815..2b7264ee6d4 100644 --- a/docs/sources/panels-visualizations/configure-overrides/index.md +++ b/docs/sources/panels-visualizations/configure-overrides/index.md @@ -244,7 +244,8 @@ To add a field override, follow these steps: 1. Select the field option that you want to apply. 1. Continue to add overrides to this field by clicking **Add override property**. 1. Add as many overrides as you need. -1. When you're finished, click **Save** to save all panel edits to the dashboard. +1. When you're finished, click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. ## Edit a field override @@ -259,5 +260,7 @@ To edit a field override, follow these steps: - Edit settings on existing overrides or field selection parameters. - Delete existing override properties by clicking the **X** next to the property. - Delete an override entirely by clicking the trash icon at the top-right corner. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. The changes you make take effect immediately. diff --git a/docs/sources/panels-visualizations/configure-panel-options/index.md b/docs/sources/panels-visualizations/configure-panel-options/index.md index aa80d4f5def..59c466a20fb 100644 --- a/docs/sources/panels-visualizations/configure-panel-options/index.md +++ b/docs/sources/panels-visualizations/configure-panel-options/index.md @@ -95,7 +95,8 @@ To configure repeating panels, follow these steps: - **Vertical** - Arrange panels in a column. The width of repeated panels is the same as the original, repeated panel. 1. If you selected **Horizontal** in the previous step, select a value in the **Max per row** drop-down list to control the maximum number of panels that can be in a row. -1. Click **Save**. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. 1. To propagate changes to all panels, reload the dashboard. You can stop a panel from repeating by selecting **Disable repeating** in the **Repeat by variable** drop-down list. diff --git a/docs/sources/panels-visualizations/configure-thresholds/index.md b/docs/sources/panels-visualizations/configure-thresholds/index.md index 5c5c9bb3707..102b8ea4505 100644 --- a/docs/sources/panels-visualizations/configure-thresholds/index.md +++ b/docs/sources/panels-visualizations/configure-thresholds/index.md @@ -174,6 +174,8 @@ You can add as many thresholds to a visualization as you want. Grafana automatic 1. Click the colored circle to the left of the threshold value to open the color picker, where you can update the threshold color. 1. Under **Thresholds mode**, select either **Absolute** or **Percentage**. 1. Under **Show thresholds**, set how the threshold is displayed or turn it off. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. To delete a threshold, navigate to the panel that contains the threshold and click the trash icon next to the threshold you want to remove. diff --git a/docs/sources/panels-visualizations/configure-value-mappings/index.md b/docs/sources/panels-visualizations/configure-value-mappings/index.md index 8b9f77276cb..754eeed29ee 100644 --- a/docs/sources/panels-visualizations/configure-value-mappings/index.md +++ b/docs/sources/panels-visualizations/configure-value-mappings/index.md @@ -185,3 +185,6 @@ The following image shows a table visualization with value mappings. If you want 1. Click **Update** to save the value mapping. After you've added a mapping, the **Edit value mappings** button replaces the **Add value mappings** button. Click the edit button to add or update mappings. + +1. Click **Save dashboard**. +1. Click **Back to dashboard** and then **Exit edit**. diff --git a/docs/sources/panels-visualizations/panel-editor-overview/index.md b/docs/sources/panels-visualizations/panel-editor-overview/index.md index 38fba108702..4d131a900ca 100644 --- a/docs/sources/panels-visualizations/panel-editor-overview/index.md +++ b/docs/sources/panels-visualizations/panel-editor-overview/index.md @@ -52,7 +52,7 @@ refs: In the panel editor, you can update all the elements of a visualization including the data source, queries, time range, and visualization display options. -![Panel editor](/media/docs/grafana/panels-visualizations/screenshot-panel-editor-view.png) +![Panel editor](/media/docs/grafana/panels-visualizations/screenshot-grafana-11.2-panel-editor.png) This following sections describe the areas of the Grafana panel editor. @@ -60,26 +60,25 @@ This following sections describe the areas of the Grafana panel editor. The header section lists the dashboard in which the panel appears and the following controls: -- **Discard:** Discards changes you have made to the panel since you last saved the dashboard. -- **Save:** Saves changes you made to the panel. -- **Apply:** Applies changes you made and closes the panel editor, returning you to the dashboard. You'll have to save the dashboard to persist the applied changes. +- **Back to dashboard** - Return to the dashboard with changes applied, but not yet saved. +- **Discard panel changes** - Discard changes you have made to the panel since you last saved the dashboard. +- **Save dashboard** - Save your changes to the dashboard. ## Visualization preview The visualization preview section contains the following options: -- **Table view:** Convert any visualization to a table so you can see the data. Table views are helpful for troubleshooting. This view only contains the raw data. It doesn't include transformations you might have applied to the data or the formatting options available in the [Table](ref:table) visualization. -- **Fill:** The visualization preview fills the available space. If you change the width of the side pane or height of the bottom pane the visualization changes to fill the available space. -- **Actual:** The visualization preview has the exact size as the size on the dashboard. If not enough space is available, the visualization scales down preserving the aspect ratio. -- **Time range controls:** **Default** is either the browser local timezone or the timezone selected at a higher level. +- **Table view** - Convert any visualization to a table so you can see the data. Table views are helpful for troubleshooting. This view only contains the raw data. It doesn't include transformations you might have applied to the data or the formatting options available in the [Table](ref:table) visualization. +- **Time range controls** - **Default** is either the browser local timezone or the timezone selected at a higher level. +- **Refresh** - Query the data source. ## Data section The data section contains tabs where you enter queries, transform your data, and create alert rules (if applicable). -- **Query tab:** Select your data source and enter queries here. For more information, refer to [Add a query](ref:add-a-query). When you create a new dashboard, you'll be prompted to select a data source before you get to the panel editor. You set or update the data source in existing dashboards using the drop-down in the **Query** tab. -- **Transform tab:** Apply data transformations. For more information, refer to [Transform data](ref:transform-data). -- **Alert tab:** Write alert rules. For more information, refer to [the overview of Grafana Alerting](ref:the-overview-of-grafana-alerting). +- **Queries** - Select your data source and enter queries here. For more information, refer to [Add a query](ref:add-a-query). When you create a new dashboard, you'll be prompted to select a data source before you get to the panel editor. You set or update the data source in existing dashboards using the drop-down in the **Queries** tab. +- **Transformations** - Apply data transformations. For more information, refer to [Transform data](ref:transform-data). +- **Alert** - Write alert rules. For more information, refer to [the overview of Grafana Alerting](ref:the-overview-of-grafana-alerting). ## Panel display options diff --git a/docs/sources/panels-visualizations/panel-overview/index.md b/docs/sources/panels-visualizations/panel-overview/index.md index caa196fa1e2..09545bcf74e 100644 --- a/docs/sources/panels-visualizations/panel-overview/index.md +++ b/docs/sources/panels-visualizations/panel-overview/index.md @@ -175,9 +175,12 @@ To add a panel in a new dashboard click **+ Add visualization** in the middle of ![Empty dashboard state](/media/docs/grafana/dashboards/empty-dashboard-10.2.png) -To add a panel to an existing dashboard, click **Add** in the dashboard header and select **Visualization** in the drop-down: +To add a panel to an existing dashboard, follow these steps: -![Add dropdown](/media/docs/grafana/dashboards/screenshot-add-dropdown-10.0.png) +1. Click **Edit** in the top-right corner of the dashboard. +1. Click the **Add** drop-down and select **Visualization**: + + ![Add dropdown](/media/docs/grafana/panels-visualizations/screenshot-add-dropdown-11.2.png) ## Panel configuration diff --git a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md index 799215e8af0..6fea002628b 100644 --- a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @@ -298,6 +298,9 @@ This transformation has the following options: - **Numeric** - attempts to make the values numbers - **String** - will make the values strings - **Time** - attempts to parse the values as time + - The input will be parsed according to the [Moment.js parsing format](https://momentjs.com/docs/#/parsing/) + - It will parse the numeric input as a Unix epoch timestamp in milliseconds. + You must multiply your input by 1000 if it's in seconds. - Will show an option to specify a DateFormat as input by a string like yyyy-mm-dd or DD MM YYYY hh:mm:ss - **Boolean** - will make the values booleans - **Enum** - will make the values enums diff --git a/docs/sources/panels-visualizations/visualizations/traces/index.md b/docs/sources/panels-visualizations/visualizations/traces/index.md index 46a309fccd1..ba7926f1b6d 100644 --- a/docs/sources/panels-visualizations/visualizations/traces/index.md +++ b/docs/sources/panels-visualizations/visualizations/traces/index.md @@ -41,10 +41,6 @@ refs: destination: /docs/grafana//datasources/tempo/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/ - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/tempo/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/ configure-panel-options-documentation: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-panel-options/ @@ -90,20 +86,30 @@ For more information on the panel editor, refer to the [Panel editor documentati This procedure uses dashboard variables and templates to allow you to enter trace IDs which can then be visualized. You'll use a variable called `traceId` and add it as a template query. 1. From your Grafana stack, create a new dashboard or go to an existing dashboard where you'd like to add traces visualizations. -1. Select **Add visualization** from a new dashboard or select **Add Panel** on an existing dashboard. +1. Do one of the following: + + - New dashboard - Click **+ Add visualization**. + - Existing dashboard - Click **Edit** in the top-right corner and then select **Visualization** in the **Add** drop-down. + 1. Search for and select the appropriate tracing data source. -1. In the top-right of the panel editor, select the **Visualizations** tab, search for, and select **Traces**. -1. Under the **Panel options**, enter a **Title** for your trace panel or have Grafana create one using [generative AI features](ref:generative-ai-features). For more information on the panel editor, refer to the [Configure panel options documentation](ref:configure-panel-options-documentation). -1. In the query editor, select the **TraceQL** query type tab. +1. In the top-right corner of the panel editor, select the **Visualizations** tab, search for, and select **Traces**. +1. Under the **Panel options**, enter a **Title** for your trace panel or have Grafana create one using [generative AI features](ref:generative-ai-features). + + For more information on the panel editor, refer to the [Configure panel options documentation](ref:configure-panel-options-documentation). + +1. In the query editor, click the **TraceQL** query type tab. 1. Enter `${traceId}` in the TraceQL query field to create a dashboard variable. This variable is used as the template query. {{< figure src="/static/img/docs/panels/traces/screenshot-traces-template-query.png" caption="Add a template query" >}} -1. Select **Apply** in the panel editor to add the panel to the dashboard. -1. Go to the dashboard **Settings** and add a new variable called `traceId`, of variable type **Custom**, giving it a label if required. Select **Apply** to add the variable to the dashboard. +1. Click **Back to dashboard**. +1. Click **Settings** and go to the **Variables** tab. +1. Add a new variable called `traceId`, of variable type **Custom**, giving it a label if required. - {{< figure src="/static/img/docs/panels/traces/screenshot-traces-custom-variable.png" max-width="50%" caption="Add a Custom variable" >}} + {{< figure src="/media/docs/grafana/dashboards/screenshot-traces-custom-variable-11.2.png" max-width="50%" caption="Add a Custom variable" >}} +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. 1. Verify that the panel works by using a valid trace ID for the data source used for the trace panel and editing the ID in the dashboard variable. {{< figure src="/static/img/docs/panels/traces/screenshot-traces-traceid-panel.png" caption="Results of query in trace panel" >}} @@ -113,15 +119,19 @@ This procedure uses dashboard variables and templates to allow you to enter trac While you can add a trace visualization to a dashboard, having to manually add trace IDs as a dashboard variable is cumbersome. It’s more useful to instead be able to use TraceQL queries to search for specific types of traces and then select appropriate traces from matching results. -1. In the same dashboard where you added the trace visualization, select **Add panel** to add a new visualization panel. +1. In the same dashboard where you added the trace visualization, click **Edit** in the top-right corner. +1. In the **Add** drop-down, select **Visualization**. 1. Select the same trace data source you used in the previous section. -1. In the top-right of the panel editor, select the **Visualizations** tab, search for, and select **Table**. +1. In the top-right corner of the panel editor, select the **Visualizations** tab, search for, and select **Table**. 1. In the query editor, select the **TraceQL** tab. 1. Under the **Panel options**, enter a **Title** for your trace panel or have Grafana create one using [generative AI features](ref:generative-ai-features). 1. Add an appropriate TraceQL query to search for traces that you would like to visualize in the dashboard. This example uses a simple, static query. You can write the TraceQL query as a template query to take advantage of other dashboard variables, if they exist. This lets you create dynamic queries based on these variables. {{< figure src="/static/img/docs/panels/traces/screenshot-traces-dynamic-query.png" caption="Create a dynamic query" >}} +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. + When results are returned from a query, the results are rendered in the panel’s table. {{< figure src="/static/img/docs/panels/traces/screenshot-traces-returned-query.png" caption="Results of a returned query in the panel table" >}} @@ -132,7 +142,7 @@ The results in the traces visualization include links to the **Explore** page th To create a set of data links in the panel, use the following steps: -1. In the right-side menu, under **Data links**, select **Add link**. +1. In the panel editor menu, under **Data links**, click **Add link**. 1. Add a **Title** for the data link. 1. Find the UUID of the dashboard by looking in your browser’s address bar when the full dashboard is being rendered. Because this is a link to a dashboard in the same Grafana stack, only the path of the dashboard is required. @@ -144,8 +154,8 @@ To create a set of data links in the panel, use the following steps: {{< figure src="/static/img/docs/panels/traces/screenshot-traces-edit-link.png" caption="Edit link and add the Trace link" >}} 1. Select **Save** to save the data link. -1. Select **Apply** from the panel editor to apply the panel to the dashboard. -1. Save the dashboard. +1. Click **Save dashboard**. +1. Click **Back to dashboard** and **Exit edit**. You should now see a list of matching traces in the table visualization. While selecting the **TraceID** or **SpanID** fields will give you the option to either open the **Explore** page to visualize the trace or following the data link, selecting any other field (such as **Start time**, **Name** or **Duration**) automatically follows the data link, filling in the `traceId` dashboard variable, and then shows the relevant trace in the trace panel. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 1527fd91830..bf95f825a07 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -892,6 +892,12 @@ The duration in time a verification email, used to update the email address of a This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 1h (1 hour). +### last_seen_update_interval + +The frequency of updating a user's last seen time. +This setting should be expressed as a duration. Examples: 1h (hour), 15m (minutes) +Default is `15m` (15 minutes). The minimum supported duration is `5m` (5 minutes). The maximum supported duration is `1h` (1 hour). + ### hidden_users This is a comma-separated list of usernames. Users specified here are hidden in the Grafana UI. They are still visible to Grafana administrators and to themselves. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 53578a6dfb7..5a8951814e4 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -27,7 +27,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `publicDashboards` | [Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version. | Yes | | `featureHighlights` | Highlight Grafana Enterprise features | | | `correlations` | Correlations page | Yes | -| `exploreContentOutline` | Content outline sidebar | Yes | | `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | | `nestedFolders` | Enable folder nesting | Yes | | `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | @@ -138,7 +137,6 @@ Experimental features might be changed or removed without prior notice. | `pluginsFrontendSandbox` | Enables the plugins frontend sandbox | | `frontendSandboxMonitorOnly` | Enables monitor only in the plugin frontend sandbox (if enabled) | | `vizAndWidgetSplit` | Split panels between visualizations and widgets | -| `prometheusIncrementalQueryInstrumentation` | Adds RudderStack events to incremental queries | | `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | | `mlExpressions` | Enable support for Machine Learning in server-side expressions | | `metricsSummary` | Enables metrics summary queries in the Tempo data source | @@ -151,6 +149,7 @@ Experimental features might be changed or removed without prior notice. | `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins | | `idForwarding` | Generate signed id token for identity that can be forwarded to plugins and external services | | `enableNativeHTTPHistogram` | Enables native HTTP Histograms | +| `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | @@ -181,7 +180,6 @@ Experimental features might be changed or removed without prior notice. | `accessActionSets` | Introduces action sets for resource permissions | | `disableNumericMetricsSortingInExpressions` | In server-side expressions, disable the sorting of numeric-kind metrics by their metric name or labels. | | `queryLibrary` | Enables Query Library feature in Explore | -| `autofixDSUID` | Automatically migrates invalid datasource UIDs | | `logsExploreTableDefaultVisualization` | Sets the logs table as default visualisation in logs explore | | `newDashboardSharingComponent` | Enables the new sharing drawer design | | `alertingListViewV2` | Enables the new alert list view design | @@ -190,7 +188,9 @@ Experimental features might be changed or removed without prior notice. | `alertingCentralAlertHistory` | Enables the new central alert history. | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | | `pinNavItems` | Enables pinning of nav items | +| `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs | | `databaseReadReplica` | Use a read replica for some database queries. | +| `alertingApiServer` | Register Alerting APIs with the K8s API server | ## Development feature toggles diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md index 20d1ee803f2..2aaef56a35c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md @@ -79,6 +79,8 @@ To follow this guide, ensure you have permissions in your Okta workspace to crea 1. Click **Save**. 1. Click the **Back to applications** link at the top of the page. 1. From the **More** button dropdown menu, click **Refresh Application Data**. +1. Include the `groups` scope in the **Scopes** field in Grafana of the Okta integration. + For Terraform or in the Grafana configuration file, include the `groups` scope in `scopes` field. #### Optional: Add the role attribute to the User (default) Okta profile @@ -161,12 +163,7 @@ To integrate your Okta OIDC provider with Grafana using our Okta OIDC integratio 1. Review the list of other Okta OIDC [configuration options]({{< relref "#configuration-options" >}}) and complete them as necessary. -1. Optional: [Configure a refresh token]({{< relref "#configure-a-refresh-token" >}}): - - a. Extend the `scopes` field of `[auth.okta]` section in Grafana configuration file with the refresh token scope used by your OIDC provider. - - b. Enable the [refresh token]({{< relref "#configure-a-refresh-token" >}}) at the Okta application settings. - +1. Optional: [Configure a refresh token]({{< relref "#configure-a-refresh-token" >}}). 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). 1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization-enterprise-only" >}}). 1. Restart Grafana. diff --git a/docs/sources/shared/datasources/tempo-editor-traceql.md b/docs/sources/shared/datasources/tempo-editor-traceql.md index 65e5a992dd4..0556ffead7f 100644 --- a/docs/sources/shared/datasources/tempo-editor-traceql.md +++ b/docs/sources/shared/datasources/tempo-editor-traceql.md @@ -33,6 +33,10 @@ This feature is automatically available in Grafana 10 (and newer) and Grafana Cl To use the TraceQL query editor in self-hosted Grafana 9.3.2 and older, you need to [enable the `traceqlEditor` feature toggle](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/). +If trying to query a self-managed Grafana Tempo or Grafana Enterprise Traces database with a gateway (e.g., nginx) in front of it from your hosted Grafana, that gateway (e.g., nginx) must allow gRPC connections. If it does not, streaming will not work and queries will fail to return results. + +If you cannot configure your gateway to allow gRPC, open a support escalation to request streaming query results be disabled in your hosted Grafana. + ## Write TraceQL queries using the query editor The Tempo data source’s TraceQL query editor helps you query and display traces from Tempo in **Explore**. diff --git a/docs/sources/tutorials/alerting-get-started-pt2/index.md b/docs/sources/tutorials/alerting-get-started-pt2/index.md new file mode 100644 index 00000000000..340c27144d0 --- /dev/null +++ b/docs/sources/tutorials/alerting-get-started-pt2/index.md @@ -0,0 +1,170 @@ +--- +Feedback Link: https://github.com/grafana/tutorials/issues/new +categories: + - alerting +description: This is part 2 of the Get started with Grafana Alerting tutorials. Learn how to leverage alert instances, and set up a notification policy that routes alert notifications based on labels to a specific contact point. +id: alerting-get-started-pt2 +labels: + products: + - enterprise + - oss + - cloud +tags: + - beginner +title: Get started with Grafana Alerting - Part 2 +weight: 50 +--- + +# Get started with Grafana Alerting - Part 2 + +This is part 2 of the [Get Started with Grafana Alerting tutorial](http://grafana.com/tutorials/alerting-get-started/). + +In this guide, we dig into more complex yet equally fundamental elements of Grafana Alerting: **alert instances** and **notification policies**. + +After introducing each component, you will learn how to: + +- Configure an alert rule that returns more than one alert instance +- Create notification policies that route firing alert instances to different contact points +- Use labels to match alert instances and notification policies + +Learning about alert instances and notification policies is useful if you have more than one contact point in your organization, or if your alert rule returns a number of metrics that you want to handle separately by routing each alert instance to a specific contact point. The tutorial will introduce each concept, followed by how to apply both concepts in a real-world scenario. + +## Alert instances + +An [alert instance](https://grafana.com/docs/grafana/latest/alerting/fundamentals/#alert-instances) is an event that matches a metric returned by an alert rule query. + +Let's consider a scenario where you're monitoring website traffic using Grafana. You've set up an alert rule to trigger an alert instance if the number of page views exceeds a certain threshold (more than `1000` page views) within a specific time period, say, over the past `5` minutes. + +If the query returns more than one time-series, each time-series represents a different metric or aspect being monitored. In this case, the alert rule is applied individually to each time-series. + +{{< figure alt="Screenshot displaying alert instances in the context of an alert rule, highlighting the specific alerts triggered by the rule and their respective statuses" src="/media/docs/alerting/get-started-digram-instance-grey.png" max-width="1200px" caption="Alert Instances in the Context of an Alert Rule" >}} + +In this scenario, each time-series is evaluated independently against the alert rule. It results in the creation of an alert instance for each time-series. The time-series corresponding to the desktop page views meets the threshold and, therefore, results in an alert instance in **Firing** state for which an alert notification is sent. The mobile alert instance state remains **Normal**. + +## Notification policies + +[Notification policies](https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/notification-policies/) route alerts to different communication channels, reducing alert noise and providing control over when and how alerts are sent. For example, you might use notification policies to ensure that critical alerts about server downtime are sent immediately to the on-call engineer. Another use case could be routing performance alerts to the development team for review and action. + +Key Characteristics: + +- Route alert notifications by matching alerts and policies with labels +- Manage when to send notifications + +{{< figure alt="Screenshot illustrating the routing of alerts with notification policies, including the configuration and flow of alerts through different notification channels" src="/media/docs/alerting/get-started-notification-policy-tree-combo.png" max-width="1200px" caption="Routing of alerts with notification policies" >}} + +In the above diagram, alert instances and notification policies are matched by labels. For instance, the label `team=operations` matches the alert instance “**Pod stuck in CrashLoop**” and “**Disk Usage -80%**” to child policies that send alert notifications to a particular contact point (operations@grafana.com). + +## Create notification policies + +Create a notification policy if you want to handle metrics returned by alert rules separately by routing each alert instance to a specific contact point. In Grafana, click on the icon at the top left corner of the screen to access the navigation menu. + +1. Navigate to **Alerts & IRM > Alerting > Notification policies**. +1. In the Default policy, click **+ New child policy**. +1. In the field **Label** enter `device`, and in the field **Value** enter `desktop`. +1. From the **Contact point** drop-down, choose **Webhook**. + {{< admonition type="note" >}} + If you don’t have any contact points, add a [Contact point](https://grafana.com/tutorials/alerting-get-started/#create-a-contact-point). + {{}} +1. Click **Save Policy**. + + This new child policy routes alerts that match the label `device=desktop` to the Webhook contact point. + +1. **Repeat the steps above to create a second child policy** to match another alert instance. For labels use: `device=mobile`. Use the Webhook integration for the contact point. Alternatively, experiment by using a different Webhook endpoint or a [different integration](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/#list-of-supported-integrations). + +## Create an alert rule that returns alert instances + +The alert rule that you are about to create is meant to monitor web traffic page views. The objective is to explore what an alert instance is and how to leverage routing individual alert instances by using label matchers and notification policies. + +### Add a data source + +Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/datasources/testdata/) that creates simulated time series data. + +1. In Grafana navigate to **Connections > Add new connection**. +1. Search for **TestData**. +1. Click **Add new data source**. +1. Click **Save & test**. + + You should see a message confirming that the data source is working. + +### Create an alert rule + +1. Navigate to **Alerting > Alert rules**. +1. Click **New alert rule**. + +### Enter an alert rule name + +Make it short and descriptive as this will appear in your alert notification. For instance, `web-traffic`. + +### Define query and alert condition + +In this section, we define queries, expressions (used to manipulate the data), and the condition that must be met for the alert to be triggered. + +1. Select **TestData** data source from the drop-down menu. +1. From **Scenario** select **CSV Content**. +1. Copy in the following CSV data: + +``` +device,views +desktop,1200 +mobile,900 +``` + +The above CSV data simulates a data source returning multiple time series, each leading to the creation of an alert instance for that specific time series. Note that the data returned matches the example in the [Alert instance](#alert-instances) section. + +1. Remove the ‘B’ **Reduce expression** (click the bin icon). The Reduce expression is default, and in this case, is not required since the queried data is already reduced. Note that the Threshold expression is now your **Alert condition**. +1. In the ‘C’ **Threshold expression**: + - Change the **Input** to ‘**A**’ to select the data source. + - Enter `1000` as the threshold value. This is the value above which the alert rule should trigger. +1. Click **Preview** to run the queries. + +It should return two series.`desktop` in Firing state, and `mobile` in Normal state. The values `1`, and `0` mean that the condition is either `true` or `false`. + +{{< figure alt="Screenshot showing a preview of a query in Grafana that returns two alert instances, including the query results and relevant alert details" src="/media/docs/alerting/get-started-expression-instances.png" max-width="1200px" caption="Preview of a query returning two alert instances in Grafana." >}} + +### Set evaluation behavior + +In the [life cycle](http://grafana.com/docs/grafana/next/alerting/fundamentals/alert-rule-evaluation/) of alert instances, when an alert condition (threshold) is not met, the alert instance state is **Normal**. Similarly, when the condition is breached (for longer than the pending period, which in this tutorial will be 0), the alert instance state switches back to **Alerting**, which means that the alert rule state is **Firing**, and a notification is sent. + +To set up evaluation behavior: + +1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts`. This folder will contain our alerts. +1. In the **Evaluation group**, repeat the above step to create a new evaluation group. We will name it `1m` (referring to “1 minute”). +1. Choose an Evaluation interval (how often the alert will be evaluated). Choose `1m`. +1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met. + +### Configure labels and notifications + +In this section, you can select how you want to route your alert instances. Since we want to route by notification policy, we need to ensure that the labels match the alert instance. + +1. Choose **Use notification policy**. +1. Click **Preview routing**. Based on the existing labels, you should see a preview of what policies are matching with the alerts. There should be two alert instances matching the labels that were previously setup in each notification policy: `device=desktop`, `device=mobile`. + + These [types of labels](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/annotation-label/#label-types) are generated by the data source query and they can be leveraged to match our notification policies without needing to manually add them to the alert rule. + + {{< figure alt="Screenshot showing a routing preview of matched notification policies, detailing how alerts are matched and routed to specific notification channels" src="/media/docs/alerting/get-started-alert-instace-routing-prev.png" max-width="1200px" caption="Routing preview of matched notification policies" >}} + + {{< admonition type="note" >}} + Even if both labels match the policies, only the alert instance in Firing state produces an alert notification. + {{}} + +1. Click **Save rule and exit**. + +Now that we have set up the alert rule, it’s time to check the alert notification. + +## Receive alert notifications + +Now that the alert rule has been configured, you should receive alert [notifications](http://grafana.com/docs/grafana/next/alerting/fundamentals/alert-rule-evaluation/state-and-health/#notifications) in the contact point whenever the alert triggers and gets resolved. In our example, each alert instance should be routed separately as we configured labels to match notification policies. Once the evaluation interval has concluded (1m), you should receive an alert notification in the Webhook endpoint. + +{{< figure alt="Screenshot showing the exploration of alert notification details in a webhook endpoint, displaying the content and structure of the alert payload received by the endpoint" src="/media/docs/alerting/get-started-webhook-alert-isntance.png" max-width="1200px" caption="Exploring alert notification details in webhook endpoint" >}} + +The alert notification details show that the alert instance corresponding to the website views from desktop devices was correctly routed through the notification policy to the Webhook contact point. The notification also shows that the instance is in **Firing** state, as well as it includes the label `device=desktop`, which makes the routing of the alert instance possible. + +Feel free to change the CSV data in the alert rule to trigger the routing of the alert instance that matches the label `device=mobile`. + +## Summary + +In this tutorial, you have learned how Grafana Alerting can route individual alert instances using the labels generated by the data-source query and match these labels with notification policies, which in turn routes alert notifications to specific contact points. + +If you run into any problems, you are welcome to post questions in our [Grafana Community forum](https://community.grafana.com/). + +Enjoy your monitoring! diff --git a/docs/sources/tutorials/alerting-get-started/index.md b/docs/sources/tutorials/alerting-get-started/index.md index 18ac15cee5b..84077ad7dee 100644 --- a/docs/sources/tutorials/alerting-get-started/index.md +++ b/docs/sources/tutorials/alerting-get-started/index.md @@ -1,11 +1,9 @@ --- Feedback Link: https://github.com/grafana/tutorials/issues/new -authors: - - antonio-calero-merello categories: - alerting description: Get started with Grafana Alerting by creating your first alert in just a few minutes. Learn how to set up an alert, send alert notifications to a public webhook, and generate sample data to observe your alert in action. -id: alerting-get-started +id: alerting-get-started-pt1 labels: products: - enterprise @@ -13,11 +11,11 @@ labels: - cloud tags: - beginner -title: Get started with Grafana Alerting +title: Get started with Grafana Alerting - Part 1 weight: 50 --- -# Get Started with Grafana Alerting +# Get Started with Grafana Alerting - Part 1 In this guide, we'll walk you through the process of setting up your first alert in just a few minutes. You'll witness your alert in action with real-time data, as well as sending alert notifications. @@ -27,6 +25,10 @@ In this tutorial you will: - Set up an alert rule. - Receive firing and resolved alert notifications in a public webhook. +{{< admonition type="tip" >}} +Check out [Part 2](http://grafana.com/tutorials/alerting-get-started-pt2/) if you want to learn more about alerts and notification routing. +{{< /admonition >}} + ## Before you begin ### Grafana Cloud users @@ -186,6 +188,10 @@ To edit the Alert rule: By incrementing the threshold, the condition is no longer met, and after the evaluation interval has concluded (1 minute approx.), you should receive an alert notification with status **“Resolved”**. +## Learn more + +Your learning journey continues in [Part 2](http://grafana.com/tutorials/alerting-get-started-pt2/) where you will learn about alert instances and notification routing. + ## Summary In this tutorial, you have learned how to set up a contact point, create an alert, and send alert notifications to a public Webhook. By following these steps, you’ve gained a foundational understanding of how to leverage Grafana Alerting capabilities to monitor and respond to events of interest in your data. diff --git a/docs/sources/whatsnew/whats-new-in-v10-2.md b/docs/sources/whatsnew/whats-new-in-v10-2.md index 02d1ddbbdf7..1db3a3d2505 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-2.md +++ b/docs/sources/whatsnew/whats-new-in-v10-2.md @@ -379,7 +379,7 @@ _Generally available in all editions of Grafana_ Use the Grafana Alerting - Grafana OnCall integration to effortlessly connect alerts generated by Grafana Alerting with Grafana OnCall. From there, you can route them according to defined escalation chains and schedules. -To learn more, refer to the [Grafana OnCall integration for Alerting documentation](https://grafana.com/docs/grafana//alerting/alerting-rules/manage-contact-points/integrations/configure-oncall/), as well as the following video demo. +To learn more, refer to the [Grafana OnCall integration for Alerting documentation](https://grafana.com/docs/grafana//alerting/configure-notifications/manage-contact-points/integrations/configure-oncall/), as well as the following video demo. {{< youtube id="abRn5I61hxs?rel=0" >}} diff --git a/e2e/dashboards-suite/dashboard-public-create.spec.ts b/e2e/dashboards-suite/dashboard-public-create.spec.ts index 7f95df2c611..6fa08c988e5 100644 --- a/e2e/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-create.spec.ts @@ -17,7 +17,7 @@ describe('Public dashboards', () => { e2e.pages.Dashboard.DashNav.shareButton().click(); // Select public dashboards tab - e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); // Create button should be disabled e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().should('be.disabled'); @@ -78,7 +78,7 @@ describe('Public dashboards', () => { // Select public dashboards tab cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); - e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); cy.wait('@query-public-dashboard'); e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist'); @@ -118,7 +118,7 @@ describe('Public dashboards', () => { // Select public dashboards tab cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); - e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); cy.wait('@query-public-dashboard'); // save url before disabling public dashboard diff --git a/e2e/dashboards-suite/dashboard-public-templating.spec.ts b/e2e/dashboards-suite/dashboard-public-templating.spec.ts index ba819895fc2..fa3a4a91242 100644 --- a/e2e/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-templating.spec.ts @@ -13,7 +13,7 @@ describe('Create a public dashboard with template variables shows a template var e2e.pages.Dashboard.DashNav.shareButton().click(); // Select public dashboards tab - e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); // Warning Alert dashboard cannot be made public because it has template variables e2e.pages.ShareDashboardModal.PublicDashboard.TemplateVariablesWarningAlert().should('be.visible'); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/alerting.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/alerting.spec.ts new file mode 100644 index 00000000000..531cb85e091 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/alerting.spec.ts @@ -0,0 +1,39 @@ +import * as e2e from '@grafana/e2e-selectors'; +import { expect, test } from '@grafana/plugin-e2e'; + +test('should evaluate to false if entire request returns 500', async ({ page, alertRuleEditPage, selectors }) => { + await alertRuleEditPage.alertRuleNameField.fill('Test Alert Rule'); + + // remove the default query + const queryA = alertRuleEditPage.getAlertRuleQueryRow('A'); + await alertRuleEditPage + .getByGrafanaSelector(selectors.components.QueryEditorRow.actionButton('Remove query'), { + root: queryA.locator, + }) + .click(); + await expect(alertRuleEditPage.evaluate()).not.toBeOK(); +}); + +test('should evaluate to false if entire request returns 200 but partial query result is invalid', async ({ + page, + alertRuleEditPage, +}) => { + await alertRuleEditPage.alertRuleNameField.fill('Test Alert Rule'); + + //add working query + const queryA = alertRuleEditPage.getAlertRuleQueryRow('A'); + await queryA.datasource.set('gdev-prometheus'); + await queryA.locator.getByLabel('Code').click(); + await page.waitForFunction(() => window.monaco); + await queryA.getByGrafanaSelector(e2e.selectors.components.QueryField.container).click(); + await page.keyboard.insertText('topk(5, max(scrape_duration_seconds) by (job))'); + + //add broken query + const newQuery = await alertRuleEditPage.clickAddQueryRow(); + await newQuery.datasource.set('gdev-prometheus'); + await newQuery.locator.getByLabel('Code').click(); + await newQuery.getByGrafanaSelector(e2e.selectors.components.QueryField.container).click(); + await page.keyboard.insertText('topk(5,'); + + await expect(alertRuleEditPage.evaluate()).not.toBeOK(); +}); diff --git a/e2e/run-suite b/e2e/run-suite index 30cd8e95b55..38770c2becb 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -106,6 +106,11 @@ case "$1" in ;; esac ;; + "scenes/"*) + cypressConfig[specPattern]=./e2e/"${args[0]}"/$testFilesForSingleSuite + cypressConfig[video]=${args[1]} + env[SCENES]=true + ;; "enterprise-smtp") env[SMTP_PLUGIN_ENABLED]=true cypressConfig[specPattern]=./e2e/extensions/enterprise/smtp-suite/$testFilesForSingleSuite diff --git a/e2e/scenes/dashboard-suite/Repeating_a_panel_horizontally.spec.ts b/e2e/scenes/dashboards-suite/Repeating_a_panel_horizontally.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/Repeating_a_panel_horizontally.spec.ts rename to e2e/scenes/dashboards-suite/Repeating_a_panel_horizontally.spec.ts diff --git a/e2e/scenes/dashboard-suite/Repeating_a_panel_vertically.spec.ts b/e2e/scenes/dashboards-suite/Repeating_a_panel_vertically.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/Repeating_a_panel_vertically.spec.ts rename to e2e/scenes/dashboards-suite/Repeating_a_panel_vertically.spec.ts diff --git a/e2e/scenes/dashboard-suite/Repeating_an_empty_row.spec.ts b/e2e/scenes/dashboards-suite/Repeating_an_empty_row.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/Repeating_an_empty_row.spec.ts rename to e2e/scenes/dashboards-suite/Repeating_an_empty_row.spec.ts diff --git a/e2e/scenes/dashboard-suite/dashboard-browse-nested.spec.ts b/e2e/scenes/dashboards-suite/dashboard-browse-nested.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/dashboard-browse-nested.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-browse-nested.spec.ts diff --git a/e2e/scenes/dashboard-suite/dashboard-browse.spec.ts b/e2e/scenes/dashboards-suite/dashboard-browse.spec.ts similarity index 93% rename from e2e/scenes/dashboard-suite/dashboard-browse.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-browse.spec.ts index 4e9728b7a65..a5dc3cfc5ca 100644 --- a/e2e/scenes/dashboard-suite/dashboard-browse.spec.ts +++ b/e2e/scenes/dashboards-suite/dashboard-browse.spec.ts @@ -1,7 +1,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; import { e2e } from '../utils'; - -describe('Dashboard browse', () => { +// Skipping due to race conditions with same old arch test e2e/dashboards-suite/dashboard-browse.spec.ts +describe.skip('Dashboard browse', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); diff --git a/e2e/scenes/dashboard-suite/dashboard-live-streaming.spec.ts b/e2e/scenes/dashboards-suite/dashboard-live-streaming.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/dashboard-live-streaming.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-live-streaming.spec.ts diff --git a/e2e/scenes/dashboard-suite/dashboard-public-create.spec.ts b/e2e/scenes/dashboards-suite/dashboard-public-create.spec.ts similarity index 95% rename from e2e/scenes/dashboard-suite/dashboard-public-create.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-public-create.spec.ts index 0f90db253f9..993bb82c702 100644 --- a/e2e/scenes/dashboard-suite/dashboard-public-create.spec.ts +++ b/e2e/scenes/dashboards-suite/dashboard-public-create.spec.ts @@ -1,6 +1,6 @@ import { e2e } from '../utils'; - -describe('Public dashboards', () => { +// Skipping due to race conditions with same old arch test e2e/dashboards-suite/dashboard-public-create.spec.ts +describe.skip('Public dashboards', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); @@ -17,7 +17,7 @@ describe('Public dashboards', () => { e2e.components.NavToolbar.shareDashboard().click(); // Select public dashboards tab - e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); // Create button should be disabled e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().should('be.disabled'); @@ -78,7 +78,7 @@ describe('Public dashboards', () => { // Select public dashboards tab cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); - e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); cy.wait('@query-public-dashboard'); e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist'); @@ -118,7 +118,7 @@ describe('Public dashboards', () => { // Select public dashboards tab cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); - e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + e2e.components.Tab.title('Public dashboard').click(); cy.wait('@query-public-dashboard'); // save url before disabling public dashboard diff --git a/e2e/scenes/dashboard-suite/dashboard-public-templating.spec.ts b/e2e/scenes/dashboards-suite/dashboard-public-templating.spec.ts similarity index 94% rename from e2e/scenes/dashboard-suite/dashboard-public-templating.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-public-templating.spec.ts index 4a1949a7fe7..4a4e660d44e 100644 --- a/e2e/scenes/dashboard-suite/dashboard-public-templating.spec.ts +++ b/e2e/scenes/dashboards-suite/dashboard-public-templating.spec.ts @@ -13,7 +13,7 @@ describe('Create a public dashboard with template variables shows a template var e2e.components.NavToolbar.shareDashboard().click(); // Select public dashboards tab - e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + e2e.components.Tab.title('Public Dashboard').click(); // Warning Alert dashboard cannot be made public because it has template variables e2e.pages.ShareDashboardModal.PublicDashboard.TemplateVariablesWarningAlert().should('be.visible'); diff --git a/e2e/scenes/dashboard-suite/dashboard-templating.spec.ts b/e2e/scenes/dashboards-suite/dashboard-templating.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/dashboard-templating.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-templating.spec.ts diff --git a/e2e/scenes/dashboard-suite/dashboard-time-zone.spec.ts b/e2e/scenes/dashboards-suite/dashboard-time-zone.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/dashboard-time-zone.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-time-zone.spec.ts diff --git a/e2e/scenes/dashboard-suite/dashboard-timepicker.spec.ts b/e2e/scenes/dashboards-suite/dashboard-timepicker.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/dashboard-timepicker.spec.ts rename to e2e/scenes/dashboards-suite/dashboard-timepicker.spec.ts diff --git a/e2e/scenes/dashboard-suite/embedded-dashboard.spec.ts b/e2e/scenes/dashboards-suite/embedded-dashboard.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/embedded-dashboard.spec.ts rename to e2e/scenes/dashboards-suite/embedded-dashboard.spec.ts diff --git a/e2e/scenes/dashboard-suite/general-dashboards.spec.ts b/e2e/scenes/dashboards-suite/general-dashboards.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/general-dashboards.spec.ts rename to e2e/scenes/dashboards-suite/general-dashboards.spec.ts diff --git a/e2e/scenes/dashboard-suite/import-dashboard.spec.ts b/e2e/scenes/dashboards-suite/import-dashboard.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/import-dashboard.spec.ts rename to e2e/scenes/dashboards-suite/import-dashboard.spec.ts diff --git a/e2e/scenes/dashboard-suite/load-options-from-url.spec.ts b/e2e/scenes/dashboards-suite/load-options-from-url.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/load-options-from-url.spec.ts rename to e2e/scenes/dashboards-suite/load-options-from-url.spec.ts diff --git a/e2e/scenes/dashboard-suite/new-constant-variable.spec.ts b/e2e/scenes/dashboards-suite/new-constant-variable.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/new-constant-variable.spec.ts rename to e2e/scenes/dashboards-suite/new-constant-variable.spec.ts diff --git a/e2e/scenes/dashboard-suite/new-custom-variable.spec.ts b/e2e/scenes/dashboards-suite/new-custom-variable.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/new-custom-variable.spec.ts rename to e2e/scenes/dashboards-suite/new-custom-variable.spec.ts diff --git a/e2e/scenes/dashboard-suite/new-datasource-variable.spec.ts b/e2e/scenes/dashboards-suite/new-datasource-variable.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/new-datasource-variable.spec.ts rename to e2e/scenes/dashboards-suite/new-datasource-variable.spec.ts diff --git a/e2e/scenes/dashboard-suite/new-interval-variable.spec.ts b/e2e/scenes/dashboards-suite/new-interval-variable.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/new-interval-variable.spec.ts rename to e2e/scenes/dashboards-suite/new-interval-variable.spec.ts diff --git a/e2e/scenes/dashboard-suite/new-query-variable.spec.ts b/e2e/scenes/dashboards-suite/new-query-variable.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/new-query-variable.spec.ts rename to e2e/scenes/dashboards-suite/new-query-variable.spec.ts diff --git a/e2e/scenes/dashboard-suite/new-text-box-variable.spec.ts b/e2e/scenes/dashboards-suite/new-text-box-variable.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/new-text-box-variable.spec.ts rename to e2e/scenes/dashboards-suite/new-text-box-variable.spec.ts diff --git a/e2e/scenes/dashboard-suite/set-options-from-ui.spec.ts b/e2e/scenes/dashboards-suite/set-options-from-ui.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/set-options-from-ui.spec.ts rename to e2e/scenes/dashboards-suite/set-options-from-ui.spec.ts diff --git a/e2e/scenes/dashboard-suite/snapshot-create.spec.ts b/e2e/scenes/dashboards-suite/snapshot-create.spec.ts similarity index 96% rename from e2e/scenes/dashboard-suite/snapshot-create.spec.ts rename to e2e/scenes/dashboards-suite/snapshot-create.spec.ts index c478b98fa26..aff04b6b805 100644 --- a/e2e/scenes/dashboard-suite/snapshot-create.spec.ts +++ b/e2e/scenes/dashboards-suite/snapshot-create.spec.ts @@ -25,7 +25,7 @@ describe('Snapshots', () => { e2e.components.NavToolbar.shareDashboard().click(); // Select the snapshot tab - e2e.pages.ShareDashboardModal.SnapshotScene.Tab().click(); + e2e.components.Tab.title('Snapshot').click(); // Publish snapshot cy.intercept('POST', '/api/snapshots').as('save'); diff --git a/e2e/scenes/dashboard-suite/templating-dashboard-links-and-variables.spec.ts b/e2e/scenes/dashboards-suite/templating-dashboard-links-and-variables.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/templating-dashboard-links-and-variables.spec.ts rename to e2e/scenes/dashboards-suite/templating-dashboard-links-and-variables.spec.ts diff --git a/e2e/scenes/dashboard-suite/textbox-variables.spec.ts b/e2e/scenes/dashboards-suite/textbox-variables.spec.ts similarity index 100% rename from e2e/scenes/dashboard-suite/textbox-variables.spec.ts rename to e2e/scenes/dashboards-suite/textbox-variables.spec.ts diff --git a/e2e/scenes/dashboard-suite/utils/makeDashboard.ts b/e2e/scenes/dashboards-suite/utils/makeDashboard.ts similarity index 100% rename from e2e/scenes/dashboard-suite/utils/makeDashboard.ts rename to e2e/scenes/dashboards-suite/utils/makeDashboard.ts diff --git a/e2e/scenes/panels-suite/frontend-sandbox-panel.spec.ts b/e2e/scenes/panels-suite/frontend-sandbox-panel.spec.ts index 1541be609d9..582d6ba6497 100644 --- a/e2e/scenes/panels-suite/frontend-sandbox-panel.spec.ts +++ b/e2e/scenes/panels-suite/frontend-sandbox-panel.spec.ts @@ -2,8 +2,8 @@ import panelSandboxDashboard from '../../dashboards/PanelSandboxDashboard.json'; import { e2e } from '../../utils'; const DASHBOARD_ID = 'c46b2460-16b7-42a5-82d1-b07fbf431950'; - -describe('Panel sandbox', () => { +// Skipping due to race conditions with same old arch test e2e/panels-suite/frontend-sandbox-panel.spec.ts +describe.skip('Panel sandbox', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); return e2e.flows.importDashboard(panelSandboxDashboard, 1000, true); diff --git a/e2e/scenes/various-suite/exemplars.spec.ts b/e2e/scenes/various-suite/exemplars.spec.ts index da642ba3f2d..675b83039ba 100644 --- a/e2e/scenes/various-suite/exemplars.spec.ts +++ b/e2e/scenes/various-suite/exemplars.spec.ts @@ -17,8 +17,8 @@ const addDataSource = () => { }, }); }; - -describe('Exemplars', () => { +// Skipping due to race conditions with same old arch test e2e/various-suite/exemplars.spec.ts +describe.skip('Exemplars', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); diff --git a/e2e/scenes/various-suite/filter-annotations.spec.ts b/e2e/scenes/various-suite/filter-annotations.spec.ts index 883b705e8d1..35319ee1beb 100644 --- a/e2e/scenes/various-suite/filter-annotations.spec.ts +++ b/e2e/scenes/various-suite/filter-annotations.spec.ts @@ -1,7 +1,8 @@ import { e2e } from '../utils'; const DASHBOARD_ID = 'ed155665'; -describe('Annotations filtering', () => { +// Skipping due to race conditions with same old arch test e2e/various-suite/filter-annotations.spec.ts +describe.skip('Annotations filtering', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); diff --git a/e2e/scenes/various-suite/keybinds.spec.ts b/e2e/scenes/various-suite/keybinds.spec.ts index d054edad0b7..02a45a6f42e 100644 --- a/e2e/scenes/various-suite/keybinds.spec.ts +++ b/e2e/scenes/various-suite/keybinds.spec.ts @@ -1,7 +1,8 @@ import { e2e } from '../utils'; import { fromBaseUrl } from '../utils/support/url'; -describe('Keyboard shortcuts', () => { +// Skipping due to race conditions with same old arch test e2e/various-suite/keybinds.spec.ts +describe.skip('Keyboard shortcuts', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); diff --git a/e2e/scenes/various-suite/loki-table-explore-to-dash.spec.ts b/e2e/scenes/various-suite/loki-table-explore-to-dash.spec.ts index c56aa16b578..088745c7fca 100644 --- a/e2e/scenes/various-suite/loki-table-explore-to-dash.spec.ts +++ b/e2e/scenes/various-suite/loki-table-explore-to-dash.spec.ts @@ -110,7 +110,8 @@ const lokiQueryResult = { }, }; -describe('Loki Query Editor', () => { +// Skipping due to race conditions with same old arch test e2e/various-suite/loki-table-explore-to-dash.spec.ts +describe.skip('Loki Query Editor', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); diff --git a/e2e/scenes/various-suite/prometheus-variable-editor.spec.ts b/e2e/scenes/various-suite/prometheus-variable-editor.spec.ts index d5e90f2c31c..4b6c62427aa 100644 --- a/e2e/scenes/various-suite/prometheus-variable-editor.spec.ts +++ b/e2e/scenes/various-suite/prometheus-variable-editor.spec.ts @@ -77,8 +77,8 @@ function variableFlowToQueryEditor(variableName: string, queryType: string) { // do nothing } } - -describe('Prometheus variable query editor', () => { +// Skipping due to race conditions with same old arch test e2e/various-suite/prometheus-variable-editor.spec.ts +describe.skip('Prometheus variable query editor', () => { beforeEach(() => { createPromDS(DATASOURCE_ID, DATASOURCE_NAME); }); diff --git a/go.mod b/go.mod index a186b0dc4dd..b121c40ae80 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // @grafana/plugins-platform-backend github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/grafana-backend-group github.com/alicebob/miniredis/v2 v2.30.1 // @grafana/alerting-backend - github.com/andybalholm/brotli v1.0.5 // @grafana/partner-datasources + github.com/andybalholm/brotli v1.0.6 // @grafana/partner-datasources github.com/apache/arrow/go/v15 v15.0.2 // @grafana/observability-metrics github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.51.31 // @grafana/aws-datasources @@ -122,9 +122,9 @@ require ( github.com/magefile/mage v1.15.0 // @grafana/grafana-release-guild github.com/matryer/is v1.4.0 // @grafana/grafana-as-code github.com/mattn/go-isatty v0.0.20 // @grafana/grafana-backend-group - github.com/mattn/go-sqlite3 v1.14.19 // @grafana/grafana-backend-group + github.com/mattn/go-sqlite3 v1.14.22 // @grafana/grafana-backend-group github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-backend - github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 // @grafana/grafana-bi-squad + github.com/microsoft/go-mssqldb v1.7.0 // @grafana/grafana-bi-squad github.com/mitchellh/mapstructure v1.5.0 //@grafana/identity-access-team github.com/modern-go/reflect2 v1.0.2 // @grafana/alerting-backend github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // @grafana/alerting-backend @@ -149,7 +149,7 @@ require ( github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf // @grafana/grafana-backend-group github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f // @grafana/grafana-backend-group github.com/urfave/cli v1.22.15 // indirect; @grafana/grafana-backend-group - github.com/urfave/cli/v2 v2.25.1 // @grafana/grafana-backend-group + github.com/urfave/cli/v2 v2.27.1 // @grafana/grafana-backend-group github.com/wk8/go-ordered-map v1.0.0 // @grafana/grafana-backend-group github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group @@ -299,6 +299,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20240620152449-c88de7f4d073 // @grafana/grafana-search-and-storage github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect github.com/grafana/sqlds/v3 v3.2.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect; @grafana/plugins-platform-backend @@ -329,7 +330,6 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/asmfmt v1.3.2 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect @@ -382,8 +382,8 @@ require ( github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/encoding v0.3.6 // indirect - github.com/sergi/go-diff v1.3.1 // indirect - github.com/shopspring/decimal v1.2.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shopspring/decimal v1.3.1 // indirect github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -411,7 +411,7 @@ require ( go.opentelemetry.io/otel/metric v1.26.0 // indirect go.opentelemetry.io/proto/otlp v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.0 // @grafana/identity-access-team golang.org/x/sys v0.21.0 // indirect golang.org/x/term v0.21.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect @@ -424,15 +424,11 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/kms v0.29.2 // indirect - lukechampine.com/uint128 v1.3.0 // indirect - modernc.org/cc/v3 v3.40.0 // indirect - modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.22.4 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/opt v0.1.3 // indirect - modernc.org/sqlite v1.21.2 // indirect - modernc.org/strutil v1.1.3 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.29.6 // indirect + modernc.org/strutil v1.2.0 // indirect modernc.org/token v1.1.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.28.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect @@ -440,21 +436,33 @@ require ( ) require ( + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/karlseguin/ccache/v3 v3.0.5 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/natefinch/wrap v0.2.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pressly/goose/v3 v3.20.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sethvargo/go-retry v0.2.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/viper v1.18.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/mock v0.4.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect ) // Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream diff --git a/go.sum b/go.sum index 6d5235fff2a..2c66d93841d 100644 --- a/go.sum +++ b/go.sum @@ -1394,10 +1394,10 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3.0/go.mod h1:Y/HgrePTmGy9HjdSGTqZNa+apUpTVIEVKXJyARP2lrk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1/go.mod h1:c/wcGeGx5FUPbM/JltUYHZcKmigwyVLJlDq+4HdtXaw= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0 h1:yfJe15aSwEQ6Oo6J+gdfdulPNoZ3TEhmbhLIoxZcA+U= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0/go.mod h1:Q28U+75mpCaSCDowNEmhIo/rmgdkqmkmzI7N6TGR4UY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0 h1:T028gtTPiYt/RMUfs8nVsAL7FDQrfLlrm/NnRG/zcC4= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0/go.mod h1:cw4zVQgBby0Z5f2v0itn6se2dDP17nTjbZFXW5uPyHA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU= github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck= github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= @@ -1526,8 +1526,9 @@ github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGn github.com/alicebob/miniredis/v2 v2.30.1 h1:HM1rlQjq1bm9yQcsawJqSZBJ9AYgxvjkMsNtddh90+g= github.com/alicebob/miniredis/v2 v2.30.1/go.mod h1:b25qWj4fCEsBeAAR2mlb0ufImGC6uH3VlUfb/HS5zKg= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 h1:goHVqTbFX3AIo0tzGr14pgfAW2ZfPChKO21Z9MGf/gk= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= @@ -2333,6 +2334,8 @@ github.com/grafana/grafana/pkg/apiserver v0.0.0-20240226124929-648abdbd0ea4 h1:t github.com/grafana/grafana/pkg/apiserver v0.0.0-20240226124929-648abdbd0ea4/go.mod h1:vpYI6DHvFO595rpQGooUjcyicjt9rOevldDdW79peV0= github.com/grafana/grafana/pkg/promlib v0.0.6 h1:FuRyHMIgVVXkLuJnCflNfk3gqJflmyiI+/ZuJ9MoAfY= github.com/grafana/grafana/pkg/promlib v0.0.6/go.mod h1:shFkrG1fQ/PPNRGhxAPNMLp0SAeG/jhqaLoG6n2191M= +github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20240620152449-c88de7f4d073 h1:Au8+1QORZnMVo52+19dgkP4zzIlXyJ7x/d9ysSGQHOk= +github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20240620152449-c88de7f4d073/go.mod h1:zOInHv2y6bsgm9bIMsCVDaz1XylqIVX9r4amH4iuWPE= github.com/grafana/grafana/pkg/util/xorm v0.0.1 h1:72QZjxWIWpSeOF8ob4aMV058kfgZyeetkAB8dmeti2o= github.com/grafana/grafana/pkg/util/xorm v0.0.1/go.mod h1:eNfbB9f2jM8o9RfwqwjY8SYm5tvowJ8Ly+iE4P9rXII= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= @@ -2540,7 +2543,6 @@ github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCM github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgx v3.2.0+incompatible h1:0Vihzu20St42/UDsvZGdNE6jak7oi/UOeMzwMPHkgFY= github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= @@ -2552,7 +2554,6 @@ github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiw github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.2.1 h1:gI8os0wpRXFd4FiAY2dWiqRK037tjj3t7rKFeO4X5iw= github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= @@ -2623,7 +2624,6 @@ github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/karlseguin/ccache/v3 v3.0.5 h1:hFX25+fxzNjsRlREYsoGNa2LoVEw5mPF8wkWq/UnevQ= github.com/karlseguin/ccache/v3 v3.0.5/go.mod h1:qxC372+Qn+IBj8Pe3KvGjHPj0sWwEF7AeZVhsNPZ6uY= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -2750,8 +2750,8 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= -github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -2759,8 +2759,8 @@ github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQth github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 h1:KT4vTYcHqj5C5hMK5kSpyAk7MnFqfHVWLL4VqMq66S8= -github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= +github.com/microsoft/go-mssqldb v1.7.0 h1:sgMPW0HA6Ihd37Yx0MzHyKD726C2kY/8KJsQtXHNaAs= +github.com/microsoft/go-mssqldb v1.7.0/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -2868,6 +2868,8 @@ github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -3144,8 +3146,8 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.3.6 h1:E6lVLyDPseWEulBmCmAKPanDd3jiyGDo5gMcugCRwZQ= github.com/segmentio/encoding v0.3.6/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= @@ -3154,8 +3156,9 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v1.7.1/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= @@ -3289,8 +3292,8 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM= github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0= -github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw= -github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/vburenin/ifacemaker v1.2.1/go.mod h1:5WqrzX2aD7/hi+okBjcaEQJMg4lDGrpuEX3B8L4Wgrs= @@ -4675,14 +4678,12 @@ k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSn k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= -modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= @@ -4693,11 +4694,12 @@ modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWs modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= -modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= -modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= @@ -4713,39 +4715,41 @@ modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= modernc.org/libc v1.21.2/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= -modernc.org/libc v1.22.4 h1:wymSbZb0AlrjdAVX3cjreCHTPCpPARbQXNz6BHPzdwQ= modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0= -modernc.org/sqlite v1.21.2 h1:ixuUG0QS413Vfzyx6FWx6PYTmHaOegTY+hjzhn7L+a0= modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0= -modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= -modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/go.work b/go.work index 2f858b9675e..67f2d6728c2 100644 --- a/go.work +++ b/go.work @@ -7,6 +7,7 @@ use ( ./pkg/build ./pkg/build/wire ./pkg/promlib + ./pkg/storage/unified/resource ./pkg/util/xorm ) diff --git a/go.work.sum b/go.work.sum index 917fda449c8..84c674a4f2d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -412,6 +412,8 @@ github.com/Azure/go-autorest/autorest/azure/cli v0.4.5/go.mod h1:ADQAXrkgm7acgWV github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= +github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v3 v3.0.0 h1:1PwO5w5VCtlUUl+KTOBsTGZlhjWkcybsGaAau52tOy8= @@ -423,6 +425,7 @@ github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/KimMachineGun/automemlimit v0.6.0 h1:p/BXkH+K40Hax+PuWWPQ478hPjsp9h1CPDhLlA3Z37E= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= @@ -449,6 +452,7 @@ github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:m github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/OneOfOne/xxhash v1.2.6 h1:U68crOE3y3MPttCMQGywZOLrTeF5HHJ3/vDBCJn9/bA= github.com/OneOfOne/xxhash v1.2.6/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -467,6 +471,7 @@ github.com/Shopify/sarama v1.38.1/go.mod h1:iwv9a67Ha8VNa+TifujYoWGxWnu2kNVAQdSd github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9 h1:7kQgkwGRoLzC9K0oyXdJo7nve/bynv/KwUsxbiTlzAM= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19 h1:iXUgAaqDcIUGbRoy2TdeofRG/j1zpGRSEmNK05T+bi8= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= @@ -475,12 +480,16 @@ github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjH github.com/alecthomas/kong v0.2.11 h1:RKeJXXWfg9N47RYfMm0+igkxBCTF4bzbneAxaqid0c4= github.com/alecthomas/kong v0.2.11/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= github.com/alecthomas/participle/v2 v2.1.0 h1:z7dElHRrOEEq45F2TG5cbQihMtNTv8vwldytDj7Wrz4= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +github.com/alexflint/go-arg v1.4.2/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM= github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= +github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw= github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI= github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI= @@ -516,6 +525,7 @@ github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bufbuild/protovalidate-go v0.2.1 h1:pJr07sYhliyfj/STAM7hU4J3FKpVeLVKvOBmOTN8j+s= github.com/bufbuild/protovalidate-go v0.2.1/go.mod h1:e7XXDtlxj5vlEyAgsrxpzayp4cEMKCSSb8ZCkin+MVA= @@ -696,6 +706,7 @@ github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= @@ -770,6 +781,8 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230111030713-bf00bc1b83b6 h1:8yY/I9 github.com/eapache/go-xerial-snappy v0.0.0-20230111030713-bf00bc1b83b6/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= +github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= @@ -812,6 +825,8 @@ github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITL github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= github.com/go-fonts/dejavu v0.1.0 h1:JSajPXURYqpr+Cu8U9bt8K+XcACIHWqWrvWCKyeFmVQ= github.com/go-fonts/latin-modern v0.2.0 h1:5/Tv1Ek/QCr20C6ZOz15vw3g7GELYL98KWr8Hgo+3vk= github.com/go-fonts/liberation v0.2.0 h1:jAkAWJP4S+OsrPLZM4/eC9iW7CtHy+HBXrEwZXWo5VM= @@ -855,8 +870,6 @@ github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJ github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k= github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd h1:hSkbZ9XSyjyBirMeqSqUrK+9HboWrweVlzRNqoBi2d4= github.com/gobuffalo/depgen v0.1.0 h1:31atYa/UW9V5q8vMJ+W6wd64OaaTHUrCUXER358zLM4= github.com/gobuffalo/envy v1.7.0 h1:GlXgaiBkmrYMHco6t4j7SacKO4XUjvh5pwXh0f4uxXU= @@ -874,6 +887,7 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk= github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4 h1:vF83LI8tAakwEwvWZtrIEx7pOySacl2TOxx6eXk4ePo= github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= @@ -915,6 +929,7 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 h1:tlyzajkF3030q6M8SvmJSemC9DTHL/xaMa18b65+JM4= +github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 h1:zC34cGQu69FG7qzJ3WiKW244WfhDC3xxYMeNOX2gtUQ= github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= @@ -941,6 +956,7 @@ github.com/grafana/grafana-plugin-sdk-go v0.228.0/go.mod h1:u4K9vVN6eU86loO68977 github.com/grafana/grafana-plugin-sdk-go v0.229.0/go.mod h1:6V6ikT4ryva8MrAp7Bdz5fTJx3/ztzKvpMJFfpzr4CI= github.com/grafana/grafana-plugin-sdk-go v0.231.1-0.20240523124942-62dae9836284/go.mod h1:bNgmNmub1I7Mc8dzIncgNqHC5jTgSZPPHlZ3aG8HKJQ= github.com/grafana/grafana-plugin-sdk-go v0.234.0/go.mod h1:FlXjmBESxaD6Hoi8ojWLkH007nyjtJM3XC8SpwzF/YE= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240613114114-5e2f08de316d/go.mod h1:adT8O7k6ZSzUKjAC4WS6VfWlCE4G1VavPwSXVhvScCs= github.com/grafana/grafana/pkg/promlib v0.0.3/go.mod h1:3El4NlsfALz8QQCbEGHGFvJUG+538QLMuALRhZ3pcoo= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= @@ -1004,7 +1020,9 @@ github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/pgproto3/v2 v2.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= github.com/jackc/pgtype v1.10.0 h1:ILnBWrRMSXGczYvmkYD6PsYyVFUNLTnIUJHHDLmqk38= +github.com/jackc/pgx v3.2.0+incompatible h1:0Vihzu20St42/UDsvZGdNE6jak7oi/UOeMzwMPHkgFY= github.com/jackc/pgx/v4 v4.15.0 h1:B7dTkXsdILD3MF987WGGCcg+tvLW6bZJdEcqVFeU//w= +github.com/jackc/puddle v1.2.1 h1:gI8os0wpRXFd4FiAY2dWiqRK037tjj3t7rKFeO4X5iw= github.com/jackspirou/syscerts v0.0.0-20160531025014-b68f5469dff1 h1:9Xm8CKtMZIXgcopfdWk/qZ1rt0HjMgfMR9nxxSeK6vk= github.com/jackspirou/syscerts v0.0.0-20160531025014-b68f5469dff1/go.mod h1:zuHl3Hh+e9P6gmBPvcqR1HjkaWHC/csgyskg6IaFKFo= github.com/jaegertracing/jaeger v1.41.0 h1:vVNky8dP46M2RjGaZ7qRENqylW+tBFay3h57N16Ip7M= @@ -1018,9 +1036,11 @@ github.com/jhump/goprotoc v0.5.0 h1:Y1UgUX+txUznfqcGdDef8ZOVlyQvnV0pKWZH08RmZuo= github.com/jmattheis/goverter v1.4.0 h1:SrboBYMpGkj1XSgFhWwqzdP024zIa1+58YzUm+0jcBE= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jon-whit/go-grpc-prometheus v1.4.0 h1:/wmpGDJcLXuEjXryWhVYEGt9YBRhtLwFEN7T+Flr8sw= github.com/jon-whit/go-grpc-prometheus v1.4.0/go.mod h1:iTPm+Iuhh3IIqR0iGZ91JJEg5ax6YQEe1I0f6vtBuao= github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jsternberg/zap-logfmt v1.2.0 h1:1v+PK4/B48cy8cfQbxL4FmmNZrjnIMr2BsnyEmXqv2o= @@ -1045,6 +1065,7 @@ github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIR github.com/kataras/tunnel v0.0.4 h1:sCAqWuJV7nPzGrlb0os3j49lk2JhILT0rID38NHNLpA= github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= @@ -1079,9 +1100,11 @@ github.com/lestrrat-go/jwx v1.2.25 h1:tAx93jN2SdPvFn08fHNAhqFJazn5mBBOB8Zli0g0ot github.com/lestrrat-go/jwx v1.2.25/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743 h1:143Bb8f8DuGWck/xpNUOckBVYfFbBTnLevfRZ1aVVqo= github.com/lightstep/lightstep-tracer-go v0.18.1 h1:vi1F1IQ8N7hNWytK9DpJsUfQhGuNSc19z330K6vl4zk= github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo= +github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= github.com/lyft/protoc-gen-star v0.6.1 h1:erE0rdztuaDq3bpGifD95wfoPrSZc95nGA6tbiNYh6M= github.com/lyft/protoc-gen-star/v2 v2.0.3 h1:/3+/2sWyXeMLzKd1bX+ixWKgEMsULrIivpDsuaF441o= github.com/lyft/protoc-gen-validate v0.0.13 h1:KNt/RhmQTOLr7Aj8PsJ7mTronaFyx80mRTT9qF261dA= @@ -1099,6 +1122,7 @@ github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= github.com/matryer/moq v0.3.1 h1:kLDiBJoGcusWS2BixGyTkF224aSCD8nLY24tj/NcTCs= github.com/matryer/moq v0.3.1/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s= +github.com/matryer/moq v0.3.3/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s= github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= @@ -1147,12 +1171,15 @@ github.com/nats-io/jwt/v2 v2.5.0/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+v github.com/nats-io/nats-server/v2 v2.5.0 h1:wsnVaaXH9VRSg+A2MVg5Q727/CqxnmPLGFQ3YZYKTQg= github.com/nats-io/nats.go v1.12.1 h1:+0ndxwUPz3CmQ2vjbXdkC1fo3FdiOQDim4gl3Mge8Qo= github.com/nats-io/nats.go v1.28.0/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8= github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY= github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1 h1:dOYG7LS/WK00RWZc8XGgcUTlTxpp3mKhdR2Q9z9HbXM= github.com/oapi-codegen/testutil v1.0.0/go.mod h1:ttCaYbHvJtHuiyeBF0tPIX+4uhEPTeizXKx28okijLw= github.com/oklog/oklog v0.3.2 h1:wVfs8F+in6nTBMkA7CbRw+zZMIB7nNM825cM1wuzoTk= @@ -1231,7 +1258,9 @@ github.com/openzipkin/zipkin-go v0.4.1 h1:kNd/ST2yLLWhaWrkgchya40TJabe8Hioj9udfP github.com/openzipkin/zipkin-go v0.4.1/go.mod h1:qY0VqDSN1pOBN94dBc6w2GJlWLiovAyg7Qt6/I9HecM= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/pact-foundation/pact-go v1.0.4 h1:OYkFijGHoZAYbOIb1LWXrwKQbMMRUv1oQ89blD2Mh2Q= +github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw= github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -1254,6 +1283,7 @@ github.com/pkg/profile v1.2.1 h1:F++O52m40owAmADcojzM+9gyjmMOY/T4oYJkgFDH8RE= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.13.1 h1:I2qBYMChEhIjOgazfJmV3/mZM256btk6wkCDRmW7JYs= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= @@ -1294,6 +1324,7 @@ github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/sagikazarmark/crypt v0.6.0 h1:REOEXCs/NFY/1jOCEouMuT4zEniE5YoXbvpC5X/TLF8= +github.com/sagikazarmark/crypt v0.17.0 h1:ZA/7pXyjkHoK4bW4mIdnCLvL8hd+Nrbiw7Dqk7D4qUk= github.com/sagikazarmark/crypt v0.17.0/go.mod h1:SMtHTvdmsZMuY/bpZoqokSoChIrcJ/epOxZN58PbZDg= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da h1:p3Vo3i64TCLY7gIfzeQaUJ+kppEO5WQG3cL8iE8tGHU= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= @@ -1362,6 +1393,7 @@ github.com/tdewolff/parse/v2 v2.6.8 h1:mhNZXYCx//xG7Yq2e/kVLNZw4YfYmeHbhx+Zc0OvF github.com/tdewolff/parse/v2 v2.6.8/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -1376,6 +1408,7 @@ github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYm github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tursodatabase/libsql-client-go v0.0.0-20240411070317-a1138d155304/go.mod h1:2Fu26tjM011BLeR5+jwTfs6DX/fNMEWV/3CBZvggrA4= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 h1:G3dpKMzFDjgEh2q1Z7zUUtKa8ViPtH+ocF0bE0g00O8= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -1396,6 +1429,7 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/vburenin/ifacemaker v1.2.1 h1:3Vq8B/bfBgjWTkv+jDg4dVL1KHt3k1K4lO7XRxYA2sk= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d h1:3wDi6J5APMqaHBVPuVd7RmHD2gRTfqbdcVSpCNoUWtk= github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d/go.mod h1:mb5taDqMnJiZNRQ3+02W2IFG+oEz1+dTuCXkp4jpkfo= github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= @@ -1435,6 +1469,8 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ github.com/xhit/go-str2duration v1.2.0 h1:BcV5u025cITWxEQKGWr1URRzrcXtu7uk8+luz3Yuhwc= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20240126124512-dbb0e1720dbf/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.55.1/go.mod h1:udNPW8eupyH/EZocecFmaSNJacKKYjzQa7cVgX5U2nc= github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= @@ -1448,6 +1484,7 @@ github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY3 github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b h1:7gd+rd8P3bqcn/96gOZa3F5dpJr/vEiDQYlNb/y2uNs= go.einride.tech/aip v0.66.0 h1:XfV+NQX6L7EOYK11yoHHFtndeaWh3KbD9/cN/6iWEt8= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -1670,6 +1707,7 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.1.3 h1:qTakTkI6ni6LFD5sBwwsdSO+AQqbSIxOauHTTQKZ/7o= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo= k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ= k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8= @@ -1719,7 +1757,10 @@ k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= diff --git a/hack/README.md b/hack/README.md index 5a8819a9db0..f425f5500d5 100644 --- a/hack/README.md +++ b/hack/README.md @@ -22,8 +22,13 @@ go mod download # the happy path ./hack/update-codegen.sh - +``` Note that the script deletes existing openapi go code and regenerates in place so that you will temporarily see deleted files in your `git status`. After a successful run, you should see them restored. -``` + +If resource client is not generated for your resource make sure that it follows the k8s guidelines for structuring the resource definition + +- the directory is named after resource version, i.e. `/v` (e.g. service/v0alpha1) +- the resource directory contains file `types.go` that includes resource definitions +- the resource definitions are annotated with comment `// +genclient` diff --git a/package.json b/package.json index 9f32bc39ac5..d012e5d4771 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@emotion/eslint-plugin": "11.11.0", "@grafana/eslint-config": "7.0.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", - "@grafana/plugin-e2e": "1.3.2", + "@grafana/plugin-e2e": "1.4.0", "@grafana/tsconfig": "^1.3.0-rc1", "@manypkg/get-packages": "^2.2.0", "@playwright/test": "1.44.1", @@ -93,7 +93,6 @@ "@testing-library/jest-dom": "6.4.2", "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", - "@types/add": "^2", "@types/angular": "1.8.9", "@types/angular-route": "1.7.6", "@types/babel__core": "^7", @@ -244,6 +243,7 @@ "@emotion/react": "11.11.4", "@fingerprintjs/fingerprintjs": "^3.4.2", "@floating-ui/react": "0.26.16", + "@formatjs/intl-durationformat": "^0.2.4", "@glideapps/glide-data-grid": "^6.0.0", "@grafana/aws-sdk": "0.3.3", "@grafana/azure-sdk": "0.0.3", @@ -400,8 +400,7 @@ "uuid": "9.0.1", "visjs-network": "4.25.0", "whatwg-fetch": "3.6.20", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz", - "yarn": "^1.22.22" + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" }, "resolutions": { "underscore": "1.13.6", diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index bbe35a8fbe1..8ce1be9fe18 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -16,6 +16,7 @@ export * from './themes'; export * from './monaco'; export * from './geo/layer'; export * from './query'; +export { amendTable, trimTable, type Table } from './table/amendTimeSeries'; export { type ValueMatcherOptions, type BasicValueMatcherOptions, diff --git a/public/app/features/live/data/amendTimeSeries.test.ts b/packages/grafana-data/src/table/amendTimeSeries.test.ts similarity index 98% rename from public/app/features/live/data/amendTimeSeries.test.ts rename to packages/grafana-data/src/table/amendTimeSeries.test.ts index fce39e371da..4e49bf7df53 100644 --- a/public/app/features/live/data/amendTimeSeries.test.ts +++ b/packages/grafana-data/src/table/amendTimeSeries.test.ts @@ -1,4 +1,4 @@ -import { Table, amendTable } from "./amendTimeSeries"; +import { Table, amendTable } from './amendTimeSeries'; describe('amendTable', () => { it('should append nextTable when there is no overlap (nextTable after prevTable)', () => { diff --git a/public/app/features/live/data/amendTimeSeries.ts b/packages/grafana-data/src/table/amendTimeSeries.ts similarity index 96% rename from public/app/features/live/data/amendTimeSeries.ts rename to packages/grafana-data/src/table/amendTimeSeries.ts index bdc6c2e13dc..4eb35d0a6cf 100644 --- a/public/app/features/live/data/amendTimeSeries.ts +++ b/packages/grafana-data/src/table/amendTimeSeries.ts @@ -1,4 +1,4 @@ -import { closestIdx } from '@grafana/data'; +import { closestIdx } from '../'; export type Table = [times: number[], ...values: any[][]]; @@ -98,7 +98,7 @@ export function trimTable(table: Table, fromTime: number, toTime: number): Table if (fromIdx != null || toIdx != null) { times = times.slice(fromIdx ?? 0, toIdx); - vals = vals.map(vals2 => vals2.slice(fromIdx ?? 0, toIdx)); + vals = vals.map((vals2) => vals2.slice(fromIdx ?? 0, toIdx)); } return [times, ...vals]; diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.ts index 39e80a2d824..3305547bd10 100644 --- a/packages/grafana-data/src/transformations/transformers/filterByValue.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.ts @@ -56,8 +56,12 @@ export const filterByValueTransformer: DataTransformerInfo { if (filter.config.id === ValueMatcherID.between) { - const interpolatedFrom = ctx.interpolate(filter.config.options.from); - const interpolatedTo = ctx.interpolate(filter.config.options.to); + if (typeof filter.config.options.from === 'string') { + filter.config.options.from = ctx.interpolate(filter.config.options.from); + } + if (typeof filter.config.options.to === 'string') { + filter.config.options.to = ctx.interpolate(filter.config.options.to); + } const newFilter = { ...filter, @@ -65,8 +69,8 @@ export const filterByValueTransformer: DataTransformerInfo { storageKey: string; diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index e56ef12c010..0bece5b8160 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -13,6 +13,8 @@ export * from './binaryOperators'; export * from './unaryOperators'; export * from './nodeGraph'; export * from './selectUtils'; +export * from './store'; +export * from './LocalStorageValueProvider'; export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders'; export { arrayUtils }; export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; diff --git a/packages/grafana-o11y-ds-frontend/src/store.ts b/packages/grafana-data/src/utils/store.ts similarity index 100% rename from packages/grafana-o11y-ds-frontend/src/store.ts rename to packages/grafana-data/src/utils/store.ts diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 3a763ec3fe5..d5936714cb9 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -266,7 +266,7 @@ export const Components = { }, }, Tab: { - title: (title: string) => `Tab ${title}`, + title: (title: string) => `data-testid Tab ${title}`, active: () => '[class*="-activeTabStyle"]', }, RefreshPicker: { @@ -603,4 +603,7 @@ export const Components = { headerOrderSwitch: 'data-testid header-order-switch', headerPreviewSwitch: 'data-testid header-preview-switch', }, + EntityNotFound: { + container: 'data-testid entity-not-found', + }, }; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 763c8b1bbb4..a387f8538b3 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -66,6 +66,7 @@ export const Pages = { container: 'data-testid new share button menu', shareInternally: 'data-testid new share button share internally', shareExternally: 'data-testid new share button share externally', + shareSnapshot: 'data-testid new share button share snapshot', }, }, playlistControls: { @@ -103,7 +104,7 @@ export const Pages = { * @deprecated use components.TimeZonePicker.containerV2 from Grafana 8.3 instead */ timezone: 'Time zone picker select container', - title: 'Tab General', + title: 'General', }, Annotations: { List: { @@ -245,7 +246,6 @@ export const Pages = { }, ShareDashboardModal: { PublicDashboard: { - Tab: 'Tab Public dashboard', WillBePublicCheckbox: 'data-testid public dashboard will be public checkbox', LimitedDSCheckbox: 'data-testid public dashboard limited datasources checkbox', CostIncreaseCheckbox: 'data-testid public dashboard cost may increase checkbox', @@ -270,12 +270,8 @@ export const Pages = { ReshareLink: 'data-testid public dashboard reshare link button', }, }, - PublicDashboardScene: { - Tab: 'Tab Public Dashboard', - }, SnapshotScene: { url: (key: string) => `/dashboard/snapshot/${key}`, - Tab: 'Tab Snapshot', PublishSnapshot: 'data-testid publish snapshot button', CopyUrlButton: 'data-testid snapshot copy url button', CopyUrlInput: 'data-testid snapshot copy url input', @@ -287,6 +283,9 @@ export const Pages = { copyUrlButton: 'data-testid share externally copy url button', shareTypeSelect: 'data-testid share externally share type select', }, + ShareSnapshot: { + container: 'data-testid share snapshot drawer container', + }, }, PublicDashboard: { page: 'public-dashboard-page', diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md index 060db7211d9..2f73d46a529 100644 --- a/packages/grafana-eslint-rules/README.md +++ b/packages/grafana-eslint-rules/README.md @@ -133,16 +133,23 @@ Check if strings are marked for translation. ```tsx // Bad ❌ -const SearchTitle = ({ term }) => ( -
- Results for {term} -
-); +const SearchTitle = ({ term }) =>
Results for {term}
; -//Good ✅ +// Good ✅ +const SearchTitle = ({ term }) => Results for {{ term }}; + +// Good ✅ (if you need to interpolate variables inside nested components) +const SearchTerm = ({ term }) => {term}; const SearchTitle = ({ term }) => ( - Results for {{ term }} + Results for + +); + +// Good ✅ (if you need to interpolate variables and additional translated strings inside nested components) +const SearchTitle = ({ term }) => ( + + Results for {'{{ myVariable }}'} and this translated text is also in green ); ``` diff --git a/packages/grafana-o11y-ds-frontend/src/index.ts b/packages/grafana-o11y-ds-frontend/src/index.ts index c2f7e931a27..6cab6dbb838 100644 --- a/packages/grafana-o11y-ds-frontend/src/index.ts +++ b/packages/grafana-o11y-ds-frontend/src/index.ts @@ -13,6 +13,4 @@ export * from './TraceToLogs/TraceToLogsSettings'; export * from './TraceToMetrics/TraceToMetricsSettings'; export * from './TraceToProfiles/TraceToProfilesSettings'; export * from './utils'; -export * from './store'; -export * from './LocalStorageValueProvider/LocalStorageValueProvider'; export * from './combineResponses'; diff --git a/packages/grafana-prometheus/src/components/PromCheatSheet.tsx b/packages/grafana-prometheus/src/components/PromCheatSheet.tsx index 7eaee0b0782..4e452244cce 100644 --- a/packages/grafana-prometheus/src/components/PromCheatSheet.tsx +++ b/packages/grafana-prometheus/src/components/PromCheatSheet.tsx @@ -1,7 +1,9 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx +import { css } from '@emotion/css'; import React from 'react'; -import { QueryEditorHelpProps } from '@grafana/data'; +import { GrafanaTheme2, QueryEditorHelpProps } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { PromQuery } from '../types'; @@ -29,23 +31,44 @@ const CHEAT_SHEET_ITEMS = [ }, ]; -export const PromCheatSheet = (props: QueryEditorHelpProps) => ( -
-

PromQL Cheat Sheet

- {CHEAT_SHEET_ITEMS.map((item, index) => ( -
-
{item.title}
- {item.expression ? ( - - ) : null} -
{item.label}
-
- ))} -
-); +export const PromCheatSheet = (props: QueryEditorHelpProps) => { + const styles = useStyles2(getStyles); + + return ( +
+

PromQL Cheat Sheet

+ {CHEAT_SHEET_ITEMS.map((item, index) => ( +
+
{item.title}
+ {item.expression ? ( + + ) : null} + {item.label} +
+ ))} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + cheatSheetItem: css({ + margin: theme.spacing(3, 0), + }), + cheatSheetItemTitle: css({ + fontSize: theme.typography.h3.fontSize, + }), + cheatSheetExample: css({ + margin: theme.spacing(0.5, 0), + // element is interactive, clear button styles + textAlign: 'left', + border: 'none', + background: 'transparent', + display: 'block', + }), +}); diff --git a/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx b/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx index a202786e14a..bbff06629c5 100644 --- a/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx +++ b/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx @@ -4,7 +4,8 @@ import { isEqual } from 'lodash'; import React, { memo, useCallback } from 'react'; import { usePrevious } from 'react-use'; -import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { InlineFormLabel, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { PrometheusDatasource } from '../datasource'; import { PromQuery } from '../types'; @@ -21,6 +22,7 @@ export interface PromExploreExtraFieldProps { export const PromExploreExtraField = memo(({ query, datasource, onChange, onRunQuery }: PromExploreExtraFieldProps) => { const rangeOptions = getQueryTypeOptions(true); const prevQuery = usePrevious(query); + const styles = useStyles2(getStyles); const onExemplarChange = useCallback( (exemplar: boolean) => { @@ -59,7 +61,8 @@ export const PromExploreExtraField = memo(({ query, datasource, onChange, onRunQ
({ + queryTypeField: css({ + marginRight: theme.spacing(0.5), + }), +}); diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index ac648271d44..e4a83498aad 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -1,5 +1,5 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx -import { cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { ReactNode } from 'react'; import { isDataFrame, QueryEditorProps, QueryHint, TimeRange, toLegacyResponseData } from '@grafana/data'; @@ -265,8 +265,12 @@ class PromQueryFieldClass extends React.PureComponent -
+
+
{hint.label}{' '} {hint.fix ? ( {isEditing ? ( -
-
- -
- {editMode === 'tz' ? ( -
- { - onToggleChangeTimeSettings(); - - if (isString(timeZone)) { - onChangeTimeZone(timeZone); - } - }} - onBlur={onToggleChangeTimeSettings} - menuShouldPortal={false} - /> -
- ) : ( -
- + + { + setEditMode('tz'); + }} + aria-controls={timeZoneSettingsId} + /> + { + setEditMode('fy'); + }} + aria-controls={fiscalYearSettingsId} + /> + + + {editMode === 'tz' ? ( +
- { + if (onChangeFiscalYearStartMonth) { + onChangeFiscalYearStartMonth(value.value ?? 0); + } + }} + /> + +
+ )} +
) : null}
@@ -143,7 +164,7 @@ export const TimePickerFooter = (props: Props) => { const getStyle = (theme: GrafanaTheme2) => ({ container: css({ borderTop: `1px solid ${theme.colors.border.weak}`, - padding: '11px', + padding: theme.spacing(1.5), display: 'flex', flexDirection: 'row', justifyContent: 'space-between', @@ -151,7 +172,8 @@ const getStyle = (theme: GrafanaTheme2) => ({ }), editContainer: css({ borderTop: `1px solid ${theme.colors.border.weak}`, - padding: '11px', + padding: theme.spacing(1.5), + paddingTop: 0, justifyContent: 'space-between', alignItems: 'center', }), diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index ef72c19eca9..e6f36c80491 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -135,6 +135,7 @@ export function PanelChrome({ const theme = useTheme2(); const styles = useStyles2(getStyles); const panelContentId = useId(); + const panelTitleId = useId().replace(/:/g, '_'); const hasHeader = !hoverHeader; @@ -179,7 +180,13 @@ export function PanelChrome({ {/* Non collapsible title */} {!collapsible && title && (
- + {title}
@@ -206,7 +213,7 @@ export function PanelChrome({ aria-hidden={!!title} aria-label={!title ? 'toggle collapse panel' : undefined} /> - + {title} @@ -249,6 +256,7 @@ export function PanelChrome({
{ position: 'absolute', top: 0, width: '100%', + // this is to force the loading bar container to create a new stacking context + // otherwise, in webkit browsers on windows/linux, the aliasing of panel text changes when the loading bar is shown + // see https://github.com/grafana/grafana/issues/88104 + zIndex: 1, }), containNone: css({ contain: 'none', diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 86ac3adfb36..109637fedcd 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -43,9 +43,9 @@ export const Tab = React.forwardRef( const commonProps = { className: linkClass, + 'data-testid': selectors.components.Tab.title(label), ...otherProps, onClick: onChangeTab, - 'aria-label': otherProps['aria-label'] || selectors.components.Tab.title(label), role: 'tab', 'aria-selected': active, }; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 08abb985340..dca235c72c0 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -231,6 +231,7 @@ export { FieldArray } from './Forms/FieldArray'; // Select export { default as resetSelectStyles } from './Select/resetSelectStyles'; export * from './Select/Select'; +export { SelectMenuOptions } from './Select/SelectMenu'; export { getSelectStyles } from './Select/getSelectStyles'; export * from './Select/types'; diff --git a/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx b/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx index f8cddcb4de8..5e8742109d8 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx +++ b/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx @@ -9,13 +9,16 @@ import { getCardStyles } from './card'; import { getCodeStyles } from './code'; import { getElementStyles } from './elements'; import { getExtraStyles } from './extra'; +import { getFontAwesomeStyles } from './fontAwesome'; import { getFontStyles } from './fonts'; import { getFormElementStyles } from './forms'; +import { getJsonFormatterStyles } from './jsonFormatter'; import { getLegacySelectStyles } from './legacySelect'; import { getMarkdownStyles } from './markdownStyles'; import { getPageStyles } from './page'; import { getRcTimePickerStyles } from './rcTimePicker'; import { getSkeletonStyles } from './skeletonStyles'; +import { getSlateStyles } from './slate'; import { getUplotStyles } from './uPlot'; /** @internal */ @@ -29,13 +32,16 @@ export function GlobalStyles() { getCodeStyles(theme), getElementStyles(theme), getExtraStyles(theme), + getFontAwesomeStyles(theme), getFontStyles(theme), getFormElementStyles(theme), + getJsonFormatterStyles(theme), getPageStyles(theme), getCardStyles(theme), getAgularPanelStyles(theme), getMarkdownStyles(theme), getSkeletonStyles(theme), + getSlateStyles(theme), getRcTimePickerStyles(theme), getUplotStyles(theme), getLegacySelectStyles(theme), diff --git a/packages/grafana-ui/src/themes/GlobalStyles/code.ts b/packages/grafana-ui/src/themes/GlobalStyles/code.ts index 6ca7a03fa62..219bd089462 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/code.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/code.ts @@ -4,7 +4,7 @@ import { GrafanaTheme2 } from '@grafana/data'; export function getCodeStyles(theme: GrafanaTheme2) { return css({ - 'code, pre': { + 'code, pre, kbd, samp': { ...theme.typography.code, fontSize: theme.typography.bodySmall.fontSize, backgroundColor: theme.colors.background.primary, @@ -26,6 +26,7 @@ export function getCodeStyles(theme: GrafanaTheme2) { wordBreak: 'break-all', wordWrap: 'break-word', whiteSpace: 'pre-wrap', + overflow: 'auto', padding: '10px', code: { diff --git a/packages/grafana-ui/src/themes/GlobalStyles/elements.ts b/packages/grafana-ui/src/themes/GlobalStyles/elements.ts index a37f5561b25..9f8164559a5 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/elements.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/elements.ts @@ -40,6 +40,10 @@ export function getElementStyles(theme: GrafanaTheme2) { margin: theme.spacing(0, 0, 2), }, + textarea: { + overflow: 'auto', + }, + button: { letterSpacing: theme.typography.body.letterSpacing, @@ -67,6 +71,82 @@ export function getElementStyles(theme: GrafanaTheme2) { fontStyle: 'normal', }, + 'audio, canvas, progress, video': { + display: 'inline-block', + verticalAlign: 'baseline', + }, + + // Prevent modern browsers from displaying `audio` without controls. + // Remove excess height in iOS 5 devices. + 'audio:not([controls])': { + display: 'none', + height: 0, + }, + + // Address styling not present in Safari. + 'abbr[title]': { + borderBottom: '1px dotted', + }, + dfn: { + fontStyle: 'italic', + }, + + // Prevent `sub` and `sup` affecting `line-height` in all browsers. + 'sub, sup': { + fontSize: '75%', + lineHeight: 0, + position: 'relative', + verticalAlign: 'baseline', + }, + sup: { + top: '-0.5em', + }, + sub: { + bottom: '-0.25em', + }, + + // 1. Correct color not being inherited. + // Known issue: affects color of disabled elements. + // 2. Correct font properties not being inherited. + // 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + 'button, input, optgroup, select, textarea': { + color: 'inherit', + font: 'inherit', + margin: 0, + }, + + // Don't inherit the `font-weight` (applied by a rule above). + // NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + optgroup: { + fontWeight: 'bold', + }, + + // 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + // and `video` controls. + // 2. Correct inability to style clickable `input` types in iOS. + // 3. Improve usability and consistency of cursor style between image-type + // `input` and others. + 'button, html input[type="button"], input[type="submit"]': { + WebkitAppearance: 'button', + cursor: 'pointer', + }, + + // Remove inner padding and search cancel button in Safari and Chrome on OS X. + // Safari (but not Chrome) clips the cancel button when the search input has + // padding (and `textfield` appearance). + 'input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-decoration': { + WebkitAppearance: 'none', + }, + + table: { + borderCollapse: 'collapse', + borderSpacing: 0, + }, + + 'td, th': { + padding: 0, + }, + // Utility classes '.muted': { color: theme.colors.text.secondary, diff --git a/packages/grafana-ui/src/themes/GlobalStyles/fontAwesome.ts b/packages/grafana-ui/src/themes/GlobalStyles/fontAwesome.ts new file mode 100644 index 00000000000..fe04a352564 --- /dev/null +++ b/packages/grafana-ui/src/themes/GlobalStyles/fontAwesome.ts @@ -0,0 +1,2743 @@ +// font-awesome icons are deprecated +// TODO remove this once we've communicated a proper deprecation and removal strategy +/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */ +import { css } from '@emotion/react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export function getFontAwesomeStyles(theme: GrafanaTheme2) { + const grafanaPublicPath = typeof window !== 'undefined' && window.__grafana_public_path__; + const fontRoot = grafanaPublicPath ? `${grafanaPublicPath}fonts/` : 'public/fonts/'; + const faVersion = '4.7.0'; + + return css([ + { + '@font-face': { + fontFamily: 'FontAwesome', + src: ` + url('${fontRoot}fontawesome-webfont.eot?#iefix&v=${faVersion}') format('embedded-opentype'), + url('${fontRoot}fontawesome-webfont.woff2?v=${faVersion}') format('woff2'), + url('${fontRoot}fontawesome-webfont.woff?v=${faVersion}') format('woff'), + url('${fontRoot}fontawesome-webfont.ttf?v=${faVersion}') format('truetype'), + url('${fontRoot}fontawesome-webfont.svg?v=${faVersion}#fontawesomeregular') format('svg'); + `, + fontWeight: 'normal', + fontStyle: 'normal', + }, + }, + { + '.fa': { + display: 'inline-block', + font: 'normal normal normal 14px/1 FontAwesome', // shortening font declaration + fontSize: 'inherit', // can't have font-size inherit on line above, so need to override + textRendering: 'auto', // optimizelegibility throws things off #1094 + WebkitFontSmoothing: 'antialiased', + MozOsxFontSmoothing: 'grayscale', + }, + }, + { + '.fa-glass:before': { + content: "'\\f000'", + }, + + '.fa-music:before': { + content: "'\\f001'", + }, + + '.fa-search:before': { + content: "'\\f002'", + }, + + '.fa-envelope-o:before': { + content: "'\\f003'", + }, + + '.fa-heart:before': { + content: "'\\f004'", + }, + + '.fa-star:before': { + content: "'\\f005'", + }, + + '.fa-star-o:before': { + content: "'\\f006'", + }, + + '.fa-user:before': { + content: "'\\f007'", + }, + + '.fa-film:before': { + content: "'\\f008'", + }, + + '.fa-th-large:before': { + content: "'\\f009'", + }, + + '.fa-th:before': { + content: "'\\f00a'", + }, + + '.fa-th-list:before': { + content: "'\\f00b'", + }, + + '.fa-check:before': { + content: "'\\f00c'", + }, + + '.fa-remove:before, .fa-close:before, .fa-times:before': { + content: "'\\f00d'", + }, + + '.fa-search-plus:before': { + content: "'\\f00e'", + }, + + '.fa-search-minus:before': { + content: "'\\f010'", + }, + + '.fa-power-off:before': { + content: "'\\f011'", + }, + + '.fa-signal:before': { + content: "'\\f012'", + }, + + '.fa-gear:before, .fa-cog:before': { + content: "'\\f013'", + }, + + '.fa-trash-o:before': { + content: "'\\f014'", + }, + + '.fa-home:before': { + content: "'\\f015'", + }, + + '.fa-file-o:before': { + content: "'\\f016'", + }, + + '.fa-clock-o:before': { + content: "'\\f017'", + }, + + '.fa-road:before': { + content: "'\\f018'", + }, + + '.fa-download:before': { + content: "'\\f019'", + }, + + '.fa-arrow-circle-o-down:before': { + content: "'\\f01a'", + }, + + '.fa-arrow-circle-o-up:before': { + content: "'\\f01b'", + }, + + '.fa-inbox:before': { + content: "'\\f01c'", + }, + + '.fa-play-circle-o:before': { + content: "'\\f01d'", + }, + + '.fa-rotate-right:before, .fa-repeat:before': { + content: "'\\f01e'", + }, + + '.fa-refresh:before': { + content: "'\\f021'", + }, + + '.fa-list-alt:before': { + content: "'\\f022'", + }, + + '.fa-lock:before': { + content: "'\\f023'", + }, + + '.fa-flag:before': { + content: "'\\f024'", + }, + + '.fa-headphones:before': { + content: "'\\f025'", + }, + + '.fa-volume-off:before': { + content: "'\\f026'", + }, + + '.fa-volume-down:before': { + content: "'\\f027'", + }, + + '.fa-volume-up:before': { + content: "'\\f028'", + }, + + '.fa-qrcode:before': { + content: "'\\f029'", + }, + + '.fa-barcode:before': { + content: "'\\f02a'", + }, + + '.fa-tag:before': { + content: "'\\f02b'", + }, + + '.fa-tags:before': { + content: "'\\f02c'", + }, + + '.fa-book:before': { + content: "'\\f02d'", + }, + + '.fa-bookmark:before': { + content: "'\\f02e'", + }, + + '.fa-print:before': { + content: "'\\f02f'", + }, + + '.fa-camera:before': { + content: "'\\f030'", + }, + + '.fa-font:before': { + content: "'\\f031'", + }, + + '.fa-bold:before': { + content: "'\\f032'", + }, + + '.fa-italic:before': { + content: "'\\f033'", + }, + + '.fa-text-height:before': { + content: "'\\f034'", + }, + + '.fa-text-width:before': { + content: "'\\f035'", + }, + + '.fa-align-left:before': { + content: "'\\f036'", + }, + + '.fa-align-center:before': { + content: "'\\f037'", + }, + + '.fa-align-right:before': { + content: "'\\f038'", + }, + + '.fa-align-justify:before': { + content: "'\\f039'", + }, + + '.fa-list:before': { + content: "'\\f03a'", + }, + + '.fa-dedent:before, .fa-outdent:before': { + content: "'\\f03b'", + }, + + '.fa-indent:before': { + content: "'\\f03c'", + }, + + '.fa-video-camera:before': { + content: "'\\f03d'", + }, + + '.fa-photo:before, .fa-image:before, .fa-picture-o:before': { + content: "'\\f03e'", + }, + + '.fa-pencil:before': { + content: "'\\f040'", + }, + + '.fa-map-marker:before': { + content: "'\\f041'", + }, + + '.fa-adjust:before': { + content: "'\\f042'", + }, + + '.fa-tint:before': { + content: "'\\f043'", + }, + + '.fa-edit:before, .fa-pencil-square-o:before': { + content: "'\\f044'", + }, + + '.fa-share-square-o:before': { + content: "'\\f045'", + }, + + '.fa-check-square-o:before': { + content: "'\\f046'", + }, + + '.fa-arrows:before': { + content: "'\\f047'", + }, + + '.fa-step-backward:before': { + content: "'\\f048'", + }, + + '.fa-fast-backward:before': { + content: "'\\f049'", + }, + + '.fa-backward:before': { + content: "'\\f04a'", + }, + + '.fa-play:before': { + content: "'\\f04b'", + }, + + '.fa-pause:before': { + content: "'\\f04c'", + }, + + '.fa-stop:before': { + content: "'\\f04d'", + }, + + '.fa-forward:before': { + content: "'\\f04e'", + }, + + '.fa-fast-forward:before': { + content: "'\\f050'", + }, + + '.fa-step-forward:before': { + content: "'\\f051'", + }, + + '.fa-eject:before': { + content: "'\\f052'", + }, + + '.fa-chevron-left:before': { + content: "'\\f053'", + }, + + '.fa-chevron-right:before': { + content: "'\\f054'", + }, + + '.fa-plus-circle:before': { + content: "'\\f055'", + }, + + '.fa-minus-circle:before': { + content: "'\\f056'", + }, + + '.fa-times-circle:before': { + content: "'\\f057'", + }, + + '.fa-check-circle:before': { + content: "'\\f058'", + }, + + '.fa-question-circle:before': { + content: "'\\f059'", + }, + + '.fa-info-circle:before': { + content: "'\\f05a'", + }, + + '.fa-crosshairs:before': { + content: "'\\f05b'", + }, + + '.fa-times-circle-o:before': { + content: "'\\f05c'", + }, + + '.fa-check-circle-o:before': { + content: "'\\f05d'", + }, + + '.fa-ban:before': { + content: "'\\f05e'", + }, + + '.fa-arrow-left:before': { + content: "'\\f060'", + }, + + '.fa-arrow-right:before': { + content: "'\\f061'", + }, + + '.fa-arrow-up:before': { + content: "'\\f062'", + }, + + '.fa-arrow-down:before': { + content: "'\\f063'", + }, + + '.fa-mail-forward:before, .fa-share:before': { + content: "'\\f064'", + }, + + '.fa-expand:before': { + content: "'\\f065'", + }, + + '.fa-compress:before': { + content: "'\\f066'", + }, + + '.fa-plus:before': { + content: "'\\f067'", + }, + + '.fa-minus:before': { + content: "'\\f068'", + }, + + '.fa-asterisk:before': { + content: "'\\f069'", + }, + + '.fa-exclamation-circle:before': { + content: "'\\f06a'", + }, + + '.fa-gift:before': { + content: "'\\f06b'", + }, + + '.fa-leaf:before': { + content: "'\\f06c'", + }, + + '.fa-fire:before': { + content: "'\\f06d'", + }, + + '.fa-eye:before': { + content: "'\\f06e'", + }, + + '.fa-eye-slash:before': { + content: "'\\f070'", + }, + + '.fa-warning:before, .fa-exclamation-triangle:before': { + content: "'\\f071'", + }, + + '.fa-plane:before': { + content: "'\\f072'", + }, + + '.fa-calendar:before': { + content: "'\\f073'", + }, + + '.fa-random:before': { + content: "'\\f074'", + }, + + '.fa-comment:before': { + content: "'\\f075'", + }, + + '.fa-magnet:before': { + content: "'\\f076'", + }, + + '.fa-chevron-up:before': { + content: "'\\f077'", + }, + + '.fa-chevron-down:before': { + content: "'\\f078'", + }, + + '.fa-retweet:before': { + content: "'\\f079'", + }, + + '.fa-shopping-cart:before': { + content: "'\\f07a'", + }, + + '.fa-folder:before': { + content: "'\\f07b'", + }, + + '.fa-folder-open:before': { + content: "'\\f07c'", + }, + + '.fa-arrows-v:before': { + content: "'\\f07d'", + }, + + '.fa-arrows-h:before': { + content: "'\\f07e'", + }, + + '.fa-bar-chart-o:before, .fa-bar-chart:before': { + content: "'\\f080'", + }, + + '.fa-twitter-square:before': { + content: "'\\f081'", + }, + + '.fa-facebook-square:before': { + content: "'\\f082'", + }, + + '.fa-camera-retro:before': { + content: "'\\f083'", + }, + + '.fa-key:before': { + content: "'\\f084'", + }, + + '.fa-gears:before, .fa-cogs:before': { + content: "'\\f085'", + }, + + '.fa-comments:before': { + content: "'\\f086'", + }, + + '.fa-thumbs-o-up:before': { + content: "'\\f087'", + }, + + '.fa-thumbs-o-down:before': { + content: "'\\f088'", + }, + + '.fa-star-half:before': { + content: "'\\f089'", + }, + + '.fa-heart-o:before': { + content: "'\\f08a'", + }, + + '.fa-sign-out:before': { + content: "'\\f08b'", + }, + + '.fa-linkedin-square:before': { + content: "'\\f08c'", + }, + + '.fa-thumb-tack:before': { + content: "'\\f08d'", + }, + + '.fa-external-link:before': { + content: "'\\f08e'", + }, + + '.fa-sign-in:before': { + content: "'\\f090'", + }, + + '.fa-trophy:before': { + content: "'\\f091'", + }, + + '.fa-github-square:before': { + content: "'\\f092'", + }, + + '.fa-upload:before': { + content: "'\\f093'", + }, + + '.fa-lemon-o:before': { + content: "'\\f094'", + }, + + '.fa-phone:before': { + content: "'\\f095'", + }, + + '.fa-square-o:before': { + content: "'\\f096'", + }, + + '.fa-bookmark-o:before': { + content: "'\\f097'", + }, + + '.fa-phone-square:before': { + content: "'\\f098'", + }, + + '.fa-twitter:before': { + content: "'\\f099'", + }, + + '.fa-facebook-f:before, .fa-facebook:before': { + content: "'\\f09a'", + }, + + '.fa-github:before': { + content: "'\\f09b'", + }, + + '.fa-unlock:before': { + content: "'\\f09c'", + }, + + '.fa-credit-card:before': { + content: "'\\f09d'", + }, + + '.fa-feed:before, .fa-rss:before': { + content: "'\\f09e'", + }, + + '.fa-hdd-o:before': { + content: "'\\f0a0'", + }, + + '.fa-bullhorn:before': { + content: "'\\f0a1'", + }, + + '.fa-bell:before': { + content: "'\\f0f3'", + }, + + '.fa-certificate:before': { + content: "'\\f0a3'", + }, + + '.fa-hand-o-right:before': { + content: "'\\f0a4'", + }, + + '.fa-hand-o-left:before': { + content: "'\\f0a5'", + }, + + '.fa-hand-o-up:before': { + content: "'\\f0a6'", + }, + + '.fa-hand-o-down:before': { + content: "'\\f0a7'", + }, + + '.fa-arrow-circle-left:before': { + content: "'\\f0a8'", + }, + + '.fa-arrow-circle-right:before': { + content: "'\\f0a9'", + }, + + '.fa-arrow-circle-up:before': { + content: "'\\f0aa'", + }, + + '.fa-arrow-circle-down:before': { + content: "'\\f0ab'", + }, + + '.fa-globe:before': { + content: "'\\f0ac'", + }, + + '.fa-wrench:before': { + content: "'\\f0ad'", + }, + + '.fa-tasks:before': { + content: "'\\f0ae'", + }, + + '.fa-filter:before': { + content: "'\\f0b0'", + }, + + '.fa-briefcase:before': { + content: "'\\f0b1'", + }, + + '.fa-arrows-alt:before': { + content: "'\\f0b2'", + }, + + '.fa-group:before, .fa-users:before': { + content: "'\\f0c0'", + }, + + '.fa-chain:before, .fa-link:before': { + content: "'\\f0c1'", + }, + + '.fa-cloud:before': { + content: "'\\f0c2'", + }, + + '.fa-flask:before': { + content: "'\\f0c3'", + }, + + '.fa-cut:before, .fa-scissors:before': { + content: "'\\f0c4'", + }, + + '.fa-copy:before, .fa-files-o:before': { + content: "'\\f0c5'", + }, + + '.fa-paperclip:before': { + content: "'\\f0c6'", + }, + + '.fa-save:before, .fa-floppy-o:before': { + content: "'\\f0c7'", + }, + + '.fa-square:before': { + content: "'\\f0c8'", + }, + + '.fa-navicon:before, .fa-reorder:before, .fa-bars:before': { + content: "'\\f0c9'", + }, + + '.fa-list-ul:before': { + content: "'\\f0ca'", + }, + + '.fa-list-ol:before': { + content: "'\\f0cb'", + }, + + '.fa-strikethrough:before': { + content: "'\\f0cc'", + }, + + '.fa-underline:before': { + content: "'\\f0cd'", + }, + + '.fa-table:before': { + content: "'\\f0ce'", + }, + + '.fa-magic:before': { + content: "'\\f0d0'", + }, + + '.fa-truck:before': { + content: "'\\f0d1'", + }, + + '.fa-pinterest:before': { + content: "'\\f0d2'", + }, + + '.fa-pinterest-square:before': { + content: "'\\f0d3'", + }, + + '.fa-google-plus-square:before': { + content: "'\\f0d4'", + }, + + '.fa-google-plus:before': { + content: "'\\f0d5'", + }, + + '.fa-money:before': { + content: "'\\f0d6'", + }, + + '.fa-caret-down:before': { + content: "'\\f0d7'", + }, + + '.fa-caret-up:before': { + content: "'\\f0d8'", + }, + + '.fa-caret-left:before': { + content: "'\\f0d9'", + }, + + '.fa-caret-right:before': { + content: "'\\f0da'", + }, + + '.fa-columns:before': { + content: "'\\f0db'", + }, + + '.fa-unsorted:before, .fa-sort:before': { + content: "'\\f0dc'", + }, + + '.fa-sort-down:before, .fa-sort-desc:before': { + content: "'\\f0dd'", + }, + + '.fa-sort-up:before, .fa-sort-asc:before': { + content: "'\\f0de'", + }, + + '.fa-envelope:before': { + content: "'\\f0e0'", + }, + + '.fa-linkedin:before': { + content: "'\\f0e1'", + }, + + '.fa-rotate-left:before, .fa-undo:before': { + content: "'\\f0e2'", + }, + + '.fa-legal:before, .fa-gavel:before': { + content: "'\\f0e3'", + }, + + '.fa-dashboard:before, .fa-tachometer:before': { + content: "'\\f0e4'", + }, + + '.fa-comment-o:before': { + content: "'\\f0e5'", + }, + + '.fa-comments-o:before': { + content: "'\\f0e6'", + }, + + '.fa-flash:before, .fa-bolt:before': { + content: "'\\f0e7'", + }, + + '.fa-sitemap:before': { + content: "'\\f0e8'", + }, + + '.fa-umbrella:before': { + content: "'\\f0e9'", + }, + + '.fa-paste:before, .fa-clipboard:before': { + content: "'\\f0ea'", + }, + + '.fa-lightbulb-o:before': { + content: "'\\f0eb'", + }, + + '.fa-exchange:before': { + content: "'\\f0ec'", + }, + + '.fa-cloud-download:before': { + content: "'\\f0ed'", + }, + + '.fa-cloud-upload:before': { + content: "'\\f0ee'", + }, + + '.fa-user-md:before': { + content: "'\\f0f0'", + }, + + '.fa-stethoscope:before': { + content: "'\\f0f1'", + }, + + '.fa-suitcase:before': { + content: "'\\f0f2'", + }, + + '.fa-bell-o:before': { + content: "'\\f0a2'", + }, + + '.fa-coffee:before': { + content: "'\\f0f4'", + }, + + '.fa-cutlery:before': { + content: "'\\f0f5'", + }, + + '.fa-file-text-o:before': { + content: "'\\f0f6'", + }, + + '.fa-building-o:before': { + content: "'\\f0f7'", + }, + + '.fa-hospital-o:before': { + content: "'\\f0f8'", + }, + + '.fa-ambulance:before': { + content: "'\\f0f9'", + }, + + '.fa-medkit:before': { + content: "'\\f0fa'", + }, + + '.fa-fighter-jet:before': { + content: "'\\f0fb'", + }, + + '.fa-beer:before': { + content: "'\\f0fc'", + }, + + '.fa-h-square:before': { + content: "'\\f0fd'", + }, + + '.fa-plus-square:before': { + content: "'\\f0fe'", + }, + + '.fa-angle-double-left:before': { + content: "'\\f100'", + }, + + '.fa-angle-double-right:before': { + content: "'\\f101'", + }, + + '.fa-angle-double-up:before': { + content: "'\\f102'", + }, + + '.fa-angle-double-down:before': { + content: "'\\f103'", + }, + + '.fa-angle-left:before': { + content: "'\\f104'", + }, + + '.fa-angle-right:before': { + content: "'\\f105'", + }, + + '.fa-angle-up:before': { + content: "'\\f106'", + }, + + '.fa-angle-down:before': { + content: "'\\f107'", + }, + + '.fa-desktop:before': { + content: "'\\f108'", + }, + + '.fa-laptop:before': { + content: "'\\f109'", + }, + + '.fa-tablet:before': { + content: "'\\f10a'", + }, + + '.fa-mobile-phone:before, .fa-mobile:before': { + content: "'\\f10b'", + }, + + '.fa-circle-o:before': { + content: "'\\f10c'", + }, + + '.fa-quote-left:before': { + content: "'\\f10d'", + }, + + '.fa-quote-right:before': { + content: "'\\f10e'", + }, + + '.fa-spinner:before': { + content: "'\\f110'", + }, + + '.fa-circle:before': { + content: "'\\f111'", + }, + + '.fa-mail-reply:before, .fa-reply:before': { + content: "'\\f112'", + }, + + '.fa-github-alt:before': { + content: "'\\f113'", + }, + + '.fa-folder-o:before': { + content: "'\\f114'", + }, + + '.fa-folder-open-o:before': { + content: "'\\f115'", + }, + + '.fa-smile-o:before': { + content: "'\\f118'", + }, + + '.fa-frown-o:before': { + content: "'\\f119'", + }, + + '.fa-meh-o:before': { + content: "'\\f11a'", + }, + + '.fa-gamepad:before': { + content: "'\\f11b'", + }, + + '.fa-keyboard-o:before': { + content: "'\\f11c'", + }, + + '.fa-flag-o:before': { + content: "'\\f11d'", + }, + + '.fa-flag-checkered:before': { + content: "'\\f11e'", + }, + + '.fa-terminal:before': { + content: "'\\f120'", + }, + + '.fa-code:before': { + content: "'\\f121'", + }, + + '.fa-mail-reply-all:before, .fa-reply-all:before': { + content: "'\\f122'", + }, + + '.fa-star-half-empty:before, .fa-star-half-full:before, .fa-star-half-o:before': { + content: "'\\f123'", + }, + + '.fa-location-arrow:before': { + content: "'\\f124'", + }, + + '.fa-crop:before': { + content: "'\\f125'", + }, + + '.fa-code-fork:before': { + content: "'\\f126'", + }, + + '.fa-unlink:before, .fa-chain-broken:before': { + content: "'\\f127'", + }, + + '.fa-question:before': { + content: "'\\f128'", + }, + + '.fa-info:before': { + content: "'\\f129'", + }, + + '.fa-exclamation:before': { + content: "'\\f12a'", + }, + + '.fa-superscript:before': { + content: "'\\f12b'", + }, + + '.fa-subscript:before': { + content: "'\\f12c'", + }, + + '.fa-eraser:before': { + content: "'\\f12d'", + }, + + '.fa-puzzle-piece:before': { + content: "'\\f12e'", + }, + + '.fa-microphone:before': { + content: "'\\f130'", + }, + + '.fa-microphone-slash:before': { + content: "'\\f131'", + }, + + '.fa-shield:before': { + content: "'\\f132'", + }, + + '.fa-calendar-o:before': { + content: "'\\f133'", + }, + + '.fa-fire-extinguisher:before': { + content: "'\\f134'", + }, + + '.fa-rocket:before': { + content: "'\\f135'", + }, + + '.fa-maxcdn:before': { + content: "'\\f136'", + }, + + '.fa-chevron-circle-left:before': { + content: "'\\f137'", + }, + + '.fa-chevron-circle-right:before': { + content: "'\\f138'", + }, + + '.fa-chevron-circle-up:before': { + content: "'\\f139'", + }, + + '.fa-chevron-circle-down:before': { + content: "'\\f13a'", + }, + + '.fa-html5:before': { + content: "'\\f13b'", + }, + + '.fa-css3:before': { + content: "'\\f13c'", + }, + + '.fa-anchor:before': { + content: "'\\f13d'", + }, + + '.fa-unlock-alt:before': { + content: "'\\f13e'", + }, + + '.fa-bullseye:before': { + content: "'\\f140'", + }, + + '.fa-ellipsis-h:before': { + content: "'\\f141'", + }, + + '.fa-ellipsis-v:before': { + content: "'\\f142'", + }, + + '.fa-rss-square:before': { + content: "'\\f143'", + }, + + '.fa-play-circle:before': { + content: "'\\f144'", + }, + + '.fa-ticket:before': { + content: "'\\f145'", + }, + + '.fa-minus-square:before': { + content: "'\\f146'", + }, + + '.fa-minus-square-o:before': { + content: "'\\f147'", + }, + + '.fa-level-up:before': { + content: "'\\f148'", + }, + + '.fa-level-down:before': { + content: "'\\f149'", + }, + + '.fa-check-square:before': { + content: "'\\f14a'", + }, + + '.fa-pencil-square:before': { + content: "'\\f14b'", + }, + + '.fa-external-link-square:before': { + content: "'\\f14c'", + }, + + '.fa-share-square:before': { + content: "'\\f14d'", + }, + + '.fa-compass:before': { + content: "'\\f14e'", + }, + + '.fa-toggle-down:before, .fa-caret-square-o-down:before': { + content: "'\\f150'", + }, + + '.fa-toggle-up:before, .fa-caret-square-o-up:before': { + content: "'\\f151'", + }, + + '.fa-toggle-right:before, .fa-caret-square-o-right:before': { + content: "'\\f152'", + }, + + '.fa-euro:before, .fa-eur:before': { + content: "'\\f153'", + }, + + '.fa-gbp:before': { + content: "'\\f154'", + }, + + '.fa-dollar:before, .fa-usd:before': { + content: "'\\f155'", + }, + + '.fa-rupee:before, .fa-inr:before': { + content: "'\\f156'", + }, + + '.fa-cny:before, .fa-rmb:before, .fa-yen:before, .fa-jpy:before': { + content: "'\\f157'", + }, + + '.fa-ruble:before, .fa-rouble:before, .fa-rub:before': { + content: "'\\f158'", + }, + + '.fa-won:before, .fa-krw:before': { + content: "'\\f159'", + }, + + '.fa-bitcoin:before, .fa-btc:before': { + content: "'\\f15a'", + }, + + '.fa-file:before': { + content: "'\\f15b'", + }, + + '.fa-file-text:before': { + content: "'\\f15c'", + }, + + '.fa-sort-alpha-asc:before': { + content: "'\\f15d'", + }, + + '.fa-sort-alpha-desc:before': { + content: "'\\f15e'", + }, + + '.fa-sort-amount-asc:before': { + content: "'\\f160'", + }, + + '.fa-sort-amount-desc:before': { + content: "'\\f161'", + }, + + '.fa-sort-numeric-asc:before': { + content: "'\\f162'", + }, + + '.fa-sort-numeric-desc:before': { + content: "'\\f163'", + }, + + '.fa-thumbs-up:before': { + content: "'\\f164'", + }, + + '.fa-thumbs-down:before': { + content: "'\\f165'", + }, + + '.fa-youtube-square:before': { + content: "'\\f166'", + }, + + '.fa-youtube:before': { + content: "'\\f167'", + }, + + '.fa-xing:before': { + content: "'\\f168'", + }, + + '.fa-xing-square:before': { + content: "'\\f169'", + }, + + '.fa-youtube-play:before': { + content: "'\\f16a'", + }, + + '.fa-dropbox:before': { + content: "'\\f16b'", + }, + + '.fa-stack-overflow:before': { + content: "'\\f16c'", + }, + + '.fa-instagram:before': { + content: "'\\f16d'", + }, + + '.fa-flickr:before': { + content: "'\\f16e'", + }, + + '.fa-adn:before': { + content: "'\\f170'", + }, + + '.fa-bitbucket:before': { + content: "'\\f171'", + }, + + '.fa-bitbucket-square:before': { + content: "'\\f172'", + }, + + '.fa-tumblr:before': { + content: "'\\f173'", + }, + + '.fa-tumblr-square:before': { + content: "'\\f174'", + }, + + '.fa-long-arrow-down:before': { + content: "'\\f175'", + }, + + '.fa-long-arrow-up:before': { + content: "'\\f176'", + }, + + '.fa-long-arrow-left:before': { + content: "'\\f177'", + }, + + '.fa-long-arrow-right:before': { + content: "'\\f178'", + }, + + '.fa-apple:before': { + content: "'\\f179'", + }, + + '.fa-windows:before': { + content: "'\\f17a'", + }, + + '.fa-android:before': { + content: "'\\f17b'", + }, + + '.fa-linux:before': { + content: "'\\f17c'", + }, + + '.fa-dribbble:before': { + content: "'\\f17d'", + }, + + '.fa-skype:before': { + content: "'\\f17e'", + }, + + '.fa-foursquare:before': { + content: "'\\f180'", + }, + + '.fa-trello:before': { + content: "'\\f181'", + }, + + '.fa-female:before': { + content: "'\\f182'", + }, + + '.fa-male:before': { + content: "'\\f183'", + }, + + '.fa-gittip:before, .fa-gratipay:before': { + content: "'\\f184'", + }, + + '.fa-sun-o:before': { + content: "'\\f185'", + }, + + '.fa-moon-o:before': { + content: "'\\f186'", + }, + + '.fa-archive:before': { + content: "'\\f187'", + }, + + '.fa-bug:before': { + content: "'\\f188'", + }, + + '.fa-vk:before': { + content: "'\\f189'", + }, + + '.fa-weibo:before': { + content: "'\\f18a'", + }, + + '.fa-renren:before': { + content: "'\\f18b'", + }, + + '.fa-pagelines:before': { + content: "'\\f18c'", + }, + + '.fa-stack-exchange:before': { + content: "'\\f18d'", + }, + + '.fa-arrow-circle-o-right:before': { + content: "'\\f18e'", + }, + + '.fa-arrow-circle-o-left:before': { + content: "'\\f190'", + }, + + '.fa-toggle-left:before, .fa-caret-square-o-left:before': { + content: "'\\f191'", + }, + + '.fa-dot-circle-o:before': { + content: "'\\f192'", + }, + + '.fa-wheelchair:before': { + content: "'\\f193'", + }, + + '.fa-vimeo-square:before': { + content: "'\\f194'", + }, + + '.fa-turkish-lira:before, .fa-try:before': { + content: "'\\f195'", + }, + + '.fa-plus-square-o:before': { + content: "'\\f196'", + }, + + '.fa-space-shuttle:before': { + content: "'\\f197'", + }, + + '.fa-slack:before': { + content: "'\\f198'", + }, + + '.fa-envelope-square:before': { + content: "'\\f199'", + }, + + '.fa-wordpress:before': { + content: "'\\f19a'", + }, + + '.fa-openid:before': { + content: "'\\f19b'", + }, + + '.fa-institution:before, .fa-bank:before, .fa-university:before': { + content: "'\\f19c'", + }, + + '.fa-mortar-board:before, .fa-graduation-cap:before': { + content: "'\\f19d'", + }, + + '.fa-yahoo:before': { + content: "'\\f19e'", + }, + + '.fa-google:before': { + content: "'\\f1a0'", + }, + + '.fa-reddit:before': { + content: "'\\f1a1'", + }, + + '.fa-reddit-square:before': { + content: "'\\f1a2'", + }, + + '.fa-stumbleupon-circle:before': { + content: "'\\f1a3'", + }, + + '.fa-stumbleupon:before': { + content: "'\\f1a4'", + }, + + '.fa-delicious:before': { + content: "'\\f1a5'", + }, + + '.fa-digg:before': { + content: "'\\f1a6'", + }, + + '.fa-pied-piper-pp:before': { + content: "'\\f1a7'", + }, + + '.fa-pied-piper-alt:before': { + content: "'\\f1a8'", + }, + + '.fa-drupal:before': { + content: "'\\f1a9'", + }, + + '.fa-joomla:before': { + content: "'\\f1aa'", + }, + + '.fa-language:before': { + content: "'\\f1ab'", + }, + + '.fa-fax:before': { + content: "'\\f1ac'", + }, + + '.fa-building:before': { + content: "'\\f1ad'", + }, + + '.fa-child:before': { + content: "'\\f1ae'", + }, + + '.fa-paw:before': { + content: "'\\f1b0'", + }, + + '.fa-spoon:before': { + content: "'\\f1b1'", + }, + + '.fa-cube:before': { + content: "'\\f1b2'", + }, + + '.fa-cubes:before': { + content: "'\\f1b3'", + }, + + '.fa-behance:before': { + content: "'\\f1b4'", + }, + + '.fa-behance-square:before': { + content: "'\\f1b5'", + }, + + '.fa-steam:before': { + content: "'\\f1b6'", + }, + + '.fa-steam-square:before': { + content: "'\\f1b7'", + }, + + '.fa-recycle:before': { + content: "'\\f1b8'", + }, + + '.fa-automobile:before, .fa-car:before': { + content: "'\\f1b9'", + }, + + '.fa-cab:before, .fa-taxi:before': { + content: "'\\f1ba'", + }, + + '.fa-tree:before': { + content: "'\\f1bb'", + }, + + '.fa-spotify:before': { + content: "'\\f1bc'", + }, + + '.fa-deviantart:before': { + content: "'\\f1bd'", + }, + + '.fa-soundcloud:before': { + content: "'\\f1be'", + }, + + '.fa-database:before': { + content: "'\\f1c0'", + }, + + '.fa-file-pdf-o:before': { + content: "'\\f1c1'", + }, + + '.fa-file-word-o:before': { + content: "'\\f1c2'", + }, + + '.fa-file-excel-o:before': { + content: "'\\f1c3'", + }, + + '.fa-file-powerpoint-o:before': { + content: "'\\f1c4'", + }, + + '.fa-file-photo-o:before, .fa-file-picture-o:before, .fa-file-image-o:before': { + content: "'\\f1c5'", + }, + + '.fa-file-zip-o:before, .fa-file-archive-o:before': { + content: "'\\f1c6'", + }, + + '.fa-file-sound-o:before, .fa-file-audio-o:before': { + content: "'\\f1c7'", + }, + + '.fa-file-movie-o:before, .fa-file-video-o:before': { + content: "'\\f1c8'", + }, + + '.fa-file-code-o:before': { + content: "'\\f1c9'", + }, + + '.fa-vine:before': { + content: "'\\f1ca'", + }, + + '.fa-codepen:before': { + content: "'\\f1cb'", + }, + + '.fa-jsfiddle:before': { + content: "'\\f1cc'", + }, + + '.fa-life-bouy:before, .fa-life-buoy:before, .fa-life-saver:before, .fa-support:before, .fa-life-ring:before': { + content: "'\\f1cd'", + }, + + '.fa-circle-o-notch:before': { + content: "'\\f1ce'", + }, + + '.fa-ra:before, .fa-resistance:before, .fa-rebel:before': { + content: "'\\f1d0'", + }, + + '.fa-ge:before, .fa-empire:before': { + content: "'\\f1d1'", + }, + + '.fa-git-square:before': { + content: "'\\f1d2'", + }, + + '.fa-git:before': { + content: "'\\f1d3'", + }, + + '.fa-y-combinator-square:before, .fa-yc-square:before, .fa-hacker-news:before': { + content: "'\\f1d4'", + }, + + '.fa-tencent-weibo:before': { + content: "'\\f1d5'", + }, + + '.fa-qq:before': { + content: "'\\f1d6'", + }, + + '.fa-wechat:before, .fa-weixin:before': { + content: "'\\f1d7'", + }, + + '.fa-send:before, .fa-paper-plane:before': { + content: "'\\f1d8'", + }, + + '.fa-send-o:before, .fa-paper-plane-o:before': { + content: "'\\f1d9'", + }, + + '.fa-history:before': { + content: "'\\f1da'", + }, + + '.fa-circle-thin:before': { + content: "'\\f1db'", + }, + + '.fa-header:before': { + content: "'\\f1dc'", + }, + + '.fa-paragraph:before': { + content: "'\\f1dd'", + }, + + '.fa-sliders:before': { + content: "'\\f1de'", + }, + + '.fa-share-alt:before': { + content: "'\\f1e0'", + }, + + '.fa-share-alt-square:before': { + content: "'\\f1e1'", + }, + + '.fa-bomb:before': { + content: "'\\f1e2'", + }, + + '.fa-soccer-ball-o:before, .fa-futbol-o:before': { + content: "'\\f1e3'", + }, + + '.fa-tty:before': { + content: "'\\f1e4'", + }, + + '.fa-binoculars:before': { + content: "'\\f1e5'", + }, + + '.fa-plug:before': { + content: "'\\f1e6'", + }, + + '.fa-slideshare:before': { + content: "'\\f1e7'", + }, + + '.fa-twitch:before': { + content: "'\\f1e8'", + }, + + '.fa-yelp:before': { + content: "'\\f1e9'", + }, + + '.fa-newspaper-o:before': { + content: "'\\f1ea'", + }, + + '.fa-wifi:before': { + content: "'\\f1eb'", + }, + + '.fa-calculator:before': { + content: "'\\f1ec'", + }, + + '.fa-paypal:before': { + content: "'\\f1ed'", + }, + + '.fa-google-wallet:before': { + content: "'\\f1ee'", + }, + + '.fa-cc-visa:before': { + content: "'\\f1f0'", + }, + + '.fa-cc-mastercard:before': { + content: "'\\f1f1'", + }, + + '.fa-cc-discover:before': { + content: "'\\f1f2'", + }, + + '.fa-cc-amex:before': { + content: "'\\f1f3'", + }, + + '.fa-cc-paypal:before': { + content: "'\\f1f4'", + }, + + '.fa-cc-stripe:before': { + content: "'\\f1f5'", + }, + + '.fa-bell-slash:before': { + content: "'\\f1f6'", + }, + + '.fa-bell-slash-o:before': { + content: "'\\f1f7'", + }, + + '.fa-trash:before': { + content: "'\\f1f8'", + }, + + '.fa-copyright:before': { + content: "'\\f1f9'", + }, + + '.fa-at:before': { + content: "'\\f1fa'", + }, + + '.fa-eyedropper:before': { + content: "'\\f1fb'", + }, + + '.fa-paint-brush:before': { + content: "'\\f1fc'", + }, + + '.fa-birthday-cake:before': { + content: "'\\f1fd'", + }, + + '.fa-area-chart:before': { + content: "'\\f1fe'", + }, + + '.fa-pie-chart:before': { + content: "'\\f200'", + }, + + '.fa-line-chart:before': { + content: "'\\f201'", + }, + + '.fa-lastfm:before': { + content: "'\\f202'", + }, + + '.fa-lastfm-square:before': { + content: "'\\f203'", + }, + + '.fa-toggle-off:before': { + content: "'\\f204'", + }, + + '.fa-toggle-on:before': { + content: "'\\f205'", + }, + + '.fa-bicycle:before': { + content: "'\\f206'", + }, + + '.fa-bus:before': { + content: "'\\f207'", + }, + + '.fa-ioxhost:before': { + content: "'\\f208'", + }, + + '.fa-angellist:before': { + content: "'\\f209'", + }, + + '.fa-cc:before': { + content: "'\\f20a'", + }, + + '.fa-shekel:before, .fa-sheqel:before, .fa-ils:before': { + content: "'\\f20b'", + }, + + '.fa-meanpath:before': { + content: "'\\f20c'", + }, + + '.fa-buysellads:before': { + content: "'\\f20d'", + }, + + '.fa-connectdevelop:before': { + content: "'\\f20e'", + }, + + '.fa-dashcube:before': { + content: "'\\f210'", + }, + + '.fa-forumbee:before': { + content: "'\\f211'", + }, + + '.fa-leanpub:before': { + content: "'\\f212'", + }, + + '.fa-sellsy:before': { + content: "'\\f213'", + }, + + '.fa-shirtsinbulk:before': { + content: "'\\f214'", + }, + + '.fa-simplybuilt:before': { + content: "'\\f215'", + }, + + '.fa-skyatlas:before': { + content: "'\\f216'", + }, + + '.fa-cart-plus:before': { + content: "'\\f217'", + }, + + '.fa-cart-arrow-down:before': { + content: "'\\f218'", + }, + + '.fa-diamond:before': { + content: "'\\f219'", + }, + + '.fa-ship:before': { + content: "'\\f21a'", + }, + + '.fa-user-secret:before': { + content: "'\\f21b'", + }, + + '.fa-motorcycle:before': { + content: "'\\f21c'", + }, + + '.fa-street-view:before': { + content: "'\\f21d'", + }, + + '.fa-heartbeat:before': { + content: "'\\f21e'", + }, + + '.fa-venus:before': { + content: "'\\f221'", + }, + + '.fa-mars:before': { + content: "'\\f222'", + }, + + '.fa-mercury:before': { + content: "'\\f223'", + }, + + '.fa-intersex:before, .fa-transgender:before': { + content: "'\\f224'", + }, + + '.fa-transgender-alt:before': { + content: "'\\f225'", + }, + + '.fa-venus-double:before': { + content: "'\\f226'", + }, + + '.fa-mars-double:before': { + content: "'\\f227'", + }, + + '.fa-venus-mars:before': { + content: "'\\f228'", + }, + + '.fa-mars-stroke:before': { + content: "'\\f229'", + }, + + '.fa-mars-stroke-v:before': { + content: "'\\f22a'", + }, + + '.fa-mars-stroke-h:before': { + content: "'\\f22b'", + }, + + '.fa-neuter:before': { + content: "'\\f22c'", + }, + + '.fa-genderless:before': { + content: "'\\f22d'", + }, + + '.fa-facebook-official:before': { + content: "'\\f230'", + }, + + '.fa-pinterest-p:before': { + content: "'\\f231'", + }, + + '.fa-whatsapp:before': { + content: "'\\f232'", + }, + + '.fa-server:before': { + content: "'\\f233'", + }, + + '.fa-user-plus:before': { + content: "'\\f234'", + }, + + '.fa-user-times:before': { + content: "'\\f235'", + }, + + '.fa-hotel:before, .fa-bed:before': { + content: "'\\f236'", + }, + + '.fa-viacoin:before': { + content: "'\\f237'", + }, + + '.fa-train:before': { + content: "'\\f238'", + }, + + '.fa-subway:before': { + content: "'\\f239'", + }, + + '.fa-medium:before': { + content: "'\\f23a'", + }, + + '.fa-yc:before, .fa-y-combinator:before': { + content: "'\\f23b'", + }, + + '.fa-optin-monster:before': { + content: "'\\f23c'", + }, + + '.fa-opencart:before': { + content: "'\\f23d'", + }, + + '.fa-expeditedssl:before': { + content: "'\\f23e'", + }, + + '.fa-battery-4:before, .fa-battery:before, .fa-battery-full:before': { + content: "'\\f240'", + }, + + '.fa-battery-3:before, .fa-battery-three-quarters:before': { + content: "'\\f241'", + }, + + '.fa-battery-2:before, .fa-battery-half:before': { + content: "'\\f242'", + }, + + '.fa-battery-1:before, .fa-battery-quarter:before': { + content: "'\\f243'", + }, + + '.fa-battery-0:before, .fa-battery-empty:before': { + content: "'\\f244'", + }, + + '.fa-mouse-pointer:before': { + content: "'\\f245'", + }, + + '.fa-i-cursor:before': { + content: "'\\f246'", + }, + + '.fa-object-group:before': { + content: "'\\f247'", + }, + + '.fa-object-ungroup:before': { + content: "'\\f248'", + }, + + '.fa-sticky-note:before': { + content: "'\\f249'", + }, + + '.fa-sticky-note-o:before': { + content: "'\\f24a'", + }, + + '.fa-cc-jcb:before': { + content: "'\\f24b'", + }, + + '.fa-cc-diners-club:before': { + content: "'\\f24c'", + }, + + '.fa-clone:before': { + content: "'\\f24d'", + }, + + '.fa-balance-scale:before': { + content: "'\\f24e'", + }, + + '.fa-hourglass-o:before': { + content: "'\\f250'", + }, + + '.fa-hourglass-1:before, .fa-hourglass-start:before': { + content: "'\\f251'", + }, + + '.fa-hourglass-2:before, .fa-hourglass-half:before': { + content: "'\\f252'", + }, + + '.fa-hourglass-3:before, .fa-hourglass-end:before': { + content: "'\\f253'", + }, + + '.fa-hourglass:before': { + content: "'\\f254'", + }, + + '.fa-hand-grab-o:before, .fa-hand-rock-o:before': { + content: "'\\f255'", + }, + + '.fa-hand-stop-o:before, .fa-hand-paper-o:before': { + content: "'\\f256'", + }, + + '.fa-hand-scissors-o:before': { + content: "'\\f257'", + }, + + '.fa-hand-lizard-o:before': { + content: "'\\f258'", + }, + + '.fa-hand-spock-o:before': { + content: "'\\f259'", + }, + + '.fa-hand-pointer-o:before': { + content: "'\\f25a'", + }, + + '.fa-hand-peace-o:before': { + content: "'\\f25b'", + }, + + '.fa-trademark:before': { + content: "'\\f25c'", + }, + + '.fa-registered:before': { + content: "'\\f25d'", + }, + + '.fa-creative-commons:before': { + content: "'\\f25e'", + }, + + '.fa-gg:before': { + content: "'\\f260'", + }, + + '.fa-gg-circle:before': { + content: "'\\f261'", + }, + + '.fa-tripadvisor:before': { + content: "'\\f262'", + }, + + '.fa-odnoklassniki:before': { + content: "'\\f263'", + }, + + '.fa-odnoklassniki-square:before': { + content: "'\\f264'", + }, + + '.fa-get-pocket:before': { + content: "'\\f265'", + }, + + '.fa-wikipedia-w:before': { + content: "'\\f266'", + }, + + '.fa-safari:before': { + content: "'\\f267'", + }, + + '.fa-chrome:before': { + content: "'\\f268'", + }, + + '.fa-firefox:before': { + content: "'\\f269'", + }, + + '.fa-opera:before': { + content: "'\\f26a'", + }, + + '.fa-internet-explorer:before': { + content: "'\\f26b'", + }, + + '.fa-tv:before, .fa-television:before': { + content: "'\\f26c'", + }, + + '.fa-contao:before': { + content: "'\\f26d'", + }, + + '.fa-500px:before': { + content: "'\\f26e'", + }, + + '.fa-amazon:before': { + content: "'\\f270'", + }, + + '.fa-calendar-plus-o:before': { + content: "'\\f271'", + }, + + '.fa-calendar-minus-o:before': { + content: "'\\f272'", + }, + + '.fa-calendar-times-o:before': { + content: "'\\f273'", + }, + + '.fa-calendar-check-o:before': { + content: "'\\f274'", + }, + + '.fa-industry:before': { + content: "'\\f275'", + }, + + '.fa-map-pin:before': { + content: "'\\f276'", + }, + + '.fa-map-signs:before': { + content: "'\\f277'", + }, + + '.fa-map-o:before': { + content: "'\\f278'", + }, + + '.fa-map:before': { + content: "'\\f279'", + }, + + '.fa-commenting:before': { + content: "'\\f27a'", + }, + + '.fa-commenting-o:before': { + content: "'\\f27b'", + }, + + '.fa-houzz:before': { + content: "'\\f27c'", + }, + + '.fa-vimeo:before': { + content: "'\\f27d'", + }, + + '.fa-black-tie:before': { + content: "'\\f27e'", + }, + + '.fa-fonticons:before': { + content: "'\\f280'", + }, + + '.fa-reddit-alien:before': { + content: "'\\f281'", + }, + + '.fa-edge:before': { + content: "'\\f282'", + }, + + '.fa-credit-card-alt:before': { + content: "'\\f283'", + }, + + '.fa-codiepie:before': { + content: "'\\f284'", + }, + + '.fa-modx:before': { + content: "'\\f285'", + }, + + '.fa-fort-awesome:before': { + content: "'\\f286'", + }, + + '.fa-usb:before': { + content: "'\\f287'", + }, + + '.fa-product-hunt:before': { + content: "'\\f288'", + }, + + '.fa-mixcloud:before': { + content: "'\\f289'", + }, + + '.fa-scribd:before': { + content: "'\\f28a'", + }, + + '.fa-pause-circle:before': { + content: "'\\f28b'", + }, + + '.fa-pause-circle-o:before': { + content: "'\\f28c'", + }, + + '.fa-stop-circle:before': { + content: "'\\f28d'", + }, + + '.fa-stop-circle-o:before': { + content: "'\\f28e'", + }, + + '.fa-shopping-bag:before': { + content: "'\\f290'", + }, + + '.fa-shopping-basket:before': { + content: "'\\f291'", + }, + + '.fa-hashtag:before': { + content: "'\\f292'", + }, + + '.fa-bluetooth:before': { + content: "'\\f293'", + }, + + '.fa-bluetooth-b:before': { + content: "'\\f294'", + }, + + '.fa-percent:before': { + content: "'\\f295'", + }, + + '.fa-gitlab:before': { + content: "'\\f296'", + }, + + '.fa-wpbeginner:before': { + content: "'\\f297'", + }, + + '.fa-wpforms:before': { + content: "'\\f298'", + }, + + '.fa-envira:before': { + content: "'\\f299'", + }, + + '.fa-universal-access:before': { + content: "'\\f29a'", + }, + + '.fa-wheelchair-alt:before': { + content: "'\\f29b'", + }, + + '.fa-question-circle-o:before': { + content: "'\\f29c'", + }, + + '.fa-blind:before': { + content: "'\\f29d'", + }, + + '.fa-audio-description:before': { + content: "'\\f29e'", + }, + + '.fa-volume-control-phone:before': { + content: "'\\f2a0'", + }, + + '.fa-braille:before': { + content: "'\\f2a1'", + }, + + '.fa-assistive-listening-systems:before': { + content: "'\\f2a2'", + }, + + '.fa-asl-interpreting:before, .fa-american-sign-language-interpreting:before': { + content: "'\\f2a3'", + }, + + '.fa-deafness:before, .fa-hard-of-hearing:before, .fa-deaf:before': { + content: "'\\f2a4'", + }, + + '.fa-glide:before': { + content: "'\\f2a5'", + }, + + '.fa-glide-g:before': { + content: "'\\f2a6'", + }, + + '.fa-signing:before, .fa-sign-language:before': { + content: "'\\f2a7'", + }, + + '.fa-low-vision:before': { + content: "'\\f2a8'", + }, + + '.fa-viadeo:before': { + content: "'\\f2a9'", + }, + + '.fa-viadeo-square:before': { + content: "'\\f2aa'", + }, + + '.fa-snapchat:before': { + content: "'\\f2ab'", + }, + + '.fa-snapchat-ghost:before': { + content: "'\\f2ac'", + }, + + '.fa-snapchat-square:before': { + content: "'\\f2ad'", + }, + + '.fa-pied-piper:before': { + content: "'\\f2ae'", + }, + + '.fa-first-order:before': { + content: "'\\f2b0'", + }, + + '.fa-yoast:before': { + content: "'\\f2b1'", + }, + + '.fa-themeisle:before': { + content: "'\\f2b2'", + }, + + '.fa-google-plus-circle:before, .fa-google-plus-official:before': { + content: "'\\f2b3'", + }, + + '.fa-fa:before, .fa-font-awesome:before': { + content: "'\\f2b4'", + }, + + '.fa-handshake-o:before': { + content: "'\\f2b5'", + }, + + '.fa-envelope-open:before': { + content: "'\\f2b6'", + }, + + '.fa-envelope-open-o:before': { + content: "'\\f2b7'", + }, + + '.fa-linode:before': { + content: "'\\f2b8'", + }, + + '.fa-address-book:before': { + content: "'\\f2b9'", + }, + + '.fa-address-book-o:before': { + content: "'\\f2ba'", + }, + + '.fa-vcard:before, .fa-address-card:before': { + content: "'\\f2bb'", + }, + + '.fa-vcard-o:before, .fa-address-card-o:before': { + content: "'\\f2bc'", + }, + + '.fa-user-circle:before': { + content: "'\\f2bd'", + }, + + '.fa-user-circle-o:before': { + content: "'\\f2be'", + }, + + '.fa-user-o:before': { + content: "'\\f2c0'", + }, + + '.fa-id-badge:before': { + content: "'\\f2c1'", + }, + + '.fa-drivers-license:before, .fa-id-card:before': { + content: "'\\f2c2'", + }, + + '.fa-drivers-license-o:before, .fa-id-card-o:before': { + content: "'\\f2c3'", + }, + + '.fa-quora:before': { + content: "'\\f2c4'", + }, + + '.fa-free-code-camp:before': { + content: "'\\f2c5'", + }, + + '.fa-telegram:before': { + content: "'\\f2c6'", + }, + + '.fa-thermometer-4:before, .fa-thermometer:before, .fa-thermometer-full:before': { + content: "'\\f2c7'", + }, + + '.fa-thermometer-3:before, .fa-thermometer-three-quarters:before': { + content: "'\\f2c8'", + }, + + '.fa-thermometer-2:before, .fa-thermometer-half:before': { + content: "'\\f2c9'", + }, + + '.fa-thermometer-1:before, .fa-thermometer-quarter:before': { + content: "'\\f2ca'", + }, + + '.fa-thermometer-0:before, .fa-thermometer-empty:before': { + content: "'\\f2cb'", + }, + + '.fa-shower:before': { + content: "'\\f2cc'", + }, + + '.fa-bathtub:before, .fa-s15:before, .fa-bath:before': { + content: "'\\f2cd'", + }, + + '.fa-podcast:before': { + content: "'\\f2ce'", + }, + + '.fa-window-maximize:before': { + content: "'\\f2d0'", + }, + + '.fa-window-minimize:before': { + content: "'\\f2d1'", + }, + + '.fa-window-restore:before': { + content: "'\\f2d2'", + }, + + '.fa-times-rectangle:before, .fa-window-close:before': { + content: "'\\f2d3'", + }, + + '.fa-times-rectangle-o:before, .fa-window-close-o:before': { + content: "'\\f2d4'", + }, + + '.fa-bandcamp:before': { + content: "'\\f2d5'", + }, + + '.fa-grav:before': { + content: "'\\f2d6'", + }, + + '.fa-etsy:before': { + content: "'\\f2d7'", + }, + + '.fa-imdb:before': { + content: "'\\f2d8'", + }, + + '.fa-ravelry:before': { + content: "'\\f2d9'", + }, + + '.fa-eercast:before': { + content: "'\\f2da'", + }, + + '.fa-microchip:before': { + content: "'\\f2db'", + }, + + '.fa-snowflake-o:before': { + content: "'\\f2dc'", + }, + + '.fa-superpowers:before': { + content: "'\\f2dd'", + }, + + '.fa-wpexplorer:before': { + content: "'\\f2de'", + }, + + '.fa-meetup:before': { + content: "'\\f2e0'", + }, + }, + ]); +} diff --git a/packages/grafana-ui/src/themes/GlobalStyles/fonts.ts b/packages/grafana-ui/src/themes/GlobalStyles/fonts.ts index ca6d548a58e..56c5f66fd06 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/fonts.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/fonts.ts @@ -3,6 +3,9 @@ import { css } from '@emotion/react'; import { GrafanaTheme2 } from '@grafana/data'; export function getFontStyles(theme: GrafanaTheme2) { + const grafanaPublicPath = typeof window !== 'undefined' && window.__grafana_public_path__; + const fontRoot = grafanaPublicPath ? `${grafanaPublicPath}fonts/` : 'public/fonts/'; + return css([ { /* latin */ @@ -11,7 +14,7 @@ export function getFontStyles(theme: GrafanaTheme2) { fontStyle: 'normal', fontWeight: 400, fontDisplay: 'swap', - src: "url('./public/fonts/roboto/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2') format('woff2')", + src: `url('${fontRoot}roboto/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2') format('woff2')`, unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD', }, @@ -23,7 +26,7 @@ export function getFontStyles(theme: GrafanaTheme2) { fontStyle: 'normal', fontWeight: 500, fontDisplay: 'swap', - src: "url('./public/fonts/roboto/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2') format('woff2')", + src: `url('${fontRoot}roboto/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2') format('woff2')`, unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD', }, @@ -42,7 +45,7 @@ export function getFontStyles(theme: GrafanaTheme2) { fontStyle: 'normal', fontWeight: 400, fontDisplay: 'swap', - src: "url('./public/fonts/inter/Inter-Regular.woff2') format('woff2')", + src: `url('${fontRoot}inter/Inter-Regular.woff2') format('woff2')`, }, }, { @@ -51,7 +54,7 @@ export function getFontStyles(theme: GrafanaTheme2) { fontStyle: 'normal', fontWeight: 500, fontDisplay: 'swap', - src: "url('./public/fonts/inter/Inter-Medium.woff2') format('woff2')", + src: `url('${fontRoot}inter/Inter-Medium.woff2') format('woff2')`, }, }, ]); diff --git a/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts b/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts new file mode 100644 index 00000000000..24d2d3eea07 --- /dev/null +++ b/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts @@ -0,0 +1,125 @@ +import { css } from '@emotion/react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export function getJsonFormatterStyles(theme: GrafanaTheme2) { + return css({ + '.json-formatter-row': { + fontFamily: 'monospace', + + '&, a, a:hover': { + color: theme.colors.text.primary, + textDecoration: 'none', + }, + + '.json-formatter-row': { + marginLeft: theme.spacing(2), + }, + + '.json-formatter-children': { + '&.json-formatter-empty': { + opacity: 0.5, + marginLeft: theme.spacing(2), + + '&::after': { + display: 'none', + }, + '&.json-formatter-object::after': { + content: "'No properties'", + }, + '&.json-formatter-array::after': { + content: "'[]'", + }, + }, + }, + + '.json-formatter-string': { + color: theme.isDark ? '#23d662' : 'green', + whiteSpace: 'pre-wrap', + wordWrap: 'break-word', + wordBreak: 'break-all', + }, + + '.json-formatter-number': { + color: theme.isDark ? theme.colors.primary.text : theme.colors.primary.main, + }, + '.json-formatter-boolean': { + color: theme.isDark ? theme.colors.primary.text : theme.colors.error.main, + }, + '.json-formatter-null': { + color: theme.isDark ? '#eec97d' : '#855a00', + }, + '.json-formatter-undefined': { + color: theme.isDark ? 'rgb(239, 143, 190)' : 'rgb(202, 11, 105)', + }, + '.json-formatter-function': { + color: theme.isDark ? '#fd48cb' : '#ff20ed', + }, + '.json-formatter-url': { + textDecoration: 'underline', + color: theme.isDark ? '#027bff' : theme.colors.primary.main, + cursor: 'pointer', + }, + + '.json-formatter-bracket': { + color: theme.isDark ? '#9494ff' : theme.colors.primary.main, + }, + '.json-formatter-key': { + color: theme.isDark ? '#23a0db' : '#00008b', + cursor: 'pointer', + paddingRight: theme.spacing(0.25), + marginRight: theme.spacing(0.5), + }, + + '.json-formatter-constructor-name': { + cursor: 'pointer', + }, + + '.json-formatter-array-comma': { + marginRight: theme.spacing(0.5), + }, + + '.json-formatter-toggler': { + lineHeight: '16px', + fontSize: theme.typography.size.xs, + verticalAlign: 'middle', + opacity: 0.6, + cursor: 'pointer', + paddingRight: theme.spacing(0.25), + + '&::after': { + display: 'inline-block', + transition: 'transform 100ms ease-in', + content: "'►'", + }, + }, + + // Inline preview on hover (optional) + '> a > .json-formatter-preview-text': { + opacity: 0, + transition: 'opacity 0.15s ease-in', + fontStyle: 'italic', + }, + + '&:hover > a > .json-formatter-preview-text': { + opacity: 0.6, + }, + + // Open state + '&.json-formatter-open': { + '> .json-formatter-toggler-link .json-formatter-toggler::after': { + transform: 'rotate(90deg)', + }, + '> .json-formatter-children::after': { + display: 'inline-block', + }, + '> a > .json-formatter-preview-text': { + display: 'none', + }, + '&.json-formatter-empty::after': { + display: 'block', + }, + }, + }, + }); +} diff --git a/packages/grafana-ui/src/themes/GlobalStyles/slate.ts b/packages/grafana-ui/src/themes/GlobalStyles/slate.ts new file mode 100644 index 00000000000..e8fd858d456 --- /dev/null +++ b/packages/grafana-ui/src/themes/GlobalStyles/slate.ts @@ -0,0 +1,145 @@ +import { css } from '@emotion/react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export function getSlateStyles(theme: GrafanaTheme2) { + return css({ + '.slate-query-field': { + fontSize: theme.typography.fontSize, + fontFamily: theme.typography.fontFamilyMonospace, + height: 'auto', + wordBreak: 'break-word', + // Affects only placeholder in query field. Adds scrollbar only if content is cropped. + overflow: 'auto', + }, + + '.slate-query-field__wrapper': { + position: 'relative', + display: 'inline-block', + padding: '6px 8px', + minHeight: '32px', + width: '100%', + color: theme.colors.text.primary, + backgroundColor: theme.components.input.background, + backgroundImage: 'none', + border: `1px solid ${theme.components.input.borderColor}`, + borderRadius: theme.shape.radius.default, + transition: 'all 0.3s', + lineHeight: '18px', + }, + + '.slate-query-field__wrapper--disabled': { + backgroundColor: 'inherit', + cursor: 'not-allowed', + }, + + '.slate-typeahead': { + '.typeahead': { + position: 'relative', + zIndex: theme.zIndex.typeahead, + borderRadius: theme.shape.radius.default, + border: `1px solid ${theme.components.panel.borderColor}`, + maxHeight: '66vh', + overflowY: 'scroll', + overflowX: 'hidden', + outline: 'none', + listStyle: 'none', + background: theme.components.panel.background, + color: theme.colors.text.primary, + boxShadow: theme.shadows.z2, + }, + + '.typeahead-group__title': { + color: theme.colors.text.secondary, + fontSize: theme.typography.size.sm, + lineHeight: theme.typography.body.lineHeight, + padding: theme.spacing(1), + }, + + '.typeahead-item': { + height: 'auto', + fontFamily: theme.typography.fontFamilyMonospace, + padding: theme.spacing(1, 1, 1, 2), + fontSize: theme.typography.size.sm, + textOverflow: 'ellipsis', + overflow: 'hidden', + zIndex: 1, + display: 'block', + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: + 'color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1)', + }, + + '.typeahead-item__selected': { + backgroundColor: theme.isDark ? theme.v1.palette.dark9 : theme.v1.palette.gray6, + + '.typeahead-item-hint': { + fontSize: theme.typography.size.xs, + color: theme.colors.text.primary, + whiteSpace: 'normal', + }, + }, + + '.typeahead-match': { + color: theme.v1.palette.yellow, + borderBottom: `1px solid ${theme.v1.palette.yellow}`, + // Undoing mark styling + padding: 'inherit', + background: 'inherit', + }, + }, + + /* SYNTAX */ + + '.slate-query-field, .prism-syntax-highlight': { + '.token.comment, .token.block-comment, .token.prolog, .token.doctype, .token.cdata': { + color: theme.colors.text.secondary, + }, + + '.token.variable, .token.entity': { + color: theme.colors.text.primary, + }, + + '.token.property, .token.tag, .token.constant, .token.symbol, .token.deleted': { + color: theme.colors.error.text, + }, + + '.token.attr-value, .token.selector, .token.string, .token.char, .token.builtin, .token.inserted': { + color: theme.colors.success.text, + }, + + '.token.boolean, .token.number, .token.operator, .token.url': { + color: '#fe85fc', + }, + + '.token.function, .token.attr-name, .token.function-name, .token.atrule, .token.keyword, .token.class-name': { + color: theme.colors.primary.text, + }, + + '.token.punctuation, .token.regex, .token.important': { + color: theme.v1.palette.orange, + }, + + '.token.important': { + fontWeight: 'normal', + }, + + '.token.bold': { + fontWeight: 'bold', + }, + + '.token.italic': { + fontStyle: 'italic', + }, + + '.token.entity': { + cursor: 'help', + }, + + '.namespace': { + opacity: 0.7, + }, + }, + }); +} diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 4e8acb6a3f3..544551fddda 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -8,8 +8,8 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/identity" dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -28,13 +28,13 @@ func (hs *HTTPServer) getCreatedSnapshotHandler() web.Handler { if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesSnapshots) { namespaceMapper := request.GetNamespaceMapper(hs.Cfg) return func(w http.ResponseWriter, r *http.Request) { - user, err := appcontext.User(r.Context()) + user, err := identity.GetRequester(r.Context()) if err != nil || user == nil { errhttp.Write(r.Context(), fmt.Errorf("no user"), w) return } r.URL.Path = "/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/" + - namespaceMapper(user.OrgID) + "/dashboardsnapshots/create" + namespaceMapper(user.GetOrgID()) + "/dashboardsnapshots/create" hs.clientConfigProvider.DirectlyServeHTTP(w, r) } } diff --git a/pkg/api/ds_query.go b/pkg/api/ds_query.go index 77d02dc63fa..65f58ee81be 100644 --- a/pkg/api/ds_query.go +++ b/pkg/api/ds_query.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/middleware/requestmeta" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -43,12 +43,12 @@ func (hs *HTTPServer) getDSQueryEndpoint() web.Handler { // rewrite requests from /ds/query to the new query service namespaceMapper := request.GetNamespaceMapper(hs.Cfg) return func(w http.ResponseWriter, r *http.Request) { - user, err := appcontext.User(r.Context()) + user, err := identity.GetRequester(r.Context()) if err != nil || user == nil { errhttp.Write(r.Context(), fmt.Errorf("no user"), w) return } - r.URL.Path = "/apis/query.grafana.app/v0alpha1/namespaces/" + namespaceMapper(user.OrgID) + "/query" + r.URL.Path = "/apis/query.grafana.app/v0alpha1/namespaces/" + namespaceMapper(user.GetOrgID()) + "/query" hs.clientConfigProvider.DirectlyServeHTTP(w, r) } } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 8d9ccdbdbe1..d92476dc940 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -373,13 +373,18 @@ func (proxy *DataSourceProxy) logRequest() { panelPluginId := proxy.ctx.Req.Header.Get("X-Panel-Plugin-Id") + uri, err := util.SanitizeURI(proxy.ctx.Req.RequestURI) + if err == nil { + proxy.ctx.Logger.Error("Could not sanitize RequestURI", "error", err) + } + ctxLogger := logger.FromContext(proxy.ctx.Req.Context()) ctxLogger.Info("Proxying incoming request", "userid", proxy.ctx.UserID, "orgid", proxy.ctx.OrgID, "username", proxy.ctx.Login, "datasource", proxy.ds.Type, - "uri", proxy.ctx.Req.RequestURI, + "uri", uri, "method", proxy.ctx.Req.Method, "panelPluginId", panelPluginId, "body", body) diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index ebc90171781..fa04df432c7 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -25,7 +25,7 @@ const AnnoKeySlug = "grafana.app/slug" const AnnoKeyOriginName = "grafana.app/originName" const AnnoKeyOriginPath = "grafana.app/originPath" -const AnnoKeyOriginKey = "grafana.app/originKey" +const AnnoKeyOriginHash = "grafana.app/originHash" const AnnoKeyOriginTimestamp = "grafana.app/originTimestamp" // ResourceOriginInfo is saved in annotations. This is used to identify where the resource came from @@ -37,8 +37,8 @@ type ResourceOriginInfo struct { // The path within the named origin above (external_id in the existing dashboard provisioing) Path string `json:"path,omitempty"` - // Verification/identification key (check_sum in existing dashboard provisioning) - Key string `json:"key,omitempty"` + // Verification/identification hash (check_sum in existing dashboard provisioning) + Hash string `json:"hash,omitempty"` // Origin modification timestamp when the resource was saved // This will be before the resource updated time @@ -77,7 +77,7 @@ type GrafanaMetaAccessor interface { SetOriginInfo(info *ResourceOriginInfo) GetOriginName() string GetOriginPath() string - GetOriginKey() string + GetOriginHash() string GetOriginTimestamp() (*time.Time, error) // Find a title in the object @@ -219,15 +219,15 @@ func (m *grafanaMetaAccessor) SetOriginInfo(info *ResourceOriginInfo) { delete(anno, AnnoKeyOriginName) delete(anno, AnnoKeyOriginPath) - delete(anno, AnnoKeyOriginKey) + delete(anno, AnnoKeyOriginHash) delete(anno, AnnoKeyOriginTimestamp) if info != nil && info.Name != "" { anno[AnnoKeyOriginName] = info.Name if info.Path != "" { anno[AnnoKeyOriginPath] = info.Path } - if info.Key != "" { - anno[AnnoKeyOriginKey] = info.Key + if info.Hash != "" { + anno[AnnoKeyOriginHash] = info.Hash } if info.Timestamp != nil { anno[AnnoKeyOriginTimestamp] = info.Timestamp.UTC().Format(time.RFC3339) @@ -245,7 +245,7 @@ func (m *grafanaMetaAccessor) GetOriginInfo() (*ResourceOriginInfo, error) { return &ResourceOriginInfo{ Name: v, Path: m.GetOriginPath(), - Key: m.GetOriginKey(), + Hash: m.GetOriginHash(), Timestamp: t, }, err } @@ -258,8 +258,8 @@ func (m *grafanaMetaAccessor) GetOriginPath() string { return m.get(AnnoKeyOriginPath) } -func (m *grafanaMetaAccessor) GetOriginKey() string { - return m.get(AnnoKeyOriginKey) +func (m *grafanaMetaAccessor) GetOriginHash() string { + return m.get(AnnoKeyOriginHash) } func (m *grafanaMetaAccessor) GetOriginTimestamp() (*time.Time, error) { diff --git a/pkg/apimachinery/utils/meta_test.go b/pkg/apimachinery/utils/meta_test.go index af8c0ed2c48..0e0b81db7f0 100644 --- a/pkg/apimachinery/utils/meta_test.go +++ b/pkg/apimachinery/utils/meta_test.go @@ -126,7 +126,7 @@ func TestMetaAccessor(t *testing.T) { originInfo := &utils.ResourceOriginInfo{ Name: "test", Path: "a/b/c", - Key: "kkk", + Hash: "kkk", } t.Run("fails for non resource objects", func(t *testing.T) { @@ -158,7 +158,7 @@ func TestMetaAccessor(t *testing.T) { require.Equal(t, map[string]string{ "grafana.app/originName": "test", "grafana.app/originPath": "a/b/c", - "grafana.app/originKey": "kkk", + "grafana.app/originHash": "kkk", "grafana.app/folder": "folderUID", }, res.GetAnnotations()) @@ -192,7 +192,7 @@ func TestMetaAccessor(t *testing.T) { require.Equal(t, map[string]string{ "grafana.app/originName": "test", "grafana.app/originPath": "a/b/c", - "grafana.app/originKey": "kkk", + "grafana.app/originHash": "kkk", "grafana.app/folder": "folderUID", }, obj.GetAnnotations()) @@ -215,7 +215,7 @@ func TestMetaAccessor(t *testing.T) { require.Equal(t, map[string]string{ "grafana.app/originName": "test", "grafana.app/originPath": "a/b/c", - "grafana.app/originKey": "kkk", + "grafana.app/originHash": "kkk", "grafana.app/folder": "folderUID", }, obj2.GetAnnotations()) diff --git a/pkg/apis/alerting_notifications/v0alpha1/doc.go b/pkg/apis/alerting_notifications/v0alpha1/doc.go new file mode 100644 index 00000000000..f1e30a11e31 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=notifications.alerting.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" diff --git a/pkg/apis/alerting_notifications/v0alpha1/register.go b/pkg/apis/alerting_notifications/v0alpha1/register.go new file mode 100644 index 00000000000..a6be83e04ef --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/register.go @@ -0,0 +1,53 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +func init() { + localSchemeBuilder.Register(AddKnownTypes) +} + +const ( + GROUP = "notifications.alerting.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var ( + TimeIntervalResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "timeintervals", "timeinterval", "TimeIntervals", + func() runtime.Object { return &TimeInterval{} }, + func() runtime.Object { return &TimeIntervalList{} }, + ) + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} + // SchemaBuilder is used by standard codegen + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +// Adds the list of known types to the given scheme. +func AddKnownTypes(scheme *runtime.Scheme) error { + return AddKnownTypesGroup(scheme, SchemeGroupVersion) +} + +// Adds the list of known types to the given scheme and group version. +func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error { + scheme.AddKnownTypes(g, + &TimeInterval{}, + &TimeIntervalList{}, + ) + metav1.AddToGroupVersion(scheme, g) + return nil +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/testing.go b/pkg/apis/alerting_notifications/v0alpha1/testing.go new file mode 100644 index 00000000000..355f7422f20 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/testing.go @@ -0,0 +1,115 @@ +package v0alpha1 + +import ( + "fmt" + "math/rand" + "slices" + "strings" + "time" + + "github.com/grafana/grafana/pkg/util" +) + +// +k8s:openapi-gen=false +// +k8s:deepcopy-gen=false +type IntervalMutator func(spec *Interval) + +// +k8s:openapi-gen=false +// +k8s:deepcopy-gen=false +type IntervalGenerator struct { + mutators []IntervalMutator +} + +func (t IntervalGenerator) With(mutators ...IntervalMutator) IntervalGenerator { + return IntervalGenerator{ + mutators: append(t.mutators, mutators...), + } +} + +func (t IntervalGenerator) generateDaysOfMonth() string { + isRange := rand.Int()%2 == 0 + if !isRange { + return fmt.Sprintf("%d", rand.Intn(30)+1) + } + from := rand.Intn(15) + 1 + to := rand.Intn(31-from) + from + 1 + return fmt.Sprintf("%d:%d", from, to) +} + +func (t IntervalGenerator) generateTimeRange() TimeRange { + from := rand.Int63n(1440 / 2) // [0, 719] + to := from + rand.Int63n(1440/2) + 1 // from < ([0,719] + [1,720]) < 1440 + return TimeRange{ + StartTime: time.Unix(from*60, 0).UTC().Format("15:04"), + EndTime: time.Unix(to*60, 0).UTC().Format("15:04"), + } +} + +func (t IntervalGenerator) generateWeekday() string { + day := rand.Intn(7) + return strings.ToLower(time.Weekday(day).String()) +} + +func (t IntervalGenerator) generateYear() string { + from := 1970 + rand.Intn(100) + if rand.Int()%3 == 0 { + to := 1970 + from + rand.Intn(10) + 1 + return fmt.Sprintf("%d:%d", from, to) + } + return fmt.Sprintf("%d", from) +} + +func (t IntervalGenerator) generateLocation() *string { + if rand.Int()%3 == 0 { + return nil + } + return util.Pointer("UTC") +} + +func (t IntervalGenerator) generateMonth() string { + return fmt.Sprintf("%d", rand.Intn(12)+1) +} + +func (t IntervalGenerator) GenerateMany(count int) []Interval { + result := make([]Interval, 0, count) + for i := 0; i < count; i++ { + result = append(result, t.Generate()) + } + return result +} + +func (t IntervalGenerator) Generate() Interval { + i := Interval{ + DaysOfMonth: generateMany(rand.Intn(6), true, t.generateDaysOfMonth), + Location: t.generateLocation(), + Months: generateMany(rand.Intn(3), true, t.generateMonth), + Times: generateMany(rand.Intn(6), true, t.generateTimeRange), + Weekdays: generateMany(rand.Intn(3), true, t.generateWeekday), + Years: generateMany(rand.Intn(3), true, t.generateYear), + } + for _, mutator := range t.mutators { + mutator(&i) + } + return i +} + +func generateMany[T comparable](repeatTimes int, unique bool, f func() T) []T { + qty := repeatTimes + 1 + result := make([]T, 0, qty) + for i := 0; i < qty; i++ { + r := f() + if unique && slices.Contains(result, r) { + continue + } + result = append(result, f()) + } + return result +} + +func CopyWith(in Interval, mutators ...IntervalMutator) Interval { + r := *in.DeepCopy() + for _, mut := range mutators { + mut(&r) + } + return r +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/timeinterval_spec.go b/pkg/apis/alerting_notifications/v0alpha1/timeinterval_spec.go new file mode 100644 index 00000000000..f345bda7416 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/timeinterval_spec.go @@ -0,0 +1,38 @@ +package v0alpha1 + +// Interval defines model for Interval. +// +k8s:openapi-gen=true +type Interval struct { + // +listType=atomic + DaysOfMonth []string `json:"days_of_month,omitempty"` + + // +listType=atomic + Location *string `json:"location,omitempty"` + + // +listType=atomic + Months []string `json:"months,omitempty"` + + // +listType=atomic + Times []TimeRange `json:"times,omitempty"` + + // +listType=atomic + Weekdays []string `json:"weekdays,omitempty"` + + // +listType=atomic + Years []string `json:"years,omitempty"` +} + +// Spec defines model for Spec. +// +k8s:openapi-gen=true +type TimeIntervalSpec struct { + Name string `json:"name"` + // +listType=atomic + TimeIntervals []Interval `json:"time_intervals"` +} + +// TimeRange defines model for TimeRange. +// +k8s:openapi-gen=true +type TimeRange struct { + EndTime string `json:"end_time"` + StartTime string `json:"start_time"` +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/types.go b/pkg/apis/alerting_notifications/v0alpha1/types.go new file mode 100644 index 00000000000..6a970ad4516 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/types.go @@ -0,0 +1,87 @@ +package v0alpha1 + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TimeInterval struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec TimeIntervalSpec `json:"spec"` +} + +func (o *TimeInterval) GetSpec() any { + return o.Spec +} + +func (o *TimeInterval) SetSpec(spec any) error { + cast, ok := spec.(TimeIntervalSpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *TimeInterval) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *TimeInterval) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *TimeInterval) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) + return parsed +} + +func (o *TimeInterval) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *TimeInterval) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *TimeInterval) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:openapi-gen=true +type TimeIntervalList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []TimeInterval `json:"items"` +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..918c3d81cb2 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,157 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Interval) DeepCopyInto(out *Interval) { + *out = *in + if in.DaysOfMonth != nil { + in, out := &in.DaysOfMonth, &out.DaysOfMonth + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Location != nil { + in, out := &in.Location, &out.Location + *out = new(string) + **out = **in + } + if in.Months != nil { + in, out := &in.Months, &out.Months + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Times != nil { + in, out := &in.Times, &out.Times + *out = make([]TimeRange, len(*in)) + copy(*out, *in) + } + if in.Weekdays != nil { + in, out := &in.Weekdays, &out.Weekdays + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Years != nil { + in, out := &in.Years, &out.Years + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Interval. +func (in *Interval) DeepCopy() *Interval { + if in == nil { + return nil + } + out := new(Interval) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeInterval) DeepCopyInto(out *TimeInterval) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeInterval. +func (in *TimeInterval) DeepCopy() *TimeInterval { + if in == nil { + return nil + } + out := new(TimeInterval) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TimeInterval) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeIntervalList) DeepCopyInto(out *TimeIntervalList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TimeInterval, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeIntervalList. +func (in *TimeIntervalList) DeepCopy() *TimeIntervalList { + if in == nil { + return nil + } + out := new(TimeIntervalList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TimeIntervalList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeIntervalSpec) DeepCopyInto(out *TimeIntervalSpec) { + *out = *in + if in.TimeIntervals != nil { + in, out := &in.TimeIntervals, &out.TimeIntervals + *out = make([]Interval, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeIntervalSpec. +func (in *TimeIntervalSpec) DeepCopy() *TimeIntervalSpec { + if in == nil { + return nil + } + out := new(TimeIntervalSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeRange) DeepCopyInto(out *TimeRange) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeRange. +func (in *TimeRange) DeepCopy() *TimeRange { + if in == nil { + return nil + } + out := new(TimeRange) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/example/v0alpha1/zz_generated.defaults.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.defaults.go similarity index 100% rename from pkg/apis/example/v0alpha1/zz_generated.defaults.go rename to pkg/apis/alerting_notifications/v0alpha1/zz_generated.defaults.go diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go new file mode 100644 index 00000000000..8d5ff04f0a0 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,303 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval": schema_pkg_apis_alerting_notifications_v0alpha1_Interval(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval": schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalList": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange": schema_pkg_apis_alerting_notifications_v0alpha1_TimeRange(ref), + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_Interval(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Interval defines model for Interval.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "days_of_month": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "location": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "months": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "times": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange"), + }, + }, + }, + }, + }, + "weekdays": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "years": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec"), + }, + }, + }, + Required: []string{"metadata", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval"), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Spec defines model for Spec.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "time_intervals": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval"), + }, + }, + }, + }, + }, + }, + Required: []string{"name", "time_intervals"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TimeRange(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "TimeRange defines model for TimeRange.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "end_time": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "start_time": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"end_time", "start_time"}, + }, + }, + } +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..7b756e64dfd --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,4 @@ +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1,Interval,DaysOfMonth +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1,TimeIntervalSpec,TimeIntervals +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1,TimeRange,EndTime +API rule violation: names_match,github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1,TimeRange,StartTime diff --git a/pkg/apis/example/v0alpha1/doc.go b/pkg/apis/example/v0alpha1/doc.go deleted file mode 100644 index 96849073b83..00000000000 --- a/pkg/apis/example/v0alpha1/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// +k8s:deepcopy-gen=package -// +k8s:openapi-gen=true -// +k8s:defaulter-gen=TypeMeta -// +groupName=example.grafana.app - -// The testing api is a dependency free service that we can use to experiment with -// api aggregation across multiple deployment models. Specifically: -// - standalone: running as part of the standard grafana build -// - aggregated: running as the target - -package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/example/v0alpha1" diff --git a/pkg/apis/example/v0alpha1/register.go b/pkg/apis/example/v0alpha1/register.go deleted file mode 100644 index e254cad1d34..00000000000 --- a/pkg/apis/example/v0alpha1/register.go +++ /dev/null @@ -1,30 +0,0 @@ -package v0alpha1 - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" -) - -const ( - GROUP = "example.grafana.app" - VERSION = "v0alpha1" - APIVERSION = GROUP + "/" + VERSION -) - -var RuntimeResourceInfo = common.NewResourceInfo(GROUP, VERSION, - "runtime", "runtime", "RuntimeInfo", - func() runtime.Object { return &RuntimeInfo{} }, - func() runtime.Object { return &RuntimeInfo{} }, -) -var DummyResourceInfo = common.NewResourceInfo(GROUP, VERSION, - "dummy", "dummy", "DummyResource", - func() runtime.Object { return &DummyResource{} }, - func() runtime.Object { return &DummyResourceList{} }, -) - -var ( - // SchemeGroupVersion is group version used to register these objects - SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} -) diff --git a/pkg/apis/example/v0alpha1/types.go b/pkg/apis/example/v0alpha1/types.go deleted file mode 100644 index a8fef3ffe6f..00000000000 --- a/pkg/apis/example/v0alpha1/types.go +++ /dev/null @@ -1,48 +0,0 @@ -package v0alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" -) - -// Mirrors the info exposed in "github.com/grafana/grafana/pkg/setting" -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type RuntimeInfo struct { - metav1.TypeMeta `json:",inline"` - - // Unix timestamp when the process started - StartupTime int64 `json:"startupTime,omitempty"` - - BuildVersion string `json:"buildVersion,omitempty"` - BuildCommit string `json:"buildCommit,omitempty"` - EnterpriseBuildCommit string `json:"enterpriseBuildCommit,omitempty"` - BuildBranch string `json:"buildBranch,omitempty"` - BuildStamp int64 `json:"buildStamp,omitempty"` - IsEnterprise bool `json:"enterprise,omitempty"` - Packaging string `json:"packaging,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type DummyResource struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec common.Unstructured `json:"spec,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type DummyResourceList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - - Items []DummyResource `json:"items,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type DummySubresource struct { - metav1.TypeMeta `json:",inline"` - - // add subresource info here - Info string `json:"info,omitempty"` -} diff --git a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go deleted file mode 100644 index 063ad395718..00000000000 --- a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go +++ /dev/null @@ -1,122 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by deepcopy-gen. DO NOT EDIT. - -package v0alpha1 - -import ( - runtime "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DummyResource) DeepCopyInto(out *DummyResource) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DummyResource. -func (in *DummyResource) DeepCopy() *DummyResource { - if in == nil { - return nil - } - out := new(DummyResource) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DummyResource) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DummyResourceList) DeepCopyInto(out *DummyResourceList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]DummyResource, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DummyResourceList. -func (in *DummyResourceList) DeepCopy() *DummyResourceList { - if in == nil { - return nil - } - out := new(DummyResourceList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DummyResourceList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DummySubresource) DeepCopyInto(out *DummySubresource) { - *out = *in - out.TypeMeta = in.TypeMeta - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DummySubresource. -func (in *DummySubresource) DeepCopy() *DummySubresource { - if in == nil { - return nil - } - out := new(DummySubresource) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DummySubresource) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RuntimeInfo) DeepCopyInto(out *RuntimeInfo) { - *out = *in - out.TypeMeta = in.TypeMeta - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeInfo. -func (in *RuntimeInfo) DeepCopy() *RuntimeInfo { - if in == nil { - return nil - } - out := new(RuntimeInfo) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *RuntimeInfo) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} diff --git a/pkg/apis/example/v0alpha1/zz_generated.openapi.go b/pkg/apis/example/v0alpha1/zz_generated.openapi.go deleted file mode 100644 index 8c441c55064..00000000000 --- a/pkg/apis/example/v0alpha1/zz_generated.openapi.go +++ /dev/null @@ -1,219 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by openapi-gen. DO NOT EDIT. - -// This file was autogenerated by openapi-gen. Do not edit it manually! - -package v0alpha1 - -import ( - common "k8s.io/kube-openapi/pkg/common" - spec "k8s.io/kube-openapi/pkg/validation/spec" -) - -func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResource": schema_pkg_apis_example_v0alpha1_DummyResource(ref), - "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResourceList": schema_pkg_apis_example_v0alpha1_DummyResourceList(ref), - "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummySubresource": schema_pkg_apis_example_v0alpha1_DummySubresource(ref), - "github.com/grafana/grafana/pkg/apis/example/v0alpha1.RuntimeInfo": schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref), - } -} - -func schema_pkg_apis_example_v0alpha1_DummyResource(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_example_v0alpha1_DummyResourceList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResource"), - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResource", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_example_v0alpha1_DummySubresource(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "info": { - SchemaProps: spec.SchemaProps{ - Description: "add subresource info here", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - -func schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Mirrors the info exposed in \"github.com/grafana/grafana/pkg/setting\"", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "startupTime": { - SchemaProps: spec.SchemaProps{ - Description: "Unix timestamp when the process started", - Type: []string{"integer"}, - Format: "int64", - }, - }, - "buildVersion": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "buildCommit": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "enterpriseBuildCommit": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "buildBranch": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "buildStamp": { - SchemaProps: spec.SchemaProps{ - Type: []string{"integer"}, - Format: "int64", - }, - }, - "enterprise": { - SchemaProps: spec.SchemaProps{ - Type: []string{"boolean"}, - Format: "", - }, - }, - "packaging": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} diff --git a/pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list deleted file mode 100644 index 75c16b7b44e..00000000000 --- a/pkg/apis/example/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ /dev/null @@ -1 +0,0 @@ -API rule violation: names_match,github.com/grafana/grafana/pkg/apis/example/v0alpha1,RuntimeInfo,IsEnterprise diff --git a/pkg/apiserver/builder/helper.go b/pkg/apiserver/builder/helper.go index 1fe320aceb5..8c0e84db2e2 100644 --- a/pkg/apiserver/builder/helper.go +++ b/pkg/apiserver/builder/helper.go @@ -10,10 +10,12 @@ import ( "strings" "time" + "github.com/prometheus/client_golang/prometheus" "golang.org/x/mod/semver" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/version" openapinamer "k8s.io/apiserver/pkg/endpoints/openapi" "k8s.io/apiserver/pkg/registry/generic" @@ -25,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/apiserver/endpoints/filters" "github.com/grafana/grafana/pkg/services/apiserver/options" - "github.com/prometheus/client_golang/prometheus" ) // TODO: this is a temporary hack to make rest.Connecter work with resource level routes @@ -95,10 +96,13 @@ func SetupConfig( // Needs to run last in request chain to function as expected, hence we register it first. handler := filters.WithTracingHTTPLoggingAttributes(requestHandler) + handler = filters.WithRequester(handler) handler = genericapiserver.DefaultBuildHandlerChain(handler, c) handler = filters.WithAcceptHeader(handler) handler = filters.WithPathRewriters(handler, pathRewriters) handler = k8stracing.WithTracing(handler, serverConfig.TracerProvider, "KubernetesAPI") + // Configure filters.WithPanicRecovery to not crash on panic + utilruntime.ReallyCrash = false return handler } diff --git a/pkg/apiserver/endpoints/filters/requester.go b/pkg/apiserver/endpoints/filters/requester.go new file mode 100644 index 00000000000..d091246d024 --- /dev/null +++ b/pkg/apiserver/endpoints/filters/requester.go @@ -0,0 +1,68 @@ +package filters + +import ( + "net/http" + "slices" + + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/klog/v2" + + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +// WithRequester makes sure there is an identity.Requester in context +func WithRequester(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + requester, err := identity.GetRequester(ctx) + if err != nil { + // Find the kubernetes user info + info, ok := request.UserFrom(ctx) + if ok { + if info.GetName() == user.Anonymous { + requester = &identity.StaticRequester{ + Namespace: identity.NamespaceAnonymous, + Name: info.GetName(), + Login: info.GetName(), + Permissions: map[int64]map[string][]string{}, + } + } + + if info.GetName() == user.APIServerUser || + slices.Contains(info.GetGroups(), user.SystemPrivilegedGroup) { + orgId := int64(1) + requester = &identity.StaticRequester{ + Namespace: identity.NamespaceServiceAccount, // system:apiserver + UserID: 1, + OrgID: orgId, + Name: info.GetName(), + Login: info.GetName(), + OrgRole: identity.RoleAdmin, + IsGrafanaAdmin: true, + Permissions: map[int64]map[string][]string{ + orgId: { + "*": {"*"}, // all resources, all scopes + + // Dashboards do not support wildcard action + // dashboards.ActionDashboardsRead: {"*"}, + // dashboards.ActionDashboardsCreate: {"*"}, + // dashboards.ActionDashboardsWrite: {"*"}, + // dashboards.ActionDashboardsDelete: {"*"}, + // dashboards.ActionFoldersCreate: {"*"}, + // dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders + }, + }, + } + } + + if requester != nil { + req = req.WithContext(identity.WithRequester(ctx, requester)) + } else { + klog.V(5).Info("unable to map the k8s user to grafana requester", "user", info) + } + } + } + handler.ServeHTTP(w, req) + }) +} diff --git a/pkg/apiserver/rest/dualwriter_mode1.go b/pkg/apiserver/rest/dualwriter_mode1.go index b008f0bff82..4fb6b40d1ba 100644 --- a/pkg/apiserver/rest/dualwriter_mode1.go +++ b/pkg/apiserver/rest/dualwriter_mode1.go @@ -54,7 +54,7 @@ func (d *DualWriterMode1) Create(ctx context.Context, original runtime.Object, c ctx, cancel := context.WithTimeoutCause(ctx, time.Second*10, errors.New("storage create timeout")) defer cancel() - if err := enrichLegacyObject(original, createdCopy, true); err != nil { + if err := enrichLegacyObject(original, createdCopy); err != nil { cancel() } @@ -201,7 +201,7 @@ func (d *DualWriterMode1) Update(ctx context.Context, name string, objInfo rest. // if the object is found, create a new updateWrapper with the object found if foundObj != nil { - if err := enrichLegacyObject(foundObj, resCopy, false); err != nil { + if err := enrichLegacyObject(foundObj, resCopy); err != nil { log.Error(err, "could not enrich object") cancel() } diff --git a/pkg/apiserver/rest/dualwriter_mode2.go b/pkg/apiserver/rest/dualwriter_mode2.go index 12e4703d829..ff269800563 100644 --- a/pkg/apiserver/rest/dualwriter_mode2.go +++ b/pkg/apiserver/rest/dualwriter_mode2.go @@ -4,16 +4,15 @@ import ( "context" "time" - "github.com/grafana/grafana/pkg/apimachinery/utils" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/selection" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/klog/v2" + + "github.com/grafana/grafana/pkg/apimachinery/utils" ) type DualWriterMode2 struct { @@ -51,7 +50,7 @@ func (d *DualWriterMode2) Create(ctx context.Context, original runtime.Object, c } d.recordLegacyDuration(false, mode2Str, options.Kind, method, startLegacy) - if err := enrichLegacyObject(original, created, true); err != nil { + if err := enrichLegacyObject(original, created); err != nil { return created, err } @@ -262,7 +261,7 @@ func (d *DualWriterMode2) Update(ctx context.Context, name string, objInfo rest. // if the object is found, create a new updateWrapper with the object found if foundObj != nil { - err = enrichLegacyObject(foundObj, obj, false) + err = enrichLegacyObject(foundObj, obj) if err != nil { return obj, false, err } @@ -317,33 +316,19 @@ func parseList(legacyList []runtime.Object) (metainternalversion.ListOptions, ma indexMap := map[string]int{} for i, obj := range legacyList { - metaAccessor, err := utils.MetaAccessor(obj) - if err != nil { - return options, nil, err - } - originKeys = append(originKeys, metaAccessor.GetOriginKey()) - - accessor, err := meta.Accessor(obj) + accessor, err := utils.MetaAccessor(obj) if err != nil { return options, nil, err } indexMap[accessor.GetName()] = i } - if len(originKeys) == 0 { return options, nil, nil } - - r, err := labels.NewRequirement(utils.AnnoKeyOriginKey, selection.In, originKeys) - if err != nil { - return options, nil, err - } - options.LabelSelector = labels.NewSelector().Add(*r) - return options, indexMap, nil } -func enrichLegacyObject(originalObj, returnedObj runtime.Object, created bool) error { +func enrichLegacyObject(originalObj, returnedObj runtime.Object) error { accessorReturned, err := meta.Accessor(returnedObj) if err != nil { return err @@ -365,13 +350,6 @@ func enrichLegacyObject(originalObj, returnedObj runtime.Object, created bool) e } accessorReturned.SetAnnotations(ac) - // if the object is created, we need to reset the resource version and UID - // create method expects an empty resource version - if created { - accessorReturned.SetResourceVersion("") - accessorReturned.SetUID("") - return nil - } // otherwise, we propagate the original RV and UID accessorReturned.SetResourceVersion(accessorOriginal.GetResourceVersion()) accessorReturned.SetUID(accessorOriginal.GetUID()) diff --git a/pkg/apiserver/rest/dualwriter_mode2_test.go b/pkg/apiserver/rest/dualwriter_mode2_test.go index 5a3d3969638..f0c89b6ad75 100644 --- a/pkg/apiserver/rest/dualwriter_mode2_test.go +++ b/pkg/apiserver/rest/dualwriter_mode2_test.go @@ -79,7 +79,7 @@ func TestMode2_Create(t *testing.T) { assert.Equal(t, exampleObj, obj) accessor, err := meta.Accessor(obj) assert.NoError(t, err) - assert.Equal(t, accessor.GetResourceVersion(), "") + assert.Equal(t, accessor.GetResourceVersion(), "1") }) } } @@ -493,83 +493,7 @@ func TestEnrichReturnedObject(t *testing.T) { wantErr bool }{ { - name: "create: original object does not have labels and annotations", - inputOriginal: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5")}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - inputReturned: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "2", UID: types.UID("6"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - isCreated: true, - expectedObject: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "", UID: types.UID("")}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - }, - { - name: "create: returned object does not have labels and annotations", - inputOriginal: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - inputReturned: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "2", UID: types.UID("6")}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - isCreated: true, - expectedObject: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "", UID: types.UID(""), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - }, - { - name: "create: both objects have labels and annotations", - inputOriginal: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - inputReturned: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "2", UID: types.UID("6"), Labels: map[string]string{"label2": "2"}, Annotations: map[string]string{"annotation2": "2"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - isCreated: true, - expectedObject: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "", UID: types.UID(""), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - }, - { - name: "create: both objects have labels and annotations with duplicated keys", - inputOriginal: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - inputReturned: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "2", UID: types.UID("6"), Labels: map[string]string{"label1": "11"}, Annotations: map[string]string{"annotation1": "11"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - isCreated: true, - expectedObject: &example.Pod{ - TypeMeta: metav1.TypeMeta{Kind: "foo"}, - ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "", UID: types.UID(""), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, - Spec: example.PodSpec{}, Status: example.PodStatus{}, - }, - }, - { - name: "update: original object does not have labels and annotations", + name: "original object does not have labels and annotations", inputOriginal: &example.Pod{ TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5")}, @@ -587,7 +511,7 @@ func TestEnrichReturnedObject(t *testing.T) { }, }, { - name: "update: returned object does not have labels and annotations", + name: "returned object does not have labels and annotations", inputOriginal: &example.Pod{ TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, @@ -605,7 +529,7 @@ func TestEnrichReturnedObject(t *testing.T) { }, }, { - name: "update: both objects have labels and annotations", + name: "both objects have labels and annotations", inputOriginal: &example.Pod{ TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, @@ -623,7 +547,7 @@ func TestEnrichReturnedObject(t *testing.T) { }, }, { - name: "update: both objects have labels and annotations with duplicated keys", + name: "both objects have labels and annotations with duplicated keys", inputOriginal: &example.Pod{ TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta: metav1.ObjectMeta{Name: "foo", ResourceVersion: "1", UID: types.UID("5"), Labels: map[string]string{"label1": "1"}, Annotations: map[string]string{"annotation1": "1"}}, @@ -658,7 +582,7 @@ func TestEnrichReturnedObject(t *testing.T) { for _, tt := range testCase { t.Run(tt.name, func(t *testing.T) { - err := enrichLegacyObject(tt.inputOriginal, tt.inputReturned, tt.isCreated) + err := enrichLegacyObject(tt.inputOriginal, tt.inputReturned) if tt.wantErr { assert.Error(t, err) return diff --git a/pkg/build/actions/bump-version/action.yml b/pkg/build/actions/bump-version/action.yml new file mode 100644 index 00000000000..c93d251a47a --- /dev/null +++ b/pkg/build/actions/bump-version/action.yml @@ -0,0 +1,35 @@ +name: Bump version +description: Updates package.json versions in Grafana and subpackages and regenerates schemas +inputs: + version: + description: The new version (ex. 1.2.3) + required: true +runs: + using: "composite" + steps: + - uses: actions-ecosystem/action-regex-match@v2.0.2 + if: ${{ github.event.inputs.version != '' }} + id: regex-match + with: + text: ${{ github.event.inputs.version }} + # https://semver.org/ + regex: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' + - name: Validate input version + if: ${{ steps.regex-match.outputs.match == '' && github.event.inputs.version != '' }} + shell: bash + run: | + echo "The input version format is not correct, please respect:\ + major.minor.patch, major.minor.patch-preview or major.minor.patch-preview format. \ + example: 7.4.3, 7.4.3-preview or 7.4.3-preview1" + exit 1 + - uses: actions/setup-go@v4 + with: + go-version-file: go.mod + - name: Bump versions + uses: dagger/dagger-for-github@v5 + with: + verb: run + args: go run ./pkg/build/actions/bump-version -version=${{ inputs.version }} + - name: make gen-cue + shell: bash + run: make gen-cue diff --git a/pkg/build/actions/bump-version/main.go b/pkg/build/actions/bump-version/main.go new file mode 100644 index 00000000000..2f419af558c --- /dev/null +++ b/pkg/build/actions/bump-version/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "path/filepath" + "strings" + + "dagger.io/dagger" +) + +func main() { + var ( + dir = flag.String("dir", ".", "The grafana directory") + version = flag.String("version", "", "Use the '-version' flag to manually set the version") + ctx = context.Background() + ) + flag.Parse() + if *version == "" { + log.Fatalln("-version must be set") + } + + d, err := dagger.Connect(ctx) + if err != nil { + panic(err) + } + + src := d.Host().Directory(filepath.Clean(*dir), dagger.HostDirectoryOpts{ + Include: []string{ + ".nvmrc", + ".yarn", + ".yarnrc.yml", + "nx.json", + "project.json", + "lerna.json", + "**/package.json", + "**/yarn.lock", + }, + }) + nodeVersion, err := NodeVersion(d, src).Stdout(ctx) + if err != nil { + log.Fatalln("error getting node version from '.nvmrc':", err) + } + + // Update version(s) + updated := WithUpdatedVersion(d, src, nodeVersion, *version) + log.Println("Exporting directory") + if _, err := updated.Export(ctx, filepath.Clean(*dir)); err != nil { + log.Fatalln("error exporting directory", err) + } + log.Println("Done exporting directory") +} + +// NodeVersion a container whose `stdout` will return the node version from the '.nvmrc' file in the directory 'src'. +func NodeVersion(d *dagger.Client, src *dagger.Directory) *dagger.Container { + return d.Container().From("alpine"). + WithMountedFile("/src/.nvmrc", src.File(".nvmrc")). + WithWorkdir("/src"). + WithExec([]string{"cat", ".nvmrc"}) +} + +func WithUpdatedVersion(d *dagger.Client, src *dagger.Directory, nodeVersion, version string) *dagger.Directory { + nodeVersion = strings.TrimPrefix(strings.TrimSpace(nodeVersion), "v") + image := fmt.Sprintf("node:%s-slim", nodeVersion) + + return d.Container().From(image). + WithDirectory("/src", src). + WithWorkdir("/src"). + WithExec([]string{"yarn", "install"}). + WithExec([]string{"npm", "version", version, "--no-git-tag-version"}). + WithExec([]string{"yarn", "run", "lerna", "version", version, "--no-push", "--no-git-tag-version", "--force-publish", "--exact", "--yes"}). + WithExec([]string{"yarn", "install"}). + Directory("/src"). + WithoutDirectory("node_modules") +} diff --git a/pkg/build/go.mod b/pkg/build/go.mod index 91c7c5558c2..531adb6618e 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -35,7 +35,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group github.com/stretchr/testify v1.9.0 // @grafana/grafana-backend-group github.com/urfave/cli v1.22.15 // @grafana/grafana-backend-group - github.com/urfave/cli/v2 v2.25.1 // @grafana/grafana-backend-group + github.com/urfave/cli/v2 v2.27.1 // @grafana/grafana-backend-group go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect; @grafana/plugins-platform-backend go.opentelemetry.io/otel v1.26.0 // indirect; @grafana/grafana-backend-group go.opentelemetry.io/otel/sdk v1.26.0 // indirect; @grafana/grafana-backend-group @@ -97,12 +97,28 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect ) +require dagger.io/dagger v0.11.8-rc.2 + require ( + github.com/99designs/gqlgen v0.17.44 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/Khan/genqlient v0.7.0 // indirect + github.com/adrg/xdg v0.4.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/term v0.5.0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + github.com/sosodev/duration v1.2.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.11 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.2.0-alpha // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 // indirect + go.opentelemetry.io/otel/log v0.2.0-alpha // indirect + go.opentelemetry.io/otel/sdk/log v0.2.0-alpha // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect + golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/pkg/build/go.sum b/pkg/build/go.sum index f44c496bd24..4e40f12ad02 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -11,19 +11,29 @@ cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg= cloud.google.com/go/storage v1.38.0/go.mod h1:tlUADB0mAb9BgYls9lq+8MGkfzOXuLrnHXlpHmvFJoY= +dagger.io/dagger v0.11.8-rc.2 h1:HCP3gXgAfJJBFitJm0jRdKWJsIKgSWNmVN9UV+CkOdk= +dagger.io/dagger v0.11.8-rc.2/go.mod h1:kIzxLfN8N8FXUCN9u5EHLBJUJMJm0t6XynecUzp0A5w= github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= +github.com/99designs/gqlgen v0.17.44 h1:OS2wLk/67Y+vXM75XHbwRnNYJcbuJd4OBL76RX3NQQA= +github.com/99designs/gqlgen v0.17.44/go.mod h1:UTCu3xpK2mLI5qcMNw+HKDiEL77it/1XtAjisC4sLwM= github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e/go.mod h1:Xa6lInWHNQnuWoF0YPSsx+INFA9qk7/7pTjwb3PInkY= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Khan/genqlient v0.7.0 h1:GZ1meyRnzcDTK48EjqB8t3bcfYvHArCUUvgOwpz1D4w= +github.com/Khan/genqlient v0.7.0/go.mod h1:HNyy3wZvuYwmW3Y7mkoQLZsa/R5n5yIRajS1kPBvSFM= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/aws/aws-sdk-go v1.51.31 h1:4TM+sNc+Dzs7wY1sJ0+J8i60c6rkgnKP1pvPx8ghsSY= github.com/aws/aws-sdk-go v1.51.31/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -32,6 +42,8 @@ github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTS github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/buildkite/yaml v2.1.0+incompatible h1:xirI+ql5GzfikVNDmt+yeiXpf/v1Gt03qXTtT5WXdr8= github.com/buildkite/yaml v2.1.0+incompatible/go.mod h1:UoU8vbcwu1+vjZq01+KrpSeLBgQQIjL/H7Y6KwikUrI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -42,7 +54,6 @@ github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMX github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -130,6 +141,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksP github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -142,7 +155,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -150,6 +162,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/moby v23.0.4+incompatible h1:A/pe8vi9KIKhNbzR0G3wW4ACKDsMgXILBveMqiJNa8M= github.com/moby/moby v23.0.4+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -161,7 +175,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -169,12 +182,15 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= +github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -190,8 +206,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM= github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0= -github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw= -github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= +github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -204,14 +222,31 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 h1:oM0GTNKGlc5qHctWeIGTVyda4iFFalOzMZ3Ehj5rwB4= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88/go.mod h1:JGG8ebaMO5nXOPnvKEl+DiA4MGwFjCbjsxT1WHIEBPY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.2.0-alpha h1:z2s6Zba+OUyayRv5m1AXWNUTGh57K1iMhy6emU5QT5Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.2.0-alpha/go.mod h1:paOXXyUgPW6jYxYkP0pB47H2zHE1fPvMJ4E4G9LHOi0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 h1:1u/AyyOqAWzy+SkPxDpahCNZParHV8Vid1RnI2clyDE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0/go.mod h1:z46paqbJ9l7c9fIPCXTqTGwhQZ5XoTIsfeFYWboizjs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 h1:Waw9Wfpo/IXzOI8bCB7DIk+0JZcqqsyn1JFnAc+iam8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 h1:1wp/gyxsuYtuE/JFxsQRtcCDtMrO2qMvlfXALU5wkzI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0/go.mod h1:gbTHmghkGgqxMomVQQMur1Nba4M0MQ8AYThXDUjsJ38= +go.opentelemetry.io/otel/log v0.2.0-alpha h1:ixOPvMzserpqA07SENHvRzkZOsnG0XbPr74hv1AQ+n0= +go.opentelemetry.io/otel/log v0.2.0-alpha/go.mod h1:vbFZc65yq4c4ssvXY43y/nIqkNJLxORrqw0L85P59LA= go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= +go.opentelemetry.io/otel/sdk/log v0.2.0-alpha h1:jGTkL/jroJ31jnP6jDl34N/mDOfRGGYZHcHsCM+5kWA= +go.opentelemetry.io/otel/sdk/log v0.2.0-alpha/go.mod h1:Hd8Lw9FPGUM3pfY7iGMRvFaC2Nyau4Ajb5WnQ9OdIho= go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -219,6 +254,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -254,6 +290,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/interval.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/interval.go new file mode 100644 index 00000000000..951000054bb --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/interval.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// IntervalApplyConfiguration represents an declarative configuration of the Interval type for use +// with apply. +type IntervalApplyConfiguration struct { + DaysOfMonth []string `json:"days_of_month,omitempty"` + Location *string `json:"location,omitempty"` + Months []string `json:"months,omitempty"` + Times []TimeRangeApplyConfiguration `json:"times,omitempty"` + Weekdays []string `json:"weekdays,omitempty"` + Years []string `json:"years,omitempty"` +} + +// IntervalApplyConfiguration constructs an declarative configuration of the Interval type for use with +// apply. +func Interval() *IntervalApplyConfiguration { + return &IntervalApplyConfiguration{} +} + +// WithDaysOfMonth adds the given value to the DaysOfMonth field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the DaysOfMonth field. +func (b *IntervalApplyConfiguration) WithDaysOfMonth(values ...string) *IntervalApplyConfiguration { + for i := range values { + b.DaysOfMonth = append(b.DaysOfMonth, values[i]) + } + return b +} + +// WithLocation sets the Location field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Location field is set to the value of the last call. +func (b *IntervalApplyConfiguration) WithLocation(value string) *IntervalApplyConfiguration { + b.Location = &value + return b +} + +// WithMonths adds the given value to the Months field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Months field. +func (b *IntervalApplyConfiguration) WithMonths(values ...string) *IntervalApplyConfiguration { + for i := range values { + b.Months = append(b.Months, values[i]) + } + return b +} + +// WithTimes adds the given value to the Times field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Times field. +func (b *IntervalApplyConfiguration) WithTimes(values ...*TimeRangeApplyConfiguration) *IntervalApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithTimes") + } + b.Times = append(b.Times, *values[i]) + } + return b +} + +// WithWeekdays adds the given value to the Weekdays field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Weekdays field. +func (b *IntervalApplyConfiguration) WithWeekdays(values ...string) *IntervalApplyConfiguration { + for i := range values { + b.Weekdays = append(b.Weekdays, values[i]) + } + return b +} + +// WithYears adds the given value to the Years field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Years field. +func (b *IntervalApplyConfiguration) WithYears(values ...string) *IntervalApplyConfiguration { + for i := range values { + b.Years = append(b.Years, values[i]) + } + return b +} diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timeinterval.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timeinterval.go new file mode 100644 index 00000000000..f94c6e8f95f --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timeinterval.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// TimeIntervalApplyConfiguration represents an declarative configuration of the TimeInterval type for use +// with apply. +type TimeIntervalApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *TimeIntervalSpecApplyConfiguration `json:"spec,omitempty"` +} + +// TimeInterval constructs an declarative configuration of the TimeInterval type for use with +// apply. +func TimeInterval(name, namespace string) *TimeIntervalApplyConfiguration { + b := &TimeIntervalApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("TimeInterval") + b.WithAPIVersion("notifications.alerting.grafana.app/v0alpha1") + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithKind(value string) *TimeIntervalApplyConfiguration { + b.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithAPIVersion(value string) *TimeIntervalApplyConfiguration { + b.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithName(value string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithGenerateName(value string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithNamespace(value string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithUID(value types.UID) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithResourceVersion(value string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithGeneration(value int64) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithCreationTimestamp(value metav1.Time) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *TimeIntervalApplyConfiguration) WithLabels(entries map[string]string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Labels == nil && len(entries) > 0 { + b.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *TimeIntervalApplyConfiguration) WithAnnotations(entries map[string]string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Annotations == nil && len(entries) > 0 { + b.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *TimeIntervalApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.OwnerReferences = append(b.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *TimeIntervalApplyConfiguration) WithFinalizers(values ...string) *TimeIntervalApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.Finalizers = append(b.Finalizers, values[i]) + } + return b +} + +func (b *TimeIntervalApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *TimeIntervalApplyConfiguration) WithSpec(value *TimeIntervalSpecApplyConfiguration) *TimeIntervalApplyConfiguration { + b.Spec = value + return b +} diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timeintervalspec.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timeintervalspec.go new file mode 100644 index 00000000000..ba3b255ed33 --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timeintervalspec.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// TimeIntervalSpecApplyConfiguration represents an declarative configuration of the TimeIntervalSpec type for use +// with apply. +type TimeIntervalSpecApplyConfiguration struct { + Name *string `json:"name,omitempty"` + TimeIntervals []IntervalApplyConfiguration `json:"time_intervals,omitempty"` +} + +// TimeIntervalSpecApplyConfiguration constructs an declarative configuration of the TimeIntervalSpec type for use with +// apply. +func TimeIntervalSpec() *TimeIntervalSpecApplyConfiguration { + return &TimeIntervalSpecApplyConfiguration{} +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *TimeIntervalSpecApplyConfiguration) WithName(value string) *TimeIntervalSpecApplyConfiguration { + b.Name = &value + return b +} + +// WithTimeIntervals adds the given value to the TimeIntervals field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TimeIntervals field. +func (b *TimeIntervalSpecApplyConfiguration) WithTimeIntervals(values ...*IntervalApplyConfiguration) *TimeIntervalSpecApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithTimeIntervals") + } + b.TimeIntervals = append(b.TimeIntervals, *values[i]) + } + return b +} diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timerange.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timerange.go new file mode 100644 index 00000000000..77242ddc317 --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/timerange.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// TimeRangeApplyConfiguration represents an declarative configuration of the TimeRange type for use +// with apply. +type TimeRangeApplyConfiguration struct { + EndTime *string `json:"end_time,omitempty"` + StartTime *string `json:"start_time,omitempty"` +} + +// TimeRangeApplyConfiguration constructs an declarative configuration of the TimeRange type for use with +// apply. +func TimeRange() *TimeRangeApplyConfiguration { + return &TimeRangeApplyConfiguration{} +} + +// WithEndTime sets the EndTime field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the EndTime field is set to the value of the last call. +func (b *TimeRangeApplyConfiguration) WithEndTime(value string) *TimeRangeApplyConfiguration { + b.EndTime = &value + return b +} + +// WithStartTime sets the StartTime field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the StartTime field is set to the value of the last call. +func (b *TimeRangeApplyConfiguration) WithStartTime(value string) *TimeRangeApplyConfiguration { + b.StartTime = &value + return b +} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index 93ca019455c..561bf3385e5 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -5,8 +5,10 @@ package applyconfiguration import ( - v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" - servicev0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/service/v0alpha1" + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + applyconfigurationservicev0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/service/v0alpha1" schema "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -14,11 +16,21 @@ import ( // apply configuration type exists for the given GroupVersionKind. func ForKind(kind schema.GroupVersionKind) interface{} { switch kind { - // Group=service.grafana.app, Version=v0alpha1 - case v0alpha1.SchemeGroupVersion.WithKind("ExternalName"): - return &servicev0alpha1.ExternalNameApplyConfiguration{} - case v0alpha1.SchemeGroupVersion.WithKind("ExternalNameSpec"): - return &servicev0alpha1.ExternalNameSpecApplyConfiguration{} + // Group=notifications.alerting.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithKind("Interval"): + return &alertingnotificationsv0alpha1.IntervalApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TimeInterval"): + return &alertingnotificationsv0alpha1.TimeIntervalApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TimeIntervalSpec"): + return &alertingnotificationsv0alpha1.TimeIntervalSpecApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TimeRange"): + return &alertingnotificationsv0alpha1.TimeRangeApplyConfiguration{} + + // Group=service.grafana.app, Version=v0alpha1 + case servicev0alpha1.SchemeGroupVersion.WithKind("ExternalName"): + return &applyconfigurationservicev0alpha1.ExternalNameApplyConfiguration{} + case servicev0alpha1.SchemeGroupVersion.WithKind("ExternalNameSpec"): + return &applyconfigurationservicev0alpha1.ExternalNameSpecApplyConfiguration{} } return nil diff --git a/pkg/generated/clientset/versioned/clientset.go b/pkg/generated/clientset/versioned/clientset.go index 55fa581701a..dc02a208e84 100644 --- a/pkg/generated/clientset/versioned/clientset.go +++ b/pkg/generated/clientset/versioned/clientset.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" + notificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1" servicev0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1" discovery "k8s.io/client-go/discovery" rest "k8s.io/client-go/rest" @@ -16,13 +17,20 @@ import ( type Interface interface { Discovery() discovery.DiscoveryInterface + NotificationsV0alpha1() notificationsv0alpha1.NotificationsV0alpha1Interface ServiceV0alpha1() servicev0alpha1.ServiceV0alpha1Interface } // Clientset contains the clients for groups. type Clientset struct { *discovery.DiscoveryClient - serviceV0alpha1 *servicev0alpha1.ServiceV0alpha1Client + notificationsV0alpha1 *notificationsv0alpha1.NotificationsV0alpha1Client + serviceV0alpha1 *servicev0alpha1.ServiceV0alpha1Client +} + +// NotificationsV0alpha1 retrieves the NotificationsV0alpha1Client +func (c *Clientset) NotificationsV0alpha1() notificationsv0alpha1.NotificationsV0alpha1Interface { + return c.notificationsV0alpha1 } // ServiceV0alpha1 retrieves the ServiceV0alpha1Client @@ -74,6 +82,10 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, var cs Clientset var err error + cs.notificationsV0alpha1, err = notificationsv0alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } cs.serviceV0alpha1, err = servicev0alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) if err != nil { return nil, err @@ -99,6 +111,7 @@ func NewForConfigOrDie(c *rest.Config) *Clientset { // New creates a new Clientset for the given RESTClient. func New(c rest.Interface) *Clientset { var cs Clientset + cs.notificationsV0alpha1 = notificationsv0alpha1.New(c) cs.serviceV0alpha1 = servicev0alpha1.New(c) cs.DiscoveryClient = discovery.NewDiscoveryClient(c) diff --git a/pkg/generated/clientset/versioned/fake/clientset_generated.go b/pkg/generated/clientset/versioned/fake/clientset_generated.go index c7e19bcc918..5b7771eb90a 100644 --- a/pkg/generated/clientset/versioned/fake/clientset_generated.go +++ b/pkg/generated/clientset/versioned/fake/clientset_generated.go @@ -6,6 +6,8 @@ package fake import ( clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned" + notificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1" + fakenotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake" servicev0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1" fakeservicev0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/service/v0alpha1/fake" "k8s.io/apimachinery/pkg/runtime" @@ -65,6 +67,11 @@ var ( _ testing.FakeClient = &Clientset{} ) +// NotificationsV0alpha1 retrieves the NotificationsV0alpha1Client +func (c *Clientset) NotificationsV0alpha1() notificationsv0alpha1.NotificationsV0alpha1Interface { + return &fakenotificationsv0alpha1.FakeNotificationsV0alpha1{Fake: &c.Fake} +} + // ServiceV0alpha1 retrieves the ServiceV0alpha1Client func (c *Clientset) ServiceV0alpha1() servicev0alpha1.ServiceV0alpha1Interface { return &fakeservicev0alpha1.FakeServiceV0alpha1{Fake: &c.Fake} diff --git a/pkg/generated/clientset/versioned/fake/register.go b/pkg/generated/clientset/versioned/fake/register.go index bbf12658e76..00ae96dd36e 100644 --- a/pkg/generated/clientset/versioned/fake/register.go +++ b/pkg/generated/clientset/versioned/fake/register.go @@ -5,6 +5,7 @@ package fake import ( + notificationsv0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -17,6 +18,7 @@ var scheme = runtime.NewScheme() var codecs = serializer.NewCodecFactory(scheme) var localSchemeBuilder = runtime.SchemeBuilder{ + notificationsv0alpha1.AddToScheme, servicev0alpha1.AddToScheme, } diff --git a/pkg/generated/clientset/versioned/scheme/register.go b/pkg/generated/clientset/versioned/scheme/register.go index dd6e9619160..6ae4e0deec1 100644 --- a/pkg/generated/clientset/versioned/scheme/register.go +++ b/pkg/generated/clientset/versioned/scheme/register.go @@ -5,6 +5,7 @@ package scheme import ( + notificationsv0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -17,6 +18,7 @@ var Scheme = runtime.NewScheme() var Codecs = serializer.NewCodecFactory(Scheme) var ParameterCodec = runtime.NewParameterCodec(Scheme) var localSchemeBuilder = runtime.SchemeBuilder{ + notificationsv0alpha1.AddToScheme, servicev0alpha1.AddToScheme, } diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go new file mode 100644 index 00000000000..5c81a66d6ec --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "net/http" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/generated/clientset/versioned/scheme" + rest "k8s.io/client-go/rest" +) + +type NotificationsV0alpha1Interface interface { + RESTClient() rest.Interface + TimeIntervalsGetter +} + +// NotificationsV0alpha1Client is used to interact with features provided by the notifications.alerting.grafana.app group. +type NotificationsV0alpha1Client struct { + restClient rest.Interface +} + +func (c *NotificationsV0alpha1Client) TimeIntervals(namespace string) TimeIntervalInterface { + return newTimeIntervals(c, namespace) +} + +// NewForConfig creates a new NotificationsV0alpha1Client for the given config. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*NotificationsV0alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + httpClient, err := rest.HTTPClientFor(&config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(&config, httpClient) +} + +// NewForConfigAndClient creates a new NotificationsV0alpha1Client for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*NotificationsV0alpha1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientForConfigAndClient(&config, h) + if err != nil { + return nil, err + } + return &NotificationsV0alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new NotificationsV0alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *NotificationsV0alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new NotificationsV0alpha1Client for the given RESTClient. +func New(c rest.Interface) *NotificationsV0alpha1Client { + return &NotificationsV0alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v0alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = scheme.Codecs.WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *NotificationsV0alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/doc.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/doc.go new file mode 100644 index 00000000000..1c86744fecc --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v0alpha1 diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/doc.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/doc.go new file mode 100644 index 00000000000..d96b985b3ea --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/doc.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go new file mode 100644 index 00000000000..ece678250d8 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeNotificationsV0alpha1 struct { + *testing.Fake +} + +func (c *FakeNotificationsV0alpha1) TimeIntervals(namespace string) v0alpha1.TimeIntervalInterface { + return &FakeTimeIntervals{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeNotificationsV0alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_timeinterval.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_timeinterval.go new file mode 100644 index 00000000000..569c83a433d --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_timeinterval.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + json "encoding/json" + "fmt" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeTimeIntervals implements TimeIntervalInterface +type FakeTimeIntervals struct { + Fake *FakeNotificationsV0alpha1 + ns string +} + +var timeintervalsResource = v0alpha1.SchemeGroupVersion.WithResource("timeintervals") + +var timeintervalsKind = v0alpha1.SchemeGroupVersion.WithKind("TimeInterval") + +// Get takes name of the timeInterval, and returns the corresponding timeInterval object, and an error if there is any. +func (c *FakeTimeIntervals) Get(ctx context.Context, name string, options v1.GetOptions) (result *v0alpha1.TimeInterval, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(timeintervalsResource, c.ns, name), &v0alpha1.TimeInterval{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.TimeInterval), err +} + +// List takes label and field selectors, and returns the list of TimeIntervals that match those selectors. +func (c *FakeTimeIntervals) List(ctx context.Context, opts v1.ListOptions) (result *v0alpha1.TimeIntervalList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(timeintervalsResource, timeintervalsKind, c.ns, opts), &v0alpha1.TimeIntervalList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v0alpha1.TimeIntervalList{ListMeta: obj.(*v0alpha1.TimeIntervalList).ListMeta} + for _, item := range obj.(*v0alpha1.TimeIntervalList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested timeIntervals. +func (c *FakeTimeIntervals) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(timeintervalsResource, c.ns, opts)) + +} + +// Create takes the representation of a timeInterval and creates it. Returns the server's representation of the timeInterval, and an error, if there is any. +func (c *FakeTimeIntervals) Create(ctx context.Context, timeInterval *v0alpha1.TimeInterval, opts v1.CreateOptions) (result *v0alpha1.TimeInterval, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(timeintervalsResource, c.ns, timeInterval), &v0alpha1.TimeInterval{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.TimeInterval), err +} + +// Update takes the representation of a timeInterval and updates it. Returns the server's representation of the timeInterval, and an error, if there is any. +func (c *FakeTimeIntervals) Update(ctx context.Context, timeInterval *v0alpha1.TimeInterval, opts v1.UpdateOptions) (result *v0alpha1.TimeInterval, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(timeintervalsResource, c.ns, timeInterval), &v0alpha1.TimeInterval{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.TimeInterval), err +} + +// Delete takes name of the timeInterval and deletes it. Returns an error if one occurs. +func (c *FakeTimeIntervals) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteActionWithOptions(timeintervalsResource, c.ns, name, opts), &v0alpha1.TimeInterval{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeTimeIntervals) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(timeintervalsResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v0alpha1.TimeIntervalList{}) + return err +} + +// Patch applies the patch and returns the patched timeInterval. +func (c *FakeTimeIntervals) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TimeInterval, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(timeintervalsResource, c.ns, name, pt, data, subresources...), &v0alpha1.TimeInterval{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.TimeInterval), err +} + +// Apply takes the given apply declarative configuration, applies it and returns the applied timeInterval. +func (c *FakeTimeIntervals) Apply(ctx context.Context, timeInterval *alertingnotificationsv0alpha1.TimeIntervalApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TimeInterval, err error) { + if timeInterval == nil { + return nil, fmt.Errorf("timeInterval provided to Apply must not be nil") + } + data, err := json.Marshal(timeInterval) + if err != nil { + return nil, err + } + name := timeInterval.Name + if name == nil { + return nil, fmt.Errorf("timeInterval.Name must be provided to Apply") + } + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(timeintervalsResource, c.ns, *name, types.ApplyPatchType, data), &v0alpha1.TimeInterval{}) + + if obj == nil { + return nil, err + } + return obj.(*v0alpha1.TimeInterval), err +} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go new file mode 100644 index 00000000000..cf6e218f4ae --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +type TimeIntervalExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/timeinterval.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/timeinterval.go new file mode 100644 index 00000000000..cc3b2c05bd1 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/timeinterval.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + json "encoding/json" + "fmt" + "time" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + scheme "github.com/grafana/grafana/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// TimeIntervalsGetter has a method to return a TimeIntervalInterface. +// A group's client should implement this interface. +type TimeIntervalsGetter interface { + TimeIntervals(namespace string) TimeIntervalInterface +} + +// TimeIntervalInterface has methods to work with TimeInterval resources. +type TimeIntervalInterface interface { + Create(ctx context.Context, timeInterval *v0alpha1.TimeInterval, opts v1.CreateOptions) (*v0alpha1.TimeInterval, error) + Update(ctx context.Context, timeInterval *v0alpha1.TimeInterval, opts v1.UpdateOptions) (*v0alpha1.TimeInterval, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v0alpha1.TimeInterval, error) + List(ctx context.Context, opts v1.ListOptions) (*v0alpha1.TimeIntervalList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TimeInterval, err error) + Apply(ctx context.Context, timeInterval *alertingnotificationsv0alpha1.TimeIntervalApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TimeInterval, err error) + TimeIntervalExpansion +} + +// timeIntervals implements TimeIntervalInterface +type timeIntervals struct { + client rest.Interface + ns string +} + +// newTimeIntervals returns a TimeIntervals +func newTimeIntervals(c *NotificationsV0alpha1Client, namespace string) *timeIntervals { + return &timeIntervals{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the timeInterval, and returns the corresponding timeInterval object, and an error if there is any. +func (c *timeIntervals) Get(ctx context.Context, name string, options v1.GetOptions) (result *v0alpha1.TimeInterval, err error) { + result = &v0alpha1.TimeInterval{} + err = c.client.Get(). + Namespace(c.ns). + Resource("timeintervals"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of TimeIntervals that match those selectors. +func (c *timeIntervals) List(ctx context.Context, opts v1.ListOptions) (result *v0alpha1.TimeIntervalList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v0alpha1.TimeIntervalList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("timeintervals"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested timeIntervals. +func (c *timeIntervals) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("timeintervals"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a timeInterval and creates it. Returns the server's representation of the timeInterval, and an error, if there is any. +func (c *timeIntervals) Create(ctx context.Context, timeInterval *v0alpha1.TimeInterval, opts v1.CreateOptions) (result *v0alpha1.TimeInterval, err error) { + result = &v0alpha1.TimeInterval{} + err = c.client.Post(). + Namespace(c.ns). + Resource("timeintervals"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(timeInterval). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a timeInterval and updates it. Returns the server's representation of the timeInterval, and an error, if there is any. +func (c *timeIntervals) Update(ctx context.Context, timeInterval *v0alpha1.TimeInterval, opts v1.UpdateOptions) (result *v0alpha1.TimeInterval, err error) { + result = &v0alpha1.TimeInterval{} + err = c.client.Put(). + Namespace(c.ns). + Resource("timeintervals"). + Name(timeInterval.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(timeInterval). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the timeInterval and deletes it. Returns an error if one occurs. +func (c *timeIntervals) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("timeintervals"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *timeIntervals) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("timeintervals"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched timeInterval. +func (c *timeIntervals) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TimeInterval, err error) { + result = &v0alpha1.TimeInterval{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("timeintervals"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} + +// Apply takes the given apply declarative configuration, applies it and returns the applied timeInterval. +func (c *timeIntervals) Apply(ctx context.Context, timeInterval *alertingnotificationsv0alpha1.TimeIntervalApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TimeInterval, err error) { + if timeInterval == nil { + return nil, fmt.Errorf("timeInterval provided to Apply must not be nil") + } + patchOpts := opts.ToPatchOptions() + data, err := json.Marshal(timeInterval) + if err != nil { + return nil, err + } + name := timeInterval.Name + if name == nil { + return nil, fmt.Errorf("timeInterval.Name must be provided to Apply") + } + result = &v0alpha1.TimeInterval{} + err = c.client.Patch(types.ApplyPatchType). + Namespace(c.ns). + Resource("timeintervals"). + Name(*name). + VersionedParams(&patchOpts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/interface.go b/pkg/generated/informers/externalversions/alerting_notifications/interface.go new file mode 100644 index 00000000000..631d480cd39 --- /dev/null +++ b/pkg/generated/informers/externalversions/alerting_notifications/interface.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package alerting_notifications + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1" + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V0alpha1 provides access to shared informers for resources in V0alpha1. + V0alpha1() v0alpha1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V0alpha1 returns a new v0alpha1.Interface. +func (g *group) V0alpha1() v0alpha1.Interface { + return v0alpha1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go new file mode 100644 index 00000000000..2095a04954a --- /dev/null +++ b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // TimeIntervals returns a TimeIntervalInformer. + TimeIntervals() TimeIntervalInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// TimeIntervals returns a TimeIntervalInformer. +func (v *version) TimeIntervals() TimeIntervalInformer { + return &timeIntervalInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/timeinterval.go b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/timeinterval.go new file mode 100644 index 00000000000..903bb8eb7bc --- /dev/null +++ b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/timeinterval.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + time "time" + + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" + v0alpha1 "github.com/grafana/grafana/pkg/generated/listers/alerting_notifications/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// TimeIntervalInformer provides access to a shared informer and lister for +// TimeIntervals. +type TimeIntervalInformer interface { + Informer() cache.SharedIndexInformer + Lister() v0alpha1.TimeIntervalLister +} + +type timeIntervalInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewTimeIntervalInformer constructs a new informer for TimeInterval type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewTimeIntervalInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredTimeIntervalInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredTimeIntervalInformer constructs a new informer for TimeInterval type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredTimeIntervalInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.NotificationsV0alpha1().TimeIntervals(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.NotificationsV0alpha1().TimeIntervals(namespace).Watch(context.TODO(), options) + }, + }, + &alertingnotificationsv0alpha1.TimeInterval{}, + resyncPeriod, + indexers, + ) +} + +func (f *timeIntervalInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredTimeIntervalInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *timeIntervalInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&alertingnotificationsv0alpha1.TimeInterval{}, f.defaultInformer) +} + +func (f *timeIntervalInformer) Lister() v0alpha1.TimeIntervalLister { + return v0alpha1.NewTimeIntervalLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/factory.go b/pkg/generated/informers/externalversions/factory.go index faca5c61edb..1c28112c022 100644 --- a/pkg/generated/informers/externalversions/factory.go +++ b/pkg/generated/informers/externalversions/factory.go @@ -10,6 +10,7 @@ import ( time "time" versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + alertingnotifications "github.com/grafana/grafana/pkg/generated/informers/externalversions/alerting_notifications" internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" service "github.com/grafana/grafana/pkg/generated/informers/externalversions/service" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -239,9 +240,14 @@ type SharedInformerFactory interface { // client. InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer + Notifications() alertingnotifications.Interface Service() service.Interface } +func (f *sharedInformerFactory) Notifications() alertingnotifications.Interface { + return alertingnotifications.New(f, f.namespace, f.tweakListOptions) +} + func (f *sharedInformerFactory) Service() service.Interface { return service.New(f, f.namespace, f.tweakListOptions) } diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 88cc30aa0f9..137fbb60117 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -7,7 +7,8 @@ package externalversions import ( "fmt" - v0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1" schema "k8s.io/apimachinery/pkg/runtime/schema" cache "k8s.io/client-go/tools/cache" ) @@ -38,8 +39,12 @@ func (f *genericInformer) Lister() cache.GenericLister { // TODO extend this to unknown resources with a client pool func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { - // Group=service.grafana.app, Version=v0alpha1 - case v0alpha1.SchemeGroupVersion.WithResource("externalnames"): + // Group=notifications.alerting.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithResource("timeintervals"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().TimeIntervals().Informer()}, nil + + // Group=service.grafana.app, Version=v0alpha1 + case servicev0alpha1.SchemeGroupVersion.WithResource("externalnames"): return &genericInformer{resource: resource.GroupResource(), informer: f.Service().V0alpha1().ExternalNames().Informer()}, nil } diff --git a/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go b/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go new file mode 100644 index 00000000000..65bad473129 --- /dev/null +++ b/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +// TimeIntervalListerExpansion allows custom methods to be added to +// TimeIntervalLister. +type TimeIntervalListerExpansion interface{} + +// TimeIntervalNamespaceListerExpansion allows custom methods to be added to +// TimeIntervalNamespaceLister. +type TimeIntervalNamespaceListerExpansion interface{} diff --git a/pkg/generated/listers/alerting_notifications/v0alpha1/timeinterval.go b/pkg/generated/listers/alerting_notifications/v0alpha1/timeinterval.go new file mode 100644 index 00000000000..225a64b84e5 --- /dev/null +++ b/pkg/generated/listers/alerting_notifications/v0alpha1/timeinterval.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// TimeIntervalLister helps list TimeIntervals. +// All objects returned here must be treated as read-only. +type TimeIntervalLister interface { + // List lists all TimeIntervals in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.TimeInterval, err error) + // TimeIntervals returns an object that can list and get TimeIntervals. + TimeIntervals(namespace string) TimeIntervalNamespaceLister + TimeIntervalListerExpansion +} + +// timeIntervalLister implements the TimeIntervalLister interface. +type timeIntervalLister struct { + indexer cache.Indexer +} + +// NewTimeIntervalLister returns a new TimeIntervalLister. +func NewTimeIntervalLister(indexer cache.Indexer) TimeIntervalLister { + return &timeIntervalLister{indexer: indexer} +} + +// List lists all TimeIntervals in the indexer. +func (s *timeIntervalLister) List(selector labels.Selector) (ret []*v0alpha1.TimeInterval, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v0alpha1.TimeInterval)) + }) + return ret, err +} + +// TimeIntervals returns an object that can list and get TimeIntervals. +func (s *timeIntervalLister) TimeIntervals(namespace string) TimeIntervalNamespaceLister { + return timeIntervalNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// TimeIntervalNamespaceLister helps list and get TimeIntervals. +// All objects returned here must be treated as read-only. +type TimeIntervalNamespaceLister interface { + // List lists all TimeIntervals in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.TimeInterval, err error) + // Get retrieves the TimeInterval from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v0alpha1.TimeInterval, error) + TimeIntervalNamespaceListerExpansion +} + +// timeIntervalNamespaceLister implements the TimeIntervalNamespaceLister +// interface. +type timeIntervalNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all TimeIntervals in the indexer for a given namespace. +func (s timeIntervalNamespaceLister) List(selector labels.Selector) (ret []*v0alpha1.TimeInterval, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v0alpha1.TimeInterval)) + }) + return ret, err +} + +// Get retrieves the TimeInterval from the indexer for a given namespace and name. +func (s timeIntervalNamespaceLister) Get(name string) (*v0alpha1.TimeInterval, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v0alpha1.Resource("timeinterval"), name) + } + return obj.(*v0alpha1.TimeInterval), nil +} diff --git a/pkg/infra/appcontext/user.go b/pkg/infra/appcontext/user.go index c2a92da8d6a..d50eaacd8b9 100644 --- a/pkg/infra/appcontext/user.go +++ b/pkg/infra/appcontext/user.go @@ -4,13 +4,7 @@ import ( "context" "fmt" - k8suser "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/apiserver/pkg/endpoints/request" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/dashboards" grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context" "github.com/grafana/grafana/pkg/services/user" ) @@ -20,11 +14,16 @@ type ctxUserKey struct{} // WithUser adds the supplied SignedInUser to the context. func WithUser(ctx context.Context, usr *user.SignedInUser) context.Context { ctx = context.WithValue(ctx, ctxUserKey{}, usr) + // make sure it is also in the simplified version + if usr == nil || usr.IsNil() { + return identity.WithRequester(ctx, nil) + } return identity.WithRequester(ctx, usr) } // User extracts the SignedInUser from the supplied context. // Supports context set by appcontext.WithUser, gRPC server context, and HTTP ReqContext. +// Deprecated: use identity.GetRequester(ctx) when possible func User(ctx context.Context) (*user.SignedInUser, error) { // Set by appcontext.WithUser u, ok := ctx.Value(ctxUserKey{}).(*user.SignedInUser) @@ -38,44 +37,32 @@ func User(ctx context.Context) (*user.SignedInUser, error) { return grpcCtx.SignedInUser, nil } - // Set by incoming HTTP request - c, ok := ctxkey.Get(ctx).(*contextmodel.ReqContext) - if ok && c.SignedInUser != nil { - return c.SignedInUser, nil - } - - // Find the kubernetes user info - k8sUserInfo, ok := request.UserFrom(ctx) - if ok { - for _, group := range k8sUserInfo.GetGroups() { - switch group { - case k8suser.APIServerUser: - fallthrough - case k8suser.SystemPrivilegedGroup: - orgId := int64(1) - return &user.SignedInUser{ - UserID: 1, - OrgID: orgId, - Name: k8sUserInfo.GetName(), - Login: k8sUserInfo.GetName(), - OrgRole: identity.RoleAdmin, - IsGrafanaAdmin: true, - Permissions: map[int64]map[string][]string{ - orgId: { - "*": {"*"}, // all resources, all scopes - - // Dashboards do not support wildcard action - dashboards.ActionDashboardsRead: {"*"}, - dashboards.ActionDashboardsCreate: {"*"}, - dashboards.ActionDashboardsWrite: {"*"}, - dashboards.ActionDashboardsDelete: {"*"}, - dashboards.ActionFoldersCreate: {"*"}, - dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders - }, - }, - }, nil - } - } + // If the identity was set via requester, but not appcontext, we can map values + // NOTE: this path + requester, _ := identity.GetRequester(ctx) + if requester != nil { + id := requester.GetID() + userId, _ := id.UserID() + orgId := requester.GetOrgID() + return &user.SignedInUser{ + NamespacedID: id, + UserID: userId, + UserUID: requester.GetUID().ID(), + OrgID: orgId, + OrgName: requester.GetOrgName(), + OrgRole: requester.GetOrgRole(), + Login: requester.GetLogin(), + Email: requester.GetEmail(), + IsGrafanaAdmin: requester.GetIsGrafanaAdmin(), + Teams: requester.GetTeams(), + AuthID: requester.GetAuthID(), + AuthenticatedBy: requester.GetAuthenticatedBy(), + IDToken: requester.GetIDToken(), + Permissions: map[int64]map[string][]string{ + 0: requester.GetGlobalPermissions(), + orgId: requester.GetPermissions(), + }, + }, nil } return nil, fmt.Errorf("a SignedInUser was not found in the context") diff --git a/pkg/infra/appcontext/user_test.go b/pkg/infra/appcontext/user_test.go index 63300b13d17..55c9e0ac9a8 100644 --- a/pkg/infra/appcontext/user_test.go +++ b/pkg/infra/appcontext/user_test.go @@ -11,8 +11,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context" "github.com/grafana/grafana/pkg/services/user" ) @@ -52,11 +50,9 @@ func TestUserFromContext(t *testing.T) { require.Equal(t, expected.UserID, actual.UserID) }) - t.Run("should return user set by HTTP ReqContext", func(t *testing.T) { + t.Run("should return user set as a requester", func(t *testing.T) { expected := testUser() - ctx := ctxkey.Set(context.Background(), &contextmodel.ReqContext{ - SignedInUser: expected, - }) + ctx := identity.WithRequester(context.Background(), expected) actual, err := appcontext.User(ctx) require.NoError(t, err) require.Equal(t, expected.UserID, actual.UserID) diff --git a/pkg/middleware/loggermw/logger.go b/pkg/middleware/loggermw/logger.go index 2262845a00d..e83129cec3a 100644 --- a/pkg/middleware/loggermw/logger.go +++ b/pkg/middleware/loggermw/logger.go @@ -19,11 +19,11 @@ import ( "errors" "fmt" "net/http" - "net/url" "time" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware/requestmeta" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/apimachinery/errutil" @@ -113,7 +113,7 @@ func (l *loggerImpl) prepareLogParams(c *contextmodel.ReqContext, duration time. "size", rw.Size(), } - referer, err := SanitizeURL(r.Referer()) + referer, err := util.SanitizeURI(r.Referer()) // We add an empty referer when there's a parsing error, hence this is before the err check. logParams = append(logParams, "referer", referer) if err != nil { @@ -153,27 +153,3 @@ func errorLogParams(err error) []any { "error", gfErr.LogMessage, } } - -var sensitiveQueryStrings = [...]string{ - "auth_token", -} - -func SanitizeURL(s string) (string, error) { - if s == "" { - return s, nil - } - - u, err := url.ParseRequestURI(s) - if err != nil { - return "", fmt.Errorf("failed to sanitize URL") - } - - // strip out sensitive query strings - values := u.Query() - for _, query := range sensitiveQueryStrings { - values.Del(query) - } - u.RawQuery = values.Encode() - - return u.String(), nil -} diff --git a/pkg/middleware/loggermw/logger_test.go b/pkg/middleware/loggermw/logger_test.go index 7f6bb5570c9..980886ff034 100644 --- a/pkg/middleware/loggermw/logger_test.go +++ b/pkg/middleware/loggermw/logger_test.go @@ -16,43 +16,6 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func Test_sanitizeURL(t *testing.T) { - tests := []struct { - name string - input string - want string - expectError bool - }{ - { - name: "Receiving empty string should return it", - input: "", - want: "", - }, - { - name: "Receiving valid URL string should return it parsed", - input: "https://grafana.com/", - want: "https://grafana.com/", - }, - { - name: "Receiving invalid URL string should return empty string", - input: "this is not a valid URL", - want: "", - expectError: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - url, err := SanitizeURL(tt.input) - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equalf(t, tt.want, url, "SanitizeURL(%v)", tt.input) - }) - } -} - func Test_prepareLog(t *testing.T) { type opts struct { Features []any diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index b109a52a56f..94c60f57509 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -51,12 +51,19 @@ func RequestMetrics(features featuremgmt.FeatureToggles, cfg *setting.Cfg, promR if features.IsEnabledGlobally(featuremgmt.FlagEnableNativeHTTPHistogram) { // the recommended default value from the prom_client // https://github.com/prometheus/client_golang/blob/main/prometheus/histogram.go#L411 - // Giving this variable an value means the client will expose the histograms as an - // native histogram instead of normal a normal histogram. + // Giving this variable a value means the client will expose a native + // histogram. histogramOptions.NativeHistogramBucketFactor = 1.1 // The default value in OTel. It probably good enough for us as well. histogramOptions.NativeHistogramMaxBucketNumber = 160 histogramOptions.NativeHistogramMinResetDuration = time.Hour + + if features.IsEnabledGlobally(featuremgmt.FlagDisableClassicHTTPHistogram) { + // setting Buckets to nil with native options set means the classic + // histogram will no longer be exposed - this can be a good way to + // reduce cardinality in the exposed metrics + histogramOptions.Buckets = nil + } } httpRequestDurationHistogram := prometheus.NewHistogramVec( diff --git a/pkg/registry/apis/alerting/notifications/register.go b/pkg/registry/apis/alerting/notifications/register.go new file mode 100644 index 00000000000..cb09efaa456 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/register.go @@ -0,0 +1,112 @@ +package notifications + +import ( + "context" + "fmt" + + "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/kube-openapi/pkg/common" + + notificationsModels "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/builder" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + timeInterval "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/timeinterval" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/ngalert" + "github.com/grafana/grafana/pkg/setting" +) + +var _ builder.APIGroupBuilder = (*NotificationsAPIBuilder)(nil) + +// This is used just so wire has something unique to return +type NotificationsAPIBuilder struct { + authz accesscontrol.AccessControl + ng *ngalert.AlertNG + namespacer request.NamespaceMapper + gv schema.GroupVersion +} + +func (t NotificationsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, toMode map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { + // Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go + return grafanarest.Mode0 +} + +func RegisterAPIService( + features featuremgmt.FeatureToggles, + apiregistration builder.APIRegistrar, + cfg *setting.Cfg, + ng *ngalert.AlertNG, +) *NotificationsAPIBuilder { + if ng.IsDisabled() || !features.IsEnabledGlobally(featuremgmt.FlagAlertingApiServer) { + return nil + } + builder := &NotificationsAPIBuilder{ + ng: ng, + namespacer: request.GetNamespaceMapper(cfg), + gv: notificationsModels.SchemeGroupVersion, + authz: ng.Api.AccessControl, + } + apiregistration.RegisterAPI(builder) + return builder +} + +func (t NotificationsAPIBuilder) GetGroupVersion() schema.GroupVersion { + return t.gv +} + +func (t NotificationsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + err := notificationsModels.AddToScheme(scheme) + if err != nil { + return err + } + return scheme.SetVersionPriority(notificationsModels.SchemeGroupVersion) +} + +func (t NotificationsAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, + optsGetter generic.RESTOptionsGetter, + desiredMode grafanarest.DualWriterMode, + reg prometheus.Registerer, +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(notificationsModels.GROUP, scheme, metav1.ParameterCodec, codecs) + + intervals, err := timeInterval.NewStorage(t.ng.Api.MuteTimings, t.namespacer, scheme, desiredMode, optsGetter, reg) + if err != nil { + return nil, fmt.Errorf("failed to initialize time-interval storage: %w", err) + } + + apiGroupInfo.VersionedResourcesStorageMap[notificationsModels.VERSION] = map[string]rest.Storage{ + notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals, + } + return &apiGroupInfo, nil +} + +func (t NotificationsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return notificationsModels.GetOpenAPIDefinitions +} + +func (t NotificationsAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return nil +} + +func (t NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer { + return authorizer.AuthorizerFunc( + func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + switch a.GetResource() { + case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource: + return timeInterval.Authorize(ctx, t.authz, a) + } + return authorizer.DecisionNoOpinion, "", nil + }) +} diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/authorize.go b/pkg/registry/apis/alerting/notifications/timeinterval/authorize.go new file mode 100644 index 00000000000..b5ccd9d9812 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/timeinterval/authorize.go @@ -0,0 +1,54 @@ +package timeinterval + +import ( + "context" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if attr.GetResource() != resourceInfo.GroupResource().Resource { + return authorizer.DecisionNoOpinion, "", nil + } + user, err := appcontext.User(ctx) + if err != nil { + return authorizer.DecisionDeny, "valid user is required", err + } + + var action accesscontrol.Evaluator + switch attr.GetVerb() { + case "patch": + fallthrough + case "create": + fallthrough + case "update": + action = accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTimeIntervalsWrite), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), + ) + case "deletecollection": + fallthrough + case "delete": + action = accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTimeIntervalsDelete), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), + ) + } + + eval := accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTimeIntervalsRead), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead), + ) + if action != nil { + eval = accesscontrol.EvalAll(eval, action) + } + + ok, err := ac.Evaluate(ctx, user, eval) + if ok { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "", err +} diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go b/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go new file mode 100644 index 00000000000..cacb6f4369a --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go @@ -0,0 +1,97 @@ +package timeinterval + +import ( + "encoding/json" + "fmt" + "hash/fnv" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func getIntervalUID(t definitions.MuteTimeInterval) string { + sum := fnv.New64() + _, _ = sum.Write([]byte(t.Name)) + return fmt.Sprintf("%016x", sum.Sum64()) +} + +func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval, namespacer request.NamespaceMapper) (*model.TimeIntervalList, error) { + data, err := json.Marshal(intervals) + if err != nil { + return nil, err + } + var specs []model.TimeIntervalSpec + err = json.Unmarshal(data, &specs) + if err != nil { + return nil, err + } + result := &model.TimeIntervalList{} + for idx := range specs { + interval := intervals[idx] + spec := specs[idx] + uid := getIntervalUID(interval) // TODO replace to stable UID when we switch to normal storage + result.Items = append(result.Items, model.TimeInterval{ + TypeMeta: resourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(uid), // TODO This is needed to make PATCH work + Name: uid, // TODO replace to stable UID when we switch to normal storage + Namespace: namespacer(orgID), + Annotations: map[string]string{ // TODO find a better place for provenance? + "grafana.com/provenance": string(interval.Provenance), + }, + ResourceVersion: interval.Version, + }, + Spec: spec, + }) + } + return result, nil +} + +func convertToK8sResource(orgID int64, interval definitions.MuteTimeInterval, namespacer request.NamespaceMapper) (*model.TimeInterval, error) { + data, err := json.Marshal(interval) + if err != nil { + return nil, err + } + spec := model.TimeIntervalSpec{} + err = json.Unmarshal(data, &spec) + if err != nil { + return nil, err + } + + uid := getIntervalUID(interval) // TODO replace to stable UID when we switch to normal storage + return &model.TimeInterval{ + TypeMeta: resourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(uid), // TODO This is needed to make PATCH work + Name: uid, // TODO replace to stable UID when we switch to normal storage + Namespace: namespacer(orgID), + Annotations: map[string]string{ // TODO find a better place for provenance? + "grafana.com/provenance": string(interval.Provenance), + }, + ResourceVersion: interval.Version, + }, + Spec: spec, + }, nil +} + +func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInterval, error) { + b, err := json.Marshal(interval.Spec) + if err != nil { + return definitions.MuteTimeInterval{}, err + } + result := definitions.MuteTimeInterval{} + err = json.Unmarshal(b, &result) + if err != nil { + return definitions.MuteTimeInterval{}, err + } + result.Version = interval.ResourceVersion + err = result.Validate() + if err != nil { + return definitions.MuteTimeInterval{}, err + } + return result, nil +} diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go new file mode 100644 index 00000000000..64d95e17a12 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go @@ -0,0 +1,205 @@ +package timeinterval + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + notifications "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +var ( + _ grafanaRest.LegacyStorage = (*legacyStorage)(nil) +) + +var resourceInfo = notifications.TimeIntervalResourceInfo + +type TimeIntervalService interface { + GetMuteTimings(ctx context.Context, orgID int64) ([]definitions.MuteTimeInterval, error) + GetMuteTiming(ctx context.Context, name string, orgID int64) (definitions.MuteTimeInterval, error) + CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error) + UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error) + DeleteMuteTiming(ctx context.Context, name string, orgID int64, provenance definitions.Provenance, version string) error +} + +type legacyStorage struct { + service TimeIntervalService + namespacer request.NamespaceMapper + tableConverter rest.TableConvertor +} + +func (s *legacyStorage) New() runtime.Object { + return resourceInfo.NewFunc() +} + +func (s *legacyStorage) Destroy() {} + +func (s *legacyStorage) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *legacyStorage) GetSingularName() string { + return resourceInfo.GetSingularName() +} + +func (s *legacyStorage) NewList() runtime.Object { + return resourceInfo.NewListFunc() +} + +func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions) (runtime.Object, error) { + orgId, err := request.OrgIDForList(ctx) + if err != nil { + return nil, err + } + + res, err := s.service.GetMuteTimings(ctx, orgId) + if err != nil { + return nil, err + } + + return convertToK8sResources(orgId, res, s.namespacer) +} + +func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + timings, err := s.service.GetMuteTimings(ctx, info.OrgID) + if err != nil { + return nil, err + } + + for _, mt := range timings { + if getIntervalUID(mt) == uid { + return convertToK8sResource(info.OrgID, mt, s.namespacer) + } + } + return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid) +} + +func (s *legacyStorage) Create(ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + _ *metav1.CreateOptions, +) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + if createValidation != nil { + if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { + return nil, err + } + } + p, ok := obj.(*notifications.TimeInterval) + if !ok { + return nil, fmt.Errorf("expected time-interval but got %s", obj.GetObjectKind().GroupVersionKind()) + } + if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user + return nil, errors.NewBadRequest("object's metadata.name should be empty") + } + model, err := convertToDomainModel(p) + if err != nil { + return nil, err + } + out, err := s.service.CreateMuteTiming(ctx, model, info.OrgID) + if err != nil { + return nil, err + } + return convertToK8sResource(info.OrgID, out, s.namespacer) +} + +func (s *legacyStorage) Update(ctx context.Context, + uid string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + _ bool, + _ *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + old, err := s.Get(ctx, uid, nil) + if err != nil { + return old, false, err + } + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return old, false, err + } + if updateValidation != nil { + if err := updateValidation(ctx, obj, old); err != nil { + return nil, false, err + } + } + p, ok := obj.(*notifications.TimeInterval) + if !ok { + return nil, false, fmt.Errorf("expected time-interval but got %s", obj.GetObjectKind().GroupVersionKind()) + } + interval, err := convertToDomainModel(p) + if err != nil { + return old, false, err + } + + if p.ObjectMeta.Name != getIntervalUID(interval) { + return nil, false, errors.NewBadRequest("title of cannot be changed. Consider creating a new resource.") + } + + updated, err := s.service.UpdateMuteTiming(ctx, interval, info.OrgID) + if err != nil { + return nil, false, err + } + + r, err := convertToK8sResource(info.OrgID, updated, s.namespacer) + return r, false, err +} + +// GracefulDeleter +func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + old, err := s.Get(ctx, uid, nil) + if err != nil { + return old, false, err + } + if deleteValidation != nil { + if err = deleteValidation(ctx, old); err != nil { + return nil, false, err + } + } + version := "" + if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil { + version = *options.Preconditions.ResourceVersion + } + p, ok := old.(*notifications.TimeInterval) + if !ok { + return nil, false, fmt.Errorf("expected time-interval but got %s", old.GetObjectKind().GroupVersionKind()) + } + + err = s.service.DeleteMuteTiming(ctx, p.Spec.Name, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option + return old, false, err // false - will be deleted async +} + +func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, errors.NewMethodNotSupported(resourceInfo.GroupResource(), "deleteCollection") +} diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go new file mode 100644 index 00000000000..6b627c0c5e5 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go @@ -0,0 +1,79 @@ +package timeinterval + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/prometheus/client_golang/prometheus" + + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/utils" +) + +var _ grafanarest.Storage = (*storage)(nil) + +type storage struct { + *genericregistry.Store +} + +func (s storage) Compare(storageObj, legacyObj runtime.Object) bool { + // TODO implement when supported dual write mode is not Mode0 + return false +} + +func NewStorage( + legacySvc TimeIntervalService, + namespacer request.NamespaceMapper, + scheme *runtime.Scheme, + desiredMode grafanarest.DualWriterMode, + optsGetter generic.RESTOptionsGetter, + reg prometheus.Registerer) (rest.Storage, error) { + legacyStore := &legacyStorage{ + service: legacySvc, + namespacer: namespacer, + tableConverter: utils.NewTableConverter( + resourceInfo.GroupResource(), + []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + // {Name: "Intervals", Type: "string", Format: "string", Description: "The display name"}, + }, + func(obj any) ([]interface{}, error) { + r, ok := obj.(*model.TimeInterval) + if ok { + return []interface{}{ + r.Name, + // r.Spec, //TODO implement formatting for Spec, same as UI? + }, nil + } + return nil, fmt.Errorf("expected resource or info") + }), + } + if optsGetter != nil && desiredMode != grafanarest.Mode0 { + strategy := grafanaregistry.NewStrategy(scheme) + s := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: legacyStore.tableConverter, + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} + if err := s.CompleteWithOptions(options); err != nil { + return nil, err + } + return grafanarest.NewDualWriter(desiredMode, legacyStore, storage{Store: s}, reg), nil + } + return legacyStore, nil +} diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 90b7de08755..a44271582af 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -4,10 +4,10 @@ import ( "context" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/example" "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/peakq" @@ -27,7 +27,6 @@ type Service struct{} func ProvideRegistryServiceSink( _ *dashboard.DashboardsAPIBuilder, _ *playlist.PlaylistAPIBuilder, - _ *example.TestingAPIBuilder, _ *dashboardsnapshot.SnapshotsAPIBuilder, _ *featuretoggle.FeatureFlagAPIBuilder, _ *datasource.DataSourceAPIBuilder, @@ -35,6 +34,7 @@ func ProvideRegistryServiceSink( _ *peakq.PeakQAPIBuilder, _ *scope.ScopeAPIBuilder, _ *query.QueryAPIBuilder, + _ *notifications.NotificationsAPIBuilder, ) *Service { return &Service{} } diff --git a/pkg/registry/apis/dashboard/access/sql_dashboards.go b/pkg/registry/apis/dashboard/access/sql_dashboards.go index 13db6037eb5..f043e84b9fb 100644 --- a/pkg/registry/apis/dashboard/access/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/access/sql_dashboards.go @@ -100,9 +100,6 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, query *DashboardQuery, if query.Requirements.ListDeleted { return nil, 0, fmt.Errorf("ListDeleted not yet supported") } - if len(query.Requirements.ListOriginKeys) > 0 { - return nil, 0, fmt.Errorf("ListOriginKeys not yet supported") - } token, err := readContinueToken(query) if err != nil { @@ -318,7 +315,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { var origin_name sql.NullString var origin_path sql.NullString var origin_ts sql.NullInt64 - var origin_key sql.NullString + var origin_hash sql.NullString var data []byte // the dashboard JSON var version int64 @@ -327,7 +324,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { &created, &createdByID, &createdByName, &updated, &updatedByID, &updatedByName, &plugin_id, - &origin_name, &origin_path, &origin_key, &origin_ts, + &origin_name, &origin_path, &origin_hash, &origin_ts, &version, &row.Title, &data, ) @@ -370,7 +367,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { meta.SetOriginInfo(&utils.ResourceOriginInfo{ Name: origin_name.String, Path: originPath, - Key: origin_key.String, + Hash: origin_hash.String, Timestamp: &ts, }) } else if plugin_id != "" { diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go index 54a772591d5..f98c57d1383 100644 --- a/pkg/registry/apis/dashboard/authorizer.go +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -5,8 +5,8 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" @@ -19,7 +19,7 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionNoOpinion, "", nil } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, "", err } @@ -27,7 +27,7 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { if attr.GetName() == "" { // Discourage use of the "list" command for non super admin users if attr.GetVerb() == "list" && attr.GetResource() == v0alpha1.DashboardResourceInfo.GroupResource().Resource { - if !user.IsGrafanaAdmin { + if !user.GetIsGrafanaAdmin() { return authorizer.DecisionDeny, "list summary objects (or connect as GrafanaAdmin)", err } } diff --git a/pkg/registry/apis/dashboardsnapshot/sql_storage.go b/pkg/registry/apis/dashboardsnapshot/sql_storage.go index b21231a8613..26fdb8fff57 100644 --- a/pkg/registry/apis/dashboardsnapshot/sql_storage.go +++ b/pkg/registry/apis/dashboardsnapshot/sql_storage.go @@ -9,8 +9,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/identity" dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" ) @@ -73,7 +73,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } diff --git a/pkg/registry/apis/datasource/authorizer.go b/pkg/registry/apis/datasource/authorizer.go index 354325a65b1..570b438d808 100644 --- a/pkg/registry/apis/datasource/authorizer.go +++ b/pkg/registry/apis/datasource/authorizer.go @@ -6,7 +6,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" ) @@ -17,7 +17,7 @@ func (b *DataSourceAPIBuilder) GetAuthorizer() authorizer.Authorizer { if !attr.IsResourceRequest() { return authorizer.DecisionNoOpinion, "", nil } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, "valid user is required", err } diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go index 5871b45ab4c..aa9c01954da 100644 --- a/pkg/registry/apis/datasource/plugincontext.go +++ b/pkg/registry/apis/datasource/plugincontext.go @@ -7,9 +7,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" @@ -84,7 +84,7 @@ func (q *scopedDatasourceProvider) Get(ctx context.Context, uid string) (*v0alph if err != nil { return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } diff --git a/pkg/registry/apis/datasource/querier.go b/pkg/registry/apis/datasource/querier.go index 5811dbb8378..0a5806a4616 100644 --- a/pkg/registry/apis/datasource/querier.go +++ b/pkg/registry/apis/datasource/querier.go @@ -7,8 +7,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" @@ -108,7 +108,7 @@ func (q *DefaultQuerier) Datasource(ctx context.Context, name string) (*v0alpha1 if err != nil { return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } diff --git a/pkg/registry/apis/example/dummy_storage.go b/pkg/registry/apis/example/dummy_storage.go deleted file mode 100644 index 574a87dd527..00000000000 --- a/pkg/registry/apis/example/dummy_storage.go +++ /dev/null @@ -1,128 +0,0 @@ -package example - -import ( - "context" - "fmt" - "slices" - - "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - "k8s.io/apiserver/pkg/registry/rest" - - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" -) - -var ( - _ rest.Storage = (*dummyStorage)(nil) - _ rest.Scoper = (*dummyStorage)(nil) - _ rest.SingularNameProvider = (*dummyStorage)(nil) - _ rest.Getter = (*dummyStorage)(nil) - _ rest.Lister = (*dummyStorage)(nil) -) - -type dummyStorage struct { - store *genericregistry.Store - names []string - creationTimestamp metav1.Time -} - -func newDummyStorage(gv schema.GroupVersion, scheme *runtime.Scheme, names ...string) *dummyStorage { - var resourceInfo = example.DummyResourceInfo - strategy := grafanaregistry.NewStrategy(scheme) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - - return &dummyStorage{ - store: store, - names: names, - creationTimestamp: metav1.Now(), - } -} - -func (s *dummyStorage) New() runtime.Object { - return s.store.New() -} - -func (s *dummyStorage) Destroy() {} - -func (s *dummyStorage) NamespaceScoped() bool { - return true -} - -func (s *dummyStorage) GetSingularName() string { - return example.DummyResourceInfo.GetSingularName() -} - -func (s *dummyStorage) NewList() runtime.Object { - return s.store.NewListFunc() -} - -func (s *dummyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return s.store.TableConvertor.ConvertToTable(ctx, object, tableOptions) -} - -func (s *dummyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - info, err := grafanarequest.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - idx := slices.Index(s.names, name) - if idx < 0 { - return nil, fmt.Errorf("dummy not found") - } - - return &example.DummyResource{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: info.Value, - CreationTimestamp: s.creationTimestamp, - ResourceVersion: "1", - }, - Spec: common.Unstructured{ - Object: map[string]any{ - "Dummy": name, - }, - }, - }, nil -} - -func (s *dummyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - info, err := grafanarequest.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - res := &example.DummyResourceList{} - for _, name := range s.names { - res.Items = append(res.Items, example.DummyResource{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: info.Value, - CreationTimestamp: s.creationTimestamp, - ResourceVersion: "1", - }, - Spec: common.Unstructured{ - Object: map[string]any{ - "Dummy": name, - }, - }, - }) - } - return res, nil -} diff --git a/pkg/registry/apis/example/register.go b/pkg/registry/apis/example/register.go deleted file mode 100644 index edb2a90bbad..00000000000 --- a/pkg/registry/apis/example/register.go +++ /dev/null @@ -1,226 +0,0 @@ -package example - -import ( - "context" - "fmt" - "net/http" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/runtime/serializer" - "k8s.io/apiserver/pkg/authorization/authorizer" - "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" - "k8s.io/apiserver/pkg/endpoints/request" - "k8s.io/apiserver/pkg/registry/generic" - "k8s.io/apiserver/pkg/registry/rest" - genericapiserver "k8s.io/apiserver/pkg/server" - common "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" - "github.com/grafana/grafana/pkg/apiserver/builder" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" - "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/prometheus/client_golang/prometheus" -) - -var _ builder.APIGroupBuilder = (*TestingAPIBuilder)(nil) - -// This is used just so wire has something unique to return -type TestingAPIBuilder struct { - codecs serializer.CodecFactory - gv schema.GroupVersion -} - -func NewTestingAPIBuilder() *TestingAPIBuilder { - return &TestingAPIBuilder{ - gv: schema.GroupVersion{Group: example.GROUP, Version: example.VERSION}, - } -} - -func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, reg prometheus.Registerer) *TestingAPIBuilder { - if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { - return nil // skip registration unless opting into experimental apis - } - builder := NewTestingAPIBuilder() - apiregistration.RegisterAPI(builder) - return builder -} - -func (b *TestingAPIBuilder) GetGroupVersion() schema.GroupVersion { - return b.gv -} - -func (b *TestingAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { - // Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go - return grafanarest.Mode0 -} - -func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { - scheme.AddKnownTypes(gv, - &example.RuntimeInfo{}, - &example.DummyResource{}, - &example.DummyResourceList{}, - &example.DummySubresource{}, - ) -} - -func (b *TestingAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - addKnownTypes(scheme, b.gv) - - // Link this version to the internal representation. - // This is used for server-side-apply (PATCH), and avoids the error: - // "no kind is registered for the type" - addKnownTypes(scheme, schema.GroupVersion{ - Group: b.gv.Group, - Version: runtime.APIVersionInternal, - }) - - // If multiple versions exist, then register conversions from zz_generated.conversion.go - // if err := playlist.RegisterConversions(scheme); err != nil { - // return err - // } - metav1.AddToGroupVersion(scheme, b.gv) - return scheme.SetVersionPriority(b.gv) -} - -func (b *TestingAPIBuilder) GetAPIGroupInfo( - scheme *runtime.Scheme, - codecs serializer.CodecFactory, // pointer? - _ generic.RESTOptionsGetter, - _ grafanarest.DualWriterMode, - _ prometheus.Registerer, -) (*genericapiserver.APIGroupInfo, error) { - b.codecs = codecs - apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(b.gv.Group, scheme, metav1.ParameterCodec, codecs) - - storage := map[string]rest.Storage{} - storage[example.RuntimeResourceInfo.StoragePath()] = newDeploymentInfoStorage(b.gv, scheme) - storage[example.DummyResourceInfo.StoragePath()] = newDummyStorage(b.gv, scheme, "test1", "test2", "test3") - storage[example.DummyResourceInfo.StoragePath("sub")] = &dummySubresourceREST{} - apiGroupInfo.VersionedResourcesStorageMap[b.gv.Version] = storage - return &apiGroupInfo, nil -} - -func (b *TestingAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { - return example.GetOpenAPIDefinitions -} - -// Register additional routes with the server -func (b *TestingAPIBuilder) GetAPIRoutes() *builder.APIRoutes { - return &builder.APIRoutes{ - Root: []builder.APIRouteHandler{ - { - Path: "aaa", - Spec: &spec3.PathProps{ - Summary: "an example at the root level", - Description: "longer description here?", - Get: &spec3.Operation{ - OperationProps: spec3.OperationProps{ - Parameters: []*spec3.Parameter{ - {ParameterProps: spec3.ParameterProps{ - Name: "a", - }}, - }, - Responses: &spec3.Responses{ - ResponsesProps: spec3.ResponsesProps{ - StatusCodeResponses: map[int]*spec3.Response{ - 200: { - ResponseProps: spec3.ResponseProps{ - Description: "OK", - Content: map[string]*spec3.MediaType{ - "text/plain": { - MediaTypeProps: spec3.MediaTypeProps{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - Handler: func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("Root level handler (aaa)")) - }, - }, - { - Path: "bbb", - Spec: &spec3.PathProps{ - Summary: "an example at the root level", - Description: "longer description here?", - Get: &spec3.Operation{ - OperationProps: spec3.OperationProps{ - Parameters: []*spec3.Parameter{ - {ParameterProps: spec3.ParameterProps{ - Name: "b", - }}, - }, - }, - }, - }, - Handler: func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("Root level handler (bbb)")) - }, - }, - }, - Namespace: []builder.APIRouteHandler{ - { - Path: "ccc", - Spec: &spec3.PathProps{ - Summary: "an example at the root level", - Description: "longer description here?", - Get: &spec3.Operation{ - OperationProps: spec3.OperationProps{ - Parameters: []*spec3.Parameter{ - {ParameterProps: spec3.ParameterProps{ - Name: "a", - }}, - }, - }, - }, - }, - Handler: func(w http.ResponseWriter, r *http.Request) { - info, ok := request.RequestInfoFrom(r.Context()) - if !ok { - responsewriters.ErrorNegotiated( - apierrors.NewInternalError(fmt.Errorf("no RequestInfo found in the context")), - b.codecs, schema.GroupVersion{}, w, r, - ) - return - } - - _, _ = w.Write([]byte("Custom namespace route ccc: " + info.Namespace)) - }, - }, - }, - } -} - -func (b *TestingAPIBuilder) GetAuthorizer() authorizer.Authorizer { - return authorizer.AuthorizerFunc( - func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - // require a user - _, err = appcontext.User(ctx) - if err != nil { - return authorizer.DecisionDeny, "valid user is required", err - } - - return authorizer.DecisionNoOpinion, "", err // fallback to org/role logic - }) -} diff --git a/pkg/registry/apis/example/storage.go b/pkg/registry/apis/example/storage.go deleted file mode 100644 index 4e2c1858837..00000000000 --- a/pkg/registry/apis/example/storage.go +++ /dev/null @@ -1,86 +0,0 @@ -package example - -import ( - "context" - "time" - - "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - "k8s.io/apiserver/pkg/registry/rest" - - example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - "github.com/grafana/grafana/pkg/setting" -) - -var ( - _ rest.Storage = (*staticStorage)(nil) - _ rest.Scoper = (*staticStorage)(nil) - _ rest.SingularNameProvider = (*staticStorage)(nil) - _ rest.Lister = (*staticStorage)(nil) -) - -type staticStorage struct { - Store *genericregistry.Store - info example.RuntimeInfo -} - -func newDeploymentInfoStorage(gv schema.GroupVersion, scheme *runtime.Scheme) *staticStorage { - var resourceInfo = example.RuntimeResourceInfo - strategy := grafanaregistry.NewStrategy(scheme) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - - return &staticStorage{ - Store: store, - info: example.RuntimeInfo{ - TypeMeta: example.RuntimeResourceInfo.TypeMeta(), - BuildVersion: setting.BuildVersion, - BuildCommit: setting.BuildCommit, - BuildBranch: setting.BuildBranch, - EnterpriseBuildCommit: setting.EnterpriseBuildCommit, - BuildStamp: setting.BuildStamp, - IsEnterprise: setting.IsEnterprise, - Packaging: setting.Packaging, - StartupTime: time.Now().UnixMilli(), - }, - } -} - -func (s *staticStorage) New() runtime.Object { - return s.Store.New() -} - -func (s *staticStorage) Destroy() {} - -func (s *staticStorage) NamespaceScoped() bool { - return false -} - -func (s *staticStorage) GetSingularName() string { - return example.RuntimeResourceInfo.GetSingularName() -} - -func (s *staticStorage) NewList() runtime.Object { - return s.Store.NewListFunc() -} - -func (s *staticStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return s.Store.TableConvertor.ConvertToTable(ctx, object, tableOptions) -} - -func (s *staticStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - return &s.info, nil -} diff --git a/pkg/registry/apis/example/subresource.go b/pkg/registry/apis/example/subresource.go deleted file mode 100644 index 63fdcab5f02..00000000000 --- a/pkg/registry/apis/example/subresource.go +++ /dev/null @@ -1,54 +0,0 @@ -package example - -import ( - "context" - "fmt" - "net/http" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" - - example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" -) - -type dummySubresourceREST struct{} - -var _ = rest.Connecter(&dummySubresourceREST{}) - -func (r *dummySubresourceREST) New() runtime.Object { - return &example.DummySubresource{} -} - -func (r *dummySubresourceREST) Destroy() { -} - -func (r *dummySubresourceREST) ConnectMethods() []string { - return []string{"GET"} -} - -func (r *dummySubresourceREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" // true means you can use the trailing path as a variable -} - -func (r *dummySubresourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - user, err := appcontext.User(ctx) - if err != nil { - return nil, err - } - - // This response object format is negotiated by k8s - dummy := &example.DummySubresource{ - Info: fmt.Sprintf("%s/%s", info.Value, user.Login), - } - - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - responder.Object(http.StatusOK, dummy) - }), nil -} diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 1c4b8435379..d7f2afe02c6 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -33,7 +33,7 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) if v.ID > 0 { // nolint:staticcheck meta.SetOriginInfo(&utils.ResourceOriginInfo{ Name: "SQL", - Key: fmt.Sprintf("%d", v.ID), // nolint:staticcheck + Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck }) } if v.CreatedBy > 0 { diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index 5baedfdcefa..4802f2a2c86 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -11,9 +11,9 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/apiserver/storage/entity" "github.com/grafana/grafana/pkg/services/dashboards" @@ -83,7 +83,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } @@ -116,7 +116,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } @@ -146,7 +146,7 @@ func (s *legacyStorage) Create(ctx context.Context, return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } @@ -195,7 +195,7 @@ func (s *legacyStorage) Update(ctx context.Context, return nil, false, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, false, err } @@ -267,7 +267,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio if err != nil { return v, false, err // includes the not-found error } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, false, err } diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index d658c68db49..5b652dfb209 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -15,11 +16,11 @@ import ( common "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/apiserver/builder" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" @@ -27,7 +28,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/setting" - "github.com/prometheus/client_golang/prometheus" ) var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil) @@ -190,7 +190,7 @@ func (b *FolderAPIBuilder) GetAuthorizer() authorizer.Authorizer { } // require a user - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, "valid user is required", err } diff --git a/pkg/registry/apis/folders/sub_access.go b/pkg/registry/apis/folders/sub_access.go index be506be9ece..3e7319f88a0 100644 --- a/pkg/registry/apis/folders/sub_access.go +++ b/pkg/registry/apis/folders/sub_access.go @@ -7,8 +7,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" @@ -49,7 +49,7 @@ func (r *subAccessREST) Connect(ctx context.Context, name string, opts runtime.O if err != nil { return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } diff --git a/pkg/registry/apis/folders/sub_count.go b/pkg/registry/apis/folders/sub_count.go index f3b7d7e61b1..9f89b6a933d 100644 --- a/pkg/registry/apis/folders/sub_count.go +++ b/pkg/registry/apis/folders/sub_count.go @@ -7,8 +7,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/folder" ) @@ -46,7 +46,7 @@ func (r *subCountREST) NewConnectOptions() (runtime.Object, bool, string) { } func (r *subCountREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } diff --git a/pkg/registry/apis/playlist/conversions.go b/pkg/registry/apis/playlist/conversions.go index a5a00313157..89a8616cfe1 100644 --- a/pkg/registry/apis/playlist/conversions.go +++ b/pkg/registry/apis/playlist/conversions.go @@ -95,7 +95,7 @@ func convertToK8sResource(v *playlistsvc.PlaylistDTO, namespacer request.Namespa createdAt := time.UnixMilli(v.CreatedAt).UTC() meta.SetOriginInfo(&utils.ResourceOriginInfo{ Name: "SQL", - Key: fmt.Sprintf("%d", v.Id), + Path: fmt.Sprintf("%d", v.Id), Timestamp: &createdAt, }) } @@ -133,7 +133,7 @@ func getLegacyID(item *unstructured.Unstructured) int64 { } info, _ := meta.GetOriginInfo() if info != nil && info.Name == "SQL" { - i, err := strconv.ParseInt(info.Key, 10, 64) + i, err := strconv.ParseInt(info.Path, 10, 64) if err == nil { return i } diff --git a/pkg/registry/apis/playlist/conversions_test.go b/pkg/registry/apis/playlist/conversions_test.go index d95ae123e8f..6c42cd40d0b 100644 --- a/pkg/registry/apis/playlist/conversions_test.go +++ b/pkg/registry/apis/playlist/conversions_test.go @@ -42,7 +42,7 @@ func TestPlaylistConversion(t *testing.T) { "resourceVersion": "54321", "creationTimestamp": "1970-01-01T00:00:12Z", "annotations": { - "grafana.app/originKey": "123", + "grafana.app/originPath": "123", "grafana.app/originName": "SQL", "grafana.app/originTimestamp":"1970-01-01T00:00:12Z", "grafana.app/updatedTimestamp": "1970-01-01T00:00:54Z" diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index 755898f03b7..3e379808316 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -17,6 +17,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" @@ -77,7 +78,7 @@ func (r *queryREST) NewConnectOptions() (runtime.Object, bool, string) { return nil, false, "" // true means you can use the trailing path as a variable } -func (r *queryREST) Connect(ctx context.Context, name string, opts runtime.Object, incomingResponder rest.Responder) (http.Handler, error) { +func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.Object, incomingResponder rest.Responder) (http.Handler, error) { // See: /pkg/apiserver/builder/helper.go#L34 // The name is set with a rewriter hack if name != "name" { @@ -88,6 +89,7 @@ func (r *queryREST) Connect(ctx context.Context, name string, opts runtime.Objec return http.HandlerFunc(func(w http.ResponseWriter, httpreq *http.Request) { ctx, span := b.tracer.Start(httpreq.Context(), "QueryService.Query") defer span.End() + ctx = request.WithNamespace(ctx, request.NamespaceValue(connectCtx)) responder := newResponderWrapper(incomingResponder, func(statusCode int, obj runtime.Object) { @@ -116,7 +118,6 @@ func (r *queryREST) Connect(ctx context.Context, name string, opts runtime.Objec responder.Error(err) return } - // Parses the request and splits it into multiple sub queries (if necessary) req, err := b.parser.parseRequest(ctx, raw) if err != nil { diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index e2bd94e9e7c..2ce96e4af0d 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -3,10 +3,10 @@ package apiregistry import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/example" "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/peakq" @@ -28,7 +28,6 @@ var WireSet = wire.NewSet( // Each must be added here *and* in the ServiceSink above playlist.RegisterAPIService, dashboard.RegisterAPIService, - example.RegisterAPIService, dashboardsnapshot.RegisterAPIService, featuretoggle.RegisterAPIService, datasource.RegisterAPIService, @@ -37,4 +36,5 @@ var WireSet = wire.NewSet( service.RegisterAPIService, query.RegisterAPIService, scope.RegisterAPIService, + notifications.RegisterAPIService, ) diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 8076ef98fe6..7e68c0d8091 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -450,8 +450,9 @@ const ( ActionAlertingNotificationsWrite = "alert.notifications:write" // Alerting notifications time interval actions - ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read" - ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write" + ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read" + ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write" + ActionAlertingNotificationsTimeIntervalsDelete = "alert.notifications.time-intervals:delete" // Alerting receiver actions ActionAlertingReceiversList = "alert.notifications.receivers:list" diff --git a/pkg/services/accesscontrol/pluginutils/utils.go b/pkg/services/accesscontrol/pluginutils/utils.go index ffbde39295f..7db8cf635a0 100644 --- a/pkg/services/accesscontrol/pluginutils/utils.go +++ b/pkg/services/accesscontrol/pluginutils/utils.go @@ -9,20 +9,36 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" ) +var ( + allowedCoreActions = map[string]string{ + "plugins.app:access": "plugins:id:", + "folders:create": "folders:uid:", + "folders:read": "folders:uid:", + "folders:write": "folders:uid:", + "folders:delete": "folders:uid:", + "folders.permissions:read": "folders:uid:", + "folders.permissions:write": "folders:uid:", + } +) + // ValidatePluginPermissions errors when a permission does not match expected pattern for plugins func ValidatePluginPermissions(pluginID string, permissions []ac.Permission) error { for i := range permissions { - if permissions[i].Action != pluginaccesscontrol.ActionAppAccess && - !strings.HasPrefix(permissions[i].Action, pluginID+":") && + scopePrefix, isCore := allowedCoreActions[permissions[i].Action] + if isCore { + if permissions[i].Scope != scopePrefix+pluginID { + return &ac.ErrorScopeTarget{Action: permissions[i].Action, Scope: permissions[i].Scope, + ExpectedScope: scopePrefix + pluginID} + } + // Prevent any unlikely injection + permissions[i].Scope = scopePrefix + pluginID + continue + } + if !strings.HasPrefix(permissions[i].Action, pluginID+":") && !strings.HasPrefix(permissions[i].Action, pluginID+".") { return &ac.ErrorActionPrefixMissing{Action: permissions[i].Action, Prefixes: []string{pluginaccesscontrol.ActionAppAccess, pluginID + ":", pluginID + "."}} } - if strings.HasPrefix(permissions[i].Action, pluginaccesscontrol.ActionAppAccess) && - permissions[i].Scope != pluginaccesscontrol.ScopeProvider.GetResourceScope(pluginID) { - return &ac.ErrorScopeTarget{Action: permissions[i].Action, Scope: permissions[i].Scope, - ExpectedScope: pluginaccesscontrol.ScopeProvider.GetResourceScope(pluginID)} - } } return nil diff --git a/pkg/services/accesscontrol/pluginutils/utils_test.go b/pkg/services/accesscontrol/pluginutils/utils_test.go index 38ae6f4765c..80289ec77e7 100644 --- a/pkg/services/accesscontrol/pluginutils/utils_test.go +++ b/pkg/services/accesscontrol/pluginutils/utils_test.go @@ -149,6 +149,29 @@ func TestValidatePluginRole(t *testing.T) { }, wantErr: &ac.ErrorInvalidRole{}, }, + { + name: "valid core permission targets plugin", + pluginID: "test-app", + role: ac.RoleDTO{ + Name: "plugins:test-app:reader", + DisplayName: "Plugin Folder Reader", + Permissions: []ac.Permission{ + {Action: "folders:read", Scope: "folders:uid:test-app"}, + }, + }, + }, + { + name: "invalid core permission targets other plugin", + pluginID: "test-app", + role: ac.RoleDTO{ + Name: "plugins:test-app:reader", + DisplayName: "Plugin Folder Reader", + Permissions: []ac.Permission{ + {Action: "folders:read", Scope: "folders:uid:other-app"}, + }, + }, + wantErr: &ac.ErrorInvalidRole{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/services/apiserver/auth/authenticator/signedinuser.go b/pkg/services/apiserver/auth/authenticator/signedinuser.go index fceecd02e8a..2c3e568f0e8 100644 --- a/pkg/services/apiserver/auth/authenticator/signedinuser.go +++ b/pkg/services/apiserver/auth/authenticator/signedinuser.go @@ -8,38 +8,38 @@ import ( k8suser "k8s.io/apiserver/pkg/authentication/user" "k8s.io/klog/v2" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) var _ authenticator.RequestFunc = signedInUserAuthenticator func signedInUserAuthenticator(req *http.Request) (*authenticator.Response, bool, error) { ctx := req.Context() - signedInUser, err := appcontext.User(ctx) + signedInUser, err := identity.GetRequester(ctx) if err != nil { klog.V(5).Info("failed to get signed in user", "err", err) return nil, false, nil } userInfo := &k8suser.DefaultInfo{ - Name: signedInUser.Login, - UID: signedInUser.UserUID, + Name: signedInUser.GetLogin(), + UID: signedInUser.GetUID().ID(), Groups: []string{}, // In order to faithfully round-trip through an impersonation flow, Extra keys MUST be lowercase. // see: https://pkg.go.dev/k8s.io/apiserver@v0.27.1/pkg/authentication/user#Info Extra: map[string][]string{}, } - for _, v := range signedInUser.Teams { + for _, v := range signedInUser.GetTeams() { userInfo.Groups = append(userInfo.Groups, strconv.FormatInt(v, 10)) } // - if signedInUser.IDToken != "" { - userInfo.Extra["id-token"] = []string{signedInUser.IDToken} + if signedInUser.GetIDToken() != "" { + userInfo.Extra["id-token"] = []string{signedInUser.GetIDToken()} } - if signedInUser.OrgRole.IsValid() { - userInfo.Extra["user-instance-role"] = []string{string(signedInUser.OrgRole)} + if signedInUser.GetOrgRole().IsValid() { + userInfo.Extra["user-instance-role"] = []string{string(signedInUser.GetOrgRole())} } return &authenticator.Response{ diff --git a/pkg/services/apiserver/auth/authorizer/org_id.go b/pkg/services/apiserver/auth/authorizer/org_id.go index f73d3b59b32..f2630922280 100644 --- a/pkg/services/apiserver/auth/authorizer/org_id.go +++ b/pkg/services/apiserver/auth/authorizer/org_id.go @@ -6,7 +6,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/org" @@ -27,7 +27,7 @@ func newOrgIDAuthorizer(orgService org.Service) *orgIDAuthorizer { } func (auth orgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { - signedInUser, err := appcontext.User(ctx) + signedInUser, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil } @@ -51,12 +51,16 @@ func (auth orgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut } // Quick check that the same org is used - if signedInUser.OrgID == info.OrgID { + if signedInUser.GetOrgID() == info.OrgID { return authorizer.DecisionNoOpinion, "", nil } // Check if the user has access to the specified org - query := org.GetUserOrgListQuery{UserID: signedInUser.UserID} + userId, err := signedInUser.GetID().UserID() + if err != nil { + return authorizer.DecisionDeny, "unable to get userId", err + } + query := org.GetUserOrgListQuery{UserID: userId} result, err := auth.org.GetUserOrgList(ctx, &query) if err != nil { return authorizer.DecisionDeny, "error getting user org list", err @@ -68,5 +72,5 @@ func (auth orgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut } } - return authorizer.DecisionDeny, fmt.Sprintf("user %d is not a member of org %d", signedInUser.UserID, info.OrgID), nil + return authorizer.DecisionDeny, fmt.Sprintf("user %d is not a member of org %d", userId, info.OrgID), nil } diff --git a/pkg/services/apiserver/auth/authorizer/org_role.go b/pkg/services/apiserver/auth/authorizer/org_role.go index 4717741a8d5..9c03f3ba795 100644 --- a/pkg/services/apiserver/auth/authorizer/org_role.go +++ b/pkg/services/apiserver/auth/authorizer/org_role.go @@ -6,7 +6,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" ) @@ -22,12 +22,13 @@ func newOrgRoleAuthorizer(orgService org.Service) *orgRoleAuthorizer { } func (auth orgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { - signedInUser, err := appcontext.User(ctx) + signedInUser, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil } - switch signedInUser.OrgRole { + orgRole := signedInUser.GetOrgRole() + switch orgRole { case org.RoleAdmin: return authorizer.DecisionAllow, "", nil case org.RoleEditor: @@ -35,21 +36,21 @@ func (auth orgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib case "get", "list", "watch", "create", "update", "patch", "delete", "put", "post": return authorizer.DecisionAllow, "", nil default: - return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(string(signedInUser.OrgRole), a), nil + return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } case org.RoleViewer: switch a.GetVerb() { case "get", "list", "watch": return authorizer.DecisionAllow, "", nil default: - return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(string(signedInUser.OrgRole), a), nil + return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } case org.RoleNone: - return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(string(signedInUser.OrgRole), a), nil + return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } return authorizer.DecisionDeny, "", nil } -func errorMessageForGrafanaOrgRole(grafanaOrgRole string, a authorizer.Attributes) string { - return fmt.Sprintf("Grafana org role (%s) didn't allow %s access on requested resource=%s, path=%s", grafanaOrgRole, a.GetVerb(), a.GetResource(), a.GetPath()) +func errorMessageForGrafanaOrgRole(orgRole identity.RoleType, a authorizer.Attributes) string { + return fmt.Sprintf("Grafana org role (%s) didn't allow %s access on requested resource=%s, path=%s", orgRole, a.GetVerb(), a.GetResource(), a.GetPath()) } diff --git a/pkg/services/apiserver/auth/authorizer/stack_id.go b/pkg/services/apiserver/auth/authorizer/stack_id.go index da63c25d5ba..af4c309e226 100644 --- a/pkg/services/apiserver/auth/authorizer/stack_id.go +++ b/pkg/services/apiserver/auth/authorizer/stack_id.go @@ -6,7 +6,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" grafanarequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/setting" @@ -27,7 +27,7 @@ func newStackIDAuthorizer(cfg *setting.Cfg) *stackIDAuthorizer { } func (auth stackIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { - signedInUser, err := appcontext.User(ctx) + signedInUser, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil } @@ -48,7 +48,7 @@ func (auth stackIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib if info.OrgID != 1 { return authorizer.DecisionDeny, "cloud instance requires org 1", nil } - if signedInUser.OrgID != 1 { + if signedInUser.GetOrgID() != 1 { return authorizer.DecisionDeny, "user must be in org 1", nil } diff --git a/pkg/services/apiserver/endpoints/request/namespace.go b/pkg/services/apiserver/endpoints/request/namespace.go index 899e342dc9f..2a4c768673b 100644 --- a/pkg/services/apiserver/endpoints/request/namespace.go +++ b/pkg/services/apiserver/endpoints/request/namespace.go @@ -8,7 +8,7 @@ import ( "k8s.io/apiserver/pkg/endpoints/request" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/setting" ) @@ -82,9 +82,9 @@ func ParseNamespace(ns string) (NamespaceInfo, error) { func OrgIDForList(ctx context.Context) (int64, error) { ns := request.NamespaceValue(ctx) if ns == "" { - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if user != nil { - return user.OrgID, err + return user.GetOrgID(), err } return -1, err } diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 339b52bfd05..c50b5582430 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -157,6 +157,11 @@ func ProvideService( req.URL.Path = "/" } + if c.SignedInUser != nil { + ctx := appcontext.WithUser(req.Context(), c.SignedInUser) + req = req.WithContext(ctx) + } + resp := responsewriter.WrapForHTTP1Or2(c.Resp) s.handler.ServeHTTP(resp, req) } diff --git a/pkg/services/apiserver/standalone/factory.go b/pkg/services/apiserver/standalone/factory.go index 34ae2316813..a2c413d69a9 100644 --- a/pkg/services/apiserver/standalone/factory.go +++ b/pkg/services/apiserver/standalone/factory.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/example" "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/query" "github.com/grafana/grafana/pkg/registry/apis/query/client" @@ -75,9 +74,6 @@ func (p *DummyAPIFactory) MakeAPIServer(_ context.Context, tracer tracing.Tracer } switch gv.Group { - case "example.grafana.app": - return example.NewTestingAPIBuilder(), nil - // Only works with testdata case "query.grafana.app": return query.NewQueryAPIBuilder( diff --git a/pkg/services/apiserver/storage/entity/selector.go b/pkg/services/apiserver/storage/entity/selector.go index 7bd946a60c7..a67ab4bc2b5 100644 --- a/pkg/services/apiserver/storage/entity/selector.go +++ b/pkg/services/apiserver/storage/entity/selector.go @@ -21,8 +21,6 @@ type Requirements struct { ListDeleted bool // ListHistory is a resource name to list the history of ListHistory string - // ListOriginKeys needs to include the origin key of a given entity in order for it to be selected. - ListOriginKeys []string } func ReadLabelSelectors(selector labels.Selector) (Requirements, labels.Selector, error) { @@ -48,11 +46,6 @@ func ReadLabelSelectors(selector labels.Selector) (Requirements, labels.Selector return requirements, newSelector, apierrors.NewBadRequest(SortByKey + " label selector only supports in") } requirements.SortBy = r.Values().List() - case utils.AnnoKeyOriginKey: - if r.Operator() != selection.In { - return requirements, newSelector, apierrors.NewBadRequest(SortByKey + " label selector only supports in") - } - requirements.ListOriginKeys = r.Values().List() case ListDeletedKey: if r.Operator() != selection.Equals { return requirements, newSelector, apierrors.NewBadRequest(ListDeletedKey + " label selector only supports equality") diff --git a/pkg/services/apiserver/storage/entity/storage.go b/pkg/services/apiserver/storage/entity/storage.go index 67884541b1e..2af0bc4d4ee 100644 --- a/pkg/services/apiserver/storage/entity/storage.go +++ b/pkg/services/apiserver/storage/entity/storage.go @@ -100,7 +100,7 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou return fmt.Errorf("this was not a create operation... (%s)", rsp.Status.String()) } - err = entityToResource(rsp.Entity, out, s.codec) + err = EntityToRuntimeObject(rsp.Entity, out, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -140,7 +140,7 @@ func (s *Storage) Delete(ctx context.Context, key string, out runtime.Object, pr return err } - err = entityToResource(rsp.Entity, out, s.codec) + err = EntityToRuntimeObject(rsp.Entity, out, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -328,7 +328,7 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, return apierrors.NewNotFound(s.gr, k.Name) } - err = entityToResource(rsp, objPtr, s.codec) + err = EntityToRuntimeObject(rsp, objPtr, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -394,7 +394,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti for _, r := range rsp.Versions { res := s.newFunc() - err := entityToResource(r, res, s.codec) + err := EntityToRuntimeObject(r, res, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -442,9 +442,6 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti if len(requirements.SortBy) > 0 { req.Sort = requirements.SortBy } - if len(requirements.ListOriginKeys) > 0 { - req.OriginKeys = requirements.ListOriginKeys - } if requirements.ListDeleted { req.Deleted = true } @@ -469,7 +466,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti for _, r := range rsp.Results { res := s.newFunc() - err := entityToResource(r, res, s.codec) + err := EntityToRuntimeObject(r, res, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -585,7 +582,7 @@ func (s *Storage) GuaranteedUpdate( return err } - err = entityToResource(rsp.Entity, destination, s.codec) + err = EntityToRuntimeObject(rsp.Entity, destination, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -608,7 +605,7 @@ func (s *Storage) GuaranteedUpdate( return nil // destination is already set } - err = entityToResource(rsp.Entity, destination, s.codec) + err = EntityToRuntimeObject(rsp.Entity, destination, s.codec) if err != nil { return apierrors.NewInternalError(err) } @@ -679,7 +676,7 @@ decode: return watch.Bookmark, obj, nil } - err = entityToResource(resp.Entity, obj, d.codec) + err = EntityToRuntimeObject(resp.Entity, obj, d.codec) if err != nil { klog.Errorf("error decoding entity: %s", err) return watch.Error, nil, err @@ -713,7 +710,7 @@ decode: prevMatches := false prevObj := d.newFunc() if resp.Previous != nil { - err = entityToResource(resp.Previous, prevObj, d.codec) + err = EntityToRuntimeObject(resp.Previous, prevObj, d.codec) if err != nil { klog.Errorf("error decoding entity: %s", err) return watch.Error, nil, err @@ -754,7 +751,7 @@ decode: // if we have a previous object, return that in the deleted event if resp.Previous != nil { - err = entityToResource(resp.Previous, obj, d.codec) + err = EntityToRuntimeObject(resp.Previous, obj, d.codec) if err != nil { klog.Errorf("error decoding entity: %s", err) return watch.Error, nil, err diff --git a/pkg/services/apiserver/storage/entity/utils.go b/pkg/services/apiserver/storage/entity/utils.go index c7f854297ca..f1e9e029b79 100644 --- a/pkg/services/apiserver/storage/entity/utils.go +++ b/pkg/services/apiserver/storage/entity/utils.go @@ -19,7 +19,7 @@ import ( entityStore "github.com/grafana/grafana/pkg/services/store/entity" ) -func entityToResource(rsp *entityStore.Entity, res runtime.Object, codec runtime.Codec) error { +func EntityToRuntimeObject(rsp *entityStore.Entity, res runtime.Object, codec runtime.Codec) error { var err error // Read the body first -- it includes old resourceVersion! @@ -73,7 +73,7 @@ func entityToResource(rsp *entityStore.Entity, res runtime.Object, codec runtime originTime := time.UnixMilli(rsp.Origin.Time).UTC() grafanaAccessor.SetOriginInfo(&utils.ResourceOriginInfo{ Name: rsp.Origin.Source, - Key: rsp.Origin.Key, + Path: rsp.Origin.Key, // Using "key" in the // Path: rsp.Origin.Path, Timestamp: &originTime, }) @@ -136,8 +136,8 @@ func resourceToEntity(res runtime.Object, requestInfo *request.RequestInfo, code Title: grafanaAccessor.FindTitle(metaAccessor.GetName()), Origin: &entityStore.EntityOriginInfo{ Source: grafanaAccessor.GetOriginName(), - Key: grafanaAccessor.GetOriginKey(), - // Path: grafanaAccessor.GetOriginPath(), + // Deprecated: Keeping "key" in the protobuf to avoid migrations while a bigger one is in place + Key: grafanaAccessor.GetOriginPath(), }, Labels: metaAccessor.GetLabels(), } diff --git a/pkg/services/apiserver/storage/entity/utils_test.go b/pkg/services/apiserver/storage/entity/utils_test.go index 18953b8044f..e98741352ba 100644 --- a/pkg/services/apiserver/storage/entity/utils_test.go +++ b/pkg/services/apiserver/storage/entity/utils_test.go @@ -201,7 +201,7 @@ func TestEntityToResource(t *testing.T) { for _, tc := range testCases { t.Run(tc.entity.Key+" to resource conversion should succeed", func(t *testing.T) { var p v0alpha1.Playlist - err := entityToResource(tc.entity, &p, Codecs.LegacyCodec(v0alpha1.PlaylistResourceInfo.GroupVersion())) + err := EntityToRuntimeObject(tc.entity, &p, Codecs.LegacyCodec(v0alpha1.PlaylistResourceInfo.GroupVersion())) require.NoError(t, err) assert.Equal(t, tc.expectedApiVersion, p.TypeMeta.APIVersion) assert.Equal(t, tc.expectedCreationTimestamp.Unix(), p.ObjectMeta.CreationTimestamp.Unix()) diff --git a/pkg/services/authz/client.go b/pkg/services/authz/client.go index be0328aa284..aaf6ecfc248 100644 --- a/pkg/services/authz/client.go +++ b/pkg/services/authz/client.go @@ -5,18 +5,17 @@ import ( "github.com/fullstorydev/grpchan" "github.com/fullstorydev/grpchan/inprocgrpc" + authzv1 "github.com/grafana/authlib/authz/proto/v1" grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - authzv1 "github.com/grafana/authlib/authz/proto/v1" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/grpcserver" - grpcUtils "github.com/grafana/grafana/pkg/services/store/entity/grpc" "github.com/grafana/grafana/pkg/setting" + grpcUtils "github.com/grafana/grafana/pkg/storage/unified/resource/grpc" ) type Client interface { diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 61097b39ad1..5533aa4a0d5 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/authz/zanzana" @@ -20,26 +21,37 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -type ZanzanaClient interface{} +// ProvideZanzana used to register ZanzanaClient. +// It will also start an embedded ZanzanaSever if mode is set to "embedded". +func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureToggles) (zanzana.Client, error) { + if !features.IsEnabledGlobally(featuremgmt.FlagZanzana) { + return zanzana.NoopClient{}, nil + } -func ProvideZanzana(cfg *setting.Cfg) (ZanzanaClient, error) { - var client *zanzana.Client + logger := log.New("zanzana") + var client zanzana.Client switch cfg.Zanzana.Mode { case setting.ZanzanaModeClient: conn, err := grpc.NewClient(cfg.Zanzana.Addr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err) } - client = zanzana.NewClient(openfgav1.NewOpenFGAServiceClient(conn)) + client = zanzana.NewClient(conn) case setting.ZanzanaModeEmbedded: - srv, err := zanzana.NewServer(zanzana.NewStore()) + store, err := zanzana.NewEmbeddedStore(cfg, db, logger) if err != nil { return nil, fmt.Errorf("failed to start zanzana: %w", err) } + + srv, err := zanzana.NewServer(store, logger) + if err != nil { + return nil, fmt.Errorf("failed to start zanzana: %w", err) + } + channel := &inprocgrpc.Channel{} openfgav1.RegisterOpenFGAServiceServer(channel, srv) - client = zanzana.NewClient(openfgav1.NewOpenFGAServiceClient(channel)) + client = zanzana.NewClient(channel) default: return nil, fmt.Errorf("unsupported zanzana mode: %s", cfg.Zanzana.Mode) } @@ -77,7 +89,12 @@ type Zanzana struct { } func (z *Zanzana) start(ctx context.Context) error { - srv, err := zanzana.NewServer(zanzana.NewStore()) + store, err := zanzana.NewStore(z.cfg, z.logger) + if err != nil { + return fmt.Errorf("failed to initilize zanana store: %w", err) + } + + srv, err := zanzana.NewServer(store, z.logger) if err != nil { return fmt.Errorf("failed to start zanzana: %w", err) } diff --git a/pkg/services/authz/zanzana/client.go b/pkg/services/authz/zanzana/client.go index 4d3509a4257..e7a093fd426 100644 --- a/pkg/services/authz/zanzana/client.go +++ b/pkg/services/authz/zanzana/client.go @@ -1,14 +1,46 @@ package zanzana import ( + "context" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "google.golang.org/grpc" + + "github.com/grafana/grafana/pkg/infra/log" ) -// FIXME(kalleep): Build out our wrapper client for openFGA -type Client struct { - c openfgav1.OpenFGAServiceClient +// Client is a wrapper around OpenFGAServiceClient with only methods using in Grafana included. +type Client interface { + Check(ctx context.Context, in *openfgav1.CheckRequest, opts ...grpc.CallOption) (*openfgav1.CheckResponse, error) + ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest, opts ...grpc.CallOption) (*openfgav1.ListObjectsResponse, error) } -func NewClient(c openfgav1.OpenFGAServiceClient) *Client { - return &Client{c} +type zanzanaClient struct { + client openfgav1.OpenFGAServiceClient + logger log.Logger +} + +func NewClient(cc grpc.ClientConnInterface) Client { + return &zanzanaClient{ + client: openfgav1.NewOpenFGAServiceClient(cc), + logger: log.New("zanzana-client"), + } +} + +func (c *zanzanaClient) Check(ctx context.Context, in *openfgav1.CheckRequest, opts ...grpc.CallOption) (*openfgav1.CheckResponse, error) { + return c.client.Check(ctx, in, opts...) +} + +func (c *zanzanaClient) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest, opts ...grpc.CallOption) (*openfgav1.ListObjectsResponse, error) { + return c.client.ListObjects(ctx, in, opts...) +} + +type NoopClient struct{} + +func (nc NoopClient) Check(ctx context.Context, in *openfgav1.CheckRequest, opts ...grpc.CallOption) (*openfgav1.CheckResponse, error) { + return nil, nil +} + +func (nc NoopClient) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest, opts ...grpc.CallOption) (*openfgav1.ListObjectsResponse, error) { + return nil, nil } diff --git a/pkg/services/authz/zanzana/logger.go b/pkg/services/authz/zanzana/logger.go new file mode 100644 index 00000000000..79936df85e0 --- /dev/null +++ b/pkg/services/authz/zanzana/logger.go @@ -0,0 +1,85 @@ +package zanzana + +import ( + "context" + + "go.uber.org/zap" + + "github.com/grafana/grafana/pkg/infra/log" +) + +// zanzanaLogger is a grafana logger wrapper compatible with OpenFGA logger interface +type zanzanaLogger struct { + logger log.Logger +} + +func newZanzanaLogger(logger log.Logger) *zanzanaLogger { + return &zanzanaLogger{ + logger: logger, + } +} + +// Simple converter for zap logger fields +func zapFieldsToArgs(fields []zap.Field) []any { + // We need to pre-allocated space for key and value + args := make([]any, 0, len(fields)*2) + for _, f := range fields { + args = append(args, f.Key) + if f.Interface != nil { + args = append(args, f.Interface) + } else if f.String != "" { + args = append(args, f.String) + } else { + args = append(args, f.Integer) + } + } + return args +} + +func (l *zanzanaLogger) Debug(msg string, fields ...zap.Field) { + l.logger.Debug(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) Info(msg string, fields ...zap.Field) { + l.logger.Info(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) Warn(msg string, fields ...zap.Field) { + l.logger.Warn(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) Error(msg string, fields ...zap.Field) { + l.logger.Error(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) Panic(msg string, fields ...zap.Field) { + l.logger.Error(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) Fatal(msg string, fields ...zap.Field) { + l.logger.Error(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) DebugWithContext(ctx context.Context, msg string, fields ...zap.Field) { + l.logger.Debug(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) InfoWithContext(ctx context.Context, msg string, fields ...zap.Field) { + l.logger.Info(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) WarnWithContext(ctx context.Context, msg string, fields ...zap.Field) { + l.logger.Warn(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) ErrorWithContext(ctx context.Context, msg string, fields ...zap.Field) { + l.logger.Error(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) PanicWithContext(ctx context.Context, msg string, fields ...zap.Field) { + l.logger.Error(msg, zapFieldsToArgs(fields)...) +} + +func (l *zanzanaLogger) FatalWithContext(ctx context.Context, msg string, fields ...zap.Field) { + l.logger.Error(msg, zapFieldsToArgs(fields)...) +} diff --git a/pkg/services/authz/zanzana/server.go b/pkg/services/authz/zanzana/server.go index e00b2a22eb5..3d4ddc50b6a 100644 --- a/pkg/services/authz/zanzana/server.go +++ b/pkg/services/authz/zanzana/server.go @@ -1,17 +1,17 @@ package zanzana import ( - "github.com/openfga/openfga/pkg/logger" "github.com/openfga/openfga/pkg/server" "github.com/openfga/openfga/pkg/storage" + + "github.com/grafana/grafana/pkg/infra/log" ) -func NewServer(store storage.OpenFGADatastore) (*server.Server, error) { +func NewServer(store storage.OpenFGADatastore, logger log.Logger) (*server.Server, error) { // FIXME(kalleep): add support for more options, configure logging, tracing etc opts := []server.OpenFGAServiceV1Option{ server.WithDatastore(store), - // FIXME(kalleep): Write and log adapter for open fga logging interface - server.WithLogger(logger.NewNoopLogger()), + server.WithLogger(newZanzanaLogger(logger)), } // FIXME(kalleep): Interceptors diff --git a/pkg/services/authz/zanzana/store.go b/pkg/services/authz/zanzana/store.go index d946954763a..66da617d522 100644 --- a/pkg/services/authz/zanzana/store.go +++ b/pkg/services/authz/zanzana/store.go @@ -1,13 +1,214 @@ package zanzana import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/openfga/openfga/assets" "github.com/openfga/openfga/pkg/storage" "github.com/openfga/openfga/pkg/storage/memory" + "github.com/openfga/openfga/pkg/storage/mysql" + "github.com/openfga/openfga/pkg/storage/postgres" + "github.com/openfga/openfga/pkg/storage/sqlcommon" + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" ) -// FIXME(kalleep): Add support for postgres, mysql and sqlite data stores. -// Postgres and mysql is already implmented by openFGA so we just need to hook up migartions for them. +// FIXME(kalleep): Add sqlite data store. // There is no support for sqlite atm but we are working on adding it: https://github.com/openfga/openfga/pull/1615 -func NewStore() storage.OpenFGADatastore { - return memory.New() +func NewStore(cfg *setting.Cfg, logger log.Logger) (storage.OpenFGADatastore, error) { + grafanaDBCfg, zanzanaDBCfg, err := parseConfig(cfg, logger) + if err != nil { + return nil, fmt.Errorf("failed to parse database config: %w", err) + } + + switch grafanaDBCfg.Type { + case migrator.SQLite: + return memory.New(), nil + case migrator.MySQL: + // For mysql we need to pass parseTime parameter in connection string + connStr := grafanaDBCfg.ConnectionString + "&parseTime=true" + if err := runMigrations(cfg, migrator.MySQL, connStr, assets.MySQLMigrationDir); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + return mysql.New(connStr, zanzanaDBCfg) + case migrator.Postgres: + if err := runMigrations(cfg, migrator.Postgres, grafanaDBCfg.ConnectionString, assets.PostgresMigrationDir); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + return postgres.New(grafanaDBCfg.ConnectionString, zanzanaDBCfg) + } + + // Should never happen + return nil, fmt.Errorf("unsupported database engine: %s", grafanaDBCfg.Type) +} + +func NewEmbeddedStore(cfg *setting.Cfg, db db.DB, logger log.Logger) (storage.OpenFGADatastore, error) { + grafanaDBCfg, zanzanaDBCfg, err := parseConfig(cfg, logger) + if err != nil { + return nil, fmt.Errorf("failed to parse database config: %w", err) + } + + m := migrator.NewMigrator(db.GetEngine(), cfg) + + switch grafanaDBCfg.Type { + case migrator.SQLite: + // FIXME(kalleep): At the moment sqlite is not a supported data store. + // So we just return in memory store for now. + return memory.New(), nil + case migrator.MySQL: + if err := runMigrationsWithMigrator(m, cfg, assets.MySQLMigrationDir); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + // For mysql we need to pass parseTime parameter in connection string + return mysql.New(grafanaDBCfg.ConnectionString+"&parseTime=true", zanzanaDBCfg) + case migrator.Postgres: + if err := runMigrationsWithMigrator(m, cfg, assets.PostgresMigrationDir); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + return postgres.New(grafanaDBCfg.ConnectionString, zanzanaDBCfg) + } + + // Should never happen + return nil, fmt.Errorf("unsupported database engine: %s", db.GetDialect().DriverName()) +} + +func parseConfig(cfg *setting.Cfg, logger log.Logger) (*sqlstore.DatabaseConfig, *sqlcommon.Config, error) { + sec := cfg.Raw.Section("database") + grafanaDBCfg, err := sqlstore.NewDatabaseConfig(cfg, nil) + if err != nil { + return nil, nil, nil + } + + zanzanaDBCfg := &sqlcommon.Config{ + Logger: newZanzanaLogger(logger), + // MaxTuplesPerWriteField: 0, + // MaxTypesPerModelField: 0, + MaxOpenConns: grafanaDBCfg.MaxOpenConn, + MaxIdleConns: grafanaDBCfg.MaxIdleConn, + ConnMaxLifetime: time.Duration(grafanaDBCfg.ConnMaxLifetime) * time.Second, + ExportMetrics: sec.Key("instrument_queries").MustBool(false), + } + + return grafanaDBCfg, zanzanaDBCfg, nil +} + +func runMigrations(cfg *setting.Cfg, typ, connStr, path string) error { + engine, err := xorm.NewEngine(typ, connStr) + if err != nil { + return fmt.Errorf("failed to parse database config: %w", err) + } + + m := migrator.NewMigrator(engine, cfg) + m.AddCreateMigration() + + return runMigrationsWithMigrator(m, cfg, path) +} + +func runMigrationsWithMigrator(m *migrator.Migrator, cfg *setting.Cfg, path string) error { + migrations, err := getMigrations(path) + if err != nil { + return err + } + + for _, mig := range migrations { + m.AddMigration(mig.name, mig.migration) + } + + sec := cfg.Raw.Section("database") + return m.Start( + sec.Key("migration_locking").MustBool(true), + sec.Key("locking_attempt_timeout_sec").MustInt(), + ) +} + +type migration struct { + name string + migration migrator.Migration +} + +func getMigrations(path string) ([]migration, error) { + entries, err := assets.EmbedMigrations.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("failed to read migration dir: %w", err) + } + + // parseStatements extracts statements from a sql file so we can execute + // them as separate migrations. OpenFGA uses Goose as their migration egine + // and Goose uses a single sql file for both up and down migrations. + // Grafana only supports up migration so we strip out the down migration + // and parse each individual statement + parseStatements := func(data []byte) ([]string, error) { + scripts := strings.Split(strings.TrimPrefix(string(data), "-- +goose Up"), "-- +goose Down") + if len(scripts) != 2 { + return nil, errors.New("malformed migration file") + } + + // We assume that up migrations are always before down migrations + parts := strings.SplitAfter(scripts[0], ";") + stmts := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + stmts = append(stmts, p) + } + } + + return stmts, nil + } + + formatName := func(name string) string { + // Each migration file start with XXX where X is a number. + // We remove that part and prefix each migration with "zanzana". + return strings.TrimSuffix("zanzana"+name[3:], ".sql") + } + + migrations := make([]migration, 0, len(entries)) + for _, e := range entries { + data, err := assets.EmbedMigrations.ReadFile(path + "/" + e.Name()) + if err != nil { + return nil, fmt.Errorf("failed to read migration file: %w", err) + } + + stmts, err := parseStatements(data) + if err != nil { + return nil, fmt.Errorf("failed to parse migration: %w", err) + } + + migrations = append(migrations, migration{ + name: formatName(e.Name()), + migration: &rawMigration{stmts: stmts}, + }) + } + + return migrations, nil +} + +var _ migrator.CodeMigration = (*rawMigration)(nil) + +type rawMigration struct { + stmts []string + migrator.MigrationBase +} + +func (m *rawMigration) Exec(sess *xorm.Session, migrator *migrator.Migrator) error { + for _, stmt := range m.stmts { + if _, err := sess.Exec(stmt); err != nil { + return fmt.Errorf("failed to run migration: %w", err) + } + } + return nil +} + +func (m *rawMigration) SQL(dialect migrator.Dialect) string { + return strings.Join(m.stmts, "\n") } diff --git a/pkg/services/cloudmigration/api/api.go b/pkg/services/cloudmigration/api/api.go index 66a2107f813..96d1679ade6 100644 --- a/pkg/services/cloudmigration/api/api.go +++ b/pkg/services/cloudmigration/api/api.go @@ -40,19 +40,28 @@ func RegisterApi( // registerEndpoints Registers Endpoints on Grafana Router func (cma *CloudMigrationAPI) registerEndpoints() { cma.routeRegister.Group("/api/cloudmigration", func(cloudMigrationRoute routing.RouteRegister) { + // destination instance endpoints for token management + cloudMigrationRoute.Get("/token", routing.Wrap(cma.GetToken)) + cloudMigrationRoute.Post("/token", routing.Wrap(cma.CreateToken)) + cloudMigrationRoute.Delete("/token/:uid", routing.Wrap(cma.DeleteToken)) + + // on-prem instance endpoints for managing GMS sessions cloudMigrationRoute.Get("/migration", routing.Wrap(cma.GetSessionList)) cloudMigrationRoute.Post("/migration", routing.Wrap(cma.CreateSession)) cloudMigrationRoute.Get("/migration/:uid", routing.Wrap(cma.GetSession)) cloudMigrationRoute.Delete("/migration/:uid", routing.Wrap(cma.DeleteSession)) - // TODO new APIs for snapshot management to replace these + // sync approach to data migration cloudMigrationRoute.Post("/migration/:uid/run", routing.Wrap(cma.RunMigration)) cloudMigrationRoute.Get("/migration/:uid/run", routing.Wrap(cma.GetMigrationRunList)) cloudMigrationRoute.Get("/migration/run/:runUID", routing.Wrap(cma.GetMigrationRun)) - cloudMigrationRoute.Get("/token", routing.Wrap(cma.GetToken)) - cloudMigrationRoute.Post("/token", routing.Wrap(cma.CreateToken)) - cloudMigrationRoute.Delete("/token/:uid", routing.Wrap(cma.DeleteToken)) + // async approach to data migration using snapshots + cloudMigrationRoute.Post("/migration/:uid/snapshot", routing.Wrap(cma.CreateSnapshot)) + cloudMigrationRoute.Get("/migration/:uid/snapshot/:snapshotUid", routing.Wrap(cma.GetSnapshot)) + cloudMigrationRoute.Get("/migration/:uid/snapshots", routing.Wrap(cma.GetSnapshotList)) + cloudMigrationRoute.Post("/migration/:uid/snapshot/:snapshotUid/upload", routing.Wrap(cma.UploadSnapshot)) + cloudMigrationRoute.Post("/migration/:uid/snapshot/:snapshotUid/cancel", routing.Wrap(cma.CancelSnapshot)) }, middleware.ReqOrgAdmin) } @@ -121,6 +130,7 @@ func (cma *CloudMigrationAPI) CreateToken(c *contextmodel.ReqContext) response.R // // Responses: // 204: cloudMigrationDeleteTokenResponse +// 400: badRequestError // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError @@ -170,6 +180,7 @@ func (cma *CloudMigrationAPI) GetSessionList(c *contextmodel.ReqContext) respons // // Responses: // 200: cloudMigrationSessionResponse +// 400: badRequestError // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError @@ -201,6 +212,7 @@ func (cma *CloudMigrationAPI) GetSession(c *contextmodel.ReqContext) response.Re // // Responses: // 200: cloudMigrationSessionResponse +// 400: badRequestError // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError @@ -235,6 +247,7 @@ func (cma *CloudMigrationAPI) CreateSession(c *contextmodel.ReqContext) response // // Responses: // 200: cloudMigrationRunResponse +// 400: badRequestError // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError @@ -261,6 +274,7 @@ func (cma *CloudMigrationAPI) RunMigration(c *contextmodel.ReqContext) response. // // Responses: // 200: cloudMigrationRunResponse +// 400: badRequestError // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError @@ -293,6 +307,7 @@ func (cma *CloudMigrationAPI) GetMigrationRun(c *contextmodel.ReqContext) respon // // Responses: // 200: cloudMigrationRunListResponse +// 400: badRequestError // 401: unauthorisedError // 403: forbiddenError // 500: internalServerError @@ -314,7 +329,7 @@ func (cma *CloudMigrationAPI) GetMigrationRunList(c *contextmodel.ReqContext) re for i := 0; i < len(runList.Runs); i++ { runs[i] = MigrateDataResponseListDTO{runList.Runs[i].RunUID} } - return response.JSON(http.StatusOK, SnapshotListDTO{ + return response.JSON(http.StatusOK, CloudMigrationRunListDTO{ Runs: runs, }) } @@ -326,6 +341,7 @@ func (cma *CloudMigrationAPI) GetMigrationRunList(c *contextmodel.ReqContext) re // Responses: // 200 // 401: unauthorisedError +// 400: badRequestError // 403: forbiddenError // 500: internalServerError func (cma *CloudMigrationAPI) DeleteSession(c *contextmodel.ReqContext) response.Response { @@ -343,3 +359,196 @@ func (cma *CloudMigrationAPI) DeleteSession(c *contextmodel.ReqContext) response } return response.Empty(http.StatusOK) } + +// swagger:route POST /cloudmigration/migration/{uid}/snapshot migrations createSnapshot +// +// Trigger the creation of an instance snapshot associated with the provided session. +// If the snapshot initialization is successful, the snapshot uid is returned. +// +// Responses: +// 200: createSnapshotResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 500: internalServerError +func (cma *CloudMigrationAPI) CreateSnapshot(c *contextmodel.ReqContext) response.Response { + ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.CreateSnapshot") + defer span.End() + + uid := web.Params(c.Req)[":uid"] + if err := util.ValidateUID(uid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) + } + + ss, err := cma.cloudMigrationService.CreateSnapshot(ctx, uid) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "error creating snapshot", err) + } + + return response.JSON(http.StatusOK, CreateSnapshotResponseDTO{ + SnapshotUID: ss.UID, + }) +} + +// swagger:route GET /cloudmigration/migration/{uid}/snapshot/{snapshotUid} migrations getSnapshot +// +// Get metadata about a snapshot, including where it is in its processing and final results. +// +// Responses: +// 200: getSnapshotResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 500: internalServerError +func (cma *CloudMigrationAPI) GetSnapshot(c *contextmodel.ReqContext) response.Response { + ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.GetSnapshot") + defer span.End() + + sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"] + if err := util.ValidateUID(sessUid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) + } + if err := util.ValidateUID(snapshotUid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err) + } + + snapshot, err := cma.cloudMigrationService.GetSnapshot(ctx, sessUid, snapshotUid) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "error retrieving snapshot", err) + } + + result, err := snapshot.GetSnapshotResult() + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "error snapshot reading snapshot results", err) + } + + dtoResults := make([]MigrateDataResponseItemDTO, len(result)) + for i := 0; i < len(result); i++ { + dtoResults[i] = MigrateDataResponseItemDTO{ + Type: MigrateDataType(result[i].Type), + RefID: result[i].RefID, + Status: ItemStatus(result[i].Status), + Error: result[i].Error, + } + } + + respDto := GetSnapshotResponseDTO{ + SnapshotDTO: SnapshotDTO{ + SnapshotUID: snapshot.UID, + Status: fromSnapshotStatus(snapshot.Status), + SessionUID: sessUid, + Created: snapshot.Created, + Finished: snapshot.Finished, + }, + Results: dtoResults, + } + + return response.JSON(http.StatusOK, respDto) +} + +// swagger:route GET /cloudmigration/migration/{uid}/snapshots migrations getShapshotList +// +// Get a list of snapshots for a session. +// +// Responses: +// 200: snapshotListResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 500: internalServerError +func (cma *CloudMigrationAPI) GetSnapshotList(c *contextmodel.ReqContext) response.Response { + ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.GetShapshotList") + defer span.End() + + uid := web.Params(c.Req)[":uid"] + if err := util.ValidateUID(uid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) + } + q := cloudmigration.ListSnapshotsQuery{ + SessionUID: uid, + Limit: c.QueryInt("limit"), + Offset: c.QueryInt("offset"), + } + if q.Limit == 0 { + q.Limit = 100 + } + + snapshotList, err := cma.cloudMigrationService.GetSnapshotList(ctx, q) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "error retrieving snapshot list", err) + } + + dtos := make([]SnapshotDTO, len(snapshotList)) + for i := 0; i < len(snapshotList); i++ { + dtos[i] = SnapshotDTO{ + SnapshotUID: snapshotList[i].UID, + Status: fromSnapshotStatus(snapshotList[i].Status), + SessionUID: uid, + Created: snapshotList[i].Created, + Finished: snapshotList[i].Finished, + } + } + + return response.JSON(http.StatusOK, SnapshotListResponseDTO{ + Snapshots: dtos, + }) +} + +// swagger:route POST /cloudmigration/migration/{uid}/snapshot/{snapshotUid}/upload migrations uploadSnapshot +// +// Upload a snapshot to the Grafana Migration Service for processing. +// +// Responses: +// 200: +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 500: internalServerError +func (cma *CloudMigrationAPI) UploadSnapshot(c *contextmodel.ReqContext) response.Response { + ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.UploadSnapshot") + defer span.End() + + sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"] + if err := util.ValidateUID(sessUid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) + } + if err := util.ValidateUID(snapshotUid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err) + } + + if err := cma.cloudMigrationService.UploadSnapshot(ctx, sessUid, snapshotUid); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "error uploading snapshot", err) + } + + return response.JSON(http.StatusOK, nil) +} + +// swagger:route POST /cloudmigration/migration/{uid}/snapshot/{snapshotUid}/cancel migrations cancelSnapshot +// +// Cancel a snapshot, wherever it is in its processing chain. +// TODO: Implement +// +// Responses: +// 200: +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 500: internalServerError +func (cma *CloudMigrationAPI) CancelSnapshot(c *contextmodel.ReqContext) response.Response { + ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.CancelSnapshot") + defer span.End() + + sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"] + if err := util.ValidateUID(sessUid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) + } + if err := util.ValidateUID(snapshotUid); err != nil { + return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err) + } + + if err := cma.cloudMigrationService.CancelSnapshot(ctx, sessUid, snapshotUid); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "error canceling snapshot", err) + } + + return response.JSON(http.StatusOK, nil) +} diff --git a/pkg/services/cloudmigration/api/api_test.go b/pkg/services/cloudmigration/api/api_test.go index 6fc50df2e78..35fe13466f5 100644 --- a/pkg/services/cloudmigration/api/api_test.go +++ b/pkg/services/cloudmigration/api/api_test.go @@ -421,6 +421,240 @@ func TestCloudMigrationAPI_DeleteMigration(t *testing.T) { } } +func TestCloudMigrationAPI_CreateSnapshot(t *testing.T) { + tests := []TestCase{ + { + desc: "should return 200 if everything is ok", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot", + basicRole: org.RoleAdmin, + expectedHttpResult: http.StatusOK, + expectedBody: `{"uid":"fake_uid"}`, + }, + { + desc: "should return 403 if no used is not admin", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot", + basicRole: org.RoleEditor, + expectedHttpResult: http.StatusForbidden, + expectedBody: "", + }, + { + desc: "should return 500 if service returns an error", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusInternalServerError, + expectedBody: "", + }, + { + desc: "should return 400 if uid is invalid", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/***/snapshot", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, runSimpleApiTest(tt)) + } +} + +func TestCloudMigrationAPI_GetSnapshot(t *testing.T) { + tests := []TestCase{ + { + desc: "should return 200 if everything is ok", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1", + basicRole: org.RoleAdmin, + expectedHttpResult: http.StatusOK, + expectedBody: `{"uid":"fake_uid","status":"UNKNOWN","sessionUid":"1234","created":"0001-01-01T00:00:00Z","finished":"0001-01-01T00:00:00Z","results":[]}`, + }, + { + desc: "should return 403 if no used is not admin", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1", + basicRole: org.RoleEditor, + expectedHttpResult: http.StatusForbidden, + expectedBody: "", + }, + { + desc: "should return 500 if service returns an error", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusInternalServerError, + expectedBody: "", + }, + { + desc: "should return 400 if uid is invalid", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/***/snapshot/1", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + { + desc: "should return 400 if snapshot_uid is invalid", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/***", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, runSimpleApiTest(tt)) + } +} + +func TestCloudMigrationAPI_GetSnapshotList(t *testing.T) { + tests := []TestCase{ + { + desc: "should return 200 if everything is ok", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshots", + basicRole: org.RoleAdmin, + expectedHttpResult: http.StatusOK, + expectedBody: `{"snapshots":[{"uid":"fake_uid","status":"UNKNOWN","sessionUid":"1234","created":"0001-01-01T00:00:00Z","finished":"0001-01-01T00:00:00Z"},{"uid":"fake_uid","status":"UNKNOWN","sessionUid":"1234","created":"0001-01-01T00:00:00Z","finished":"0001-01-01T00:00:00Z"}]}`, + }, + { + desc: "should return 403 if no used is not admin", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshots", + basicRole: org.RoleEditor, + expectedHttpResult: http.StatusForbidden, + expectedBody: "", + }, + { + desc: "should return 500 if service returns an error", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/1234/snapshots", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusInternalServerError, + expectedBody: "", + }, + { + desc: "should return 400 if uid is invalid", + requestHttpMethod: http.MethodGet, + requestUrl: "/api/cloudmigration/migration/***/snapshots", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, runSimpleApiTest(tt)) + } +} + +func TestCloudMigrationAPI_UploadSnapshot(t *testing.T) { + tests := []TestCase{ + { + desc: "should return 200 if everything is ok", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/upload", + basicRole: org.RoleAdmin, + expectedHttpResult: http.StatusOK, + expectedBody: "", + }, + { + desc: "should return 403 if no used is not admin", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/upload", + basicRole: org.RoleEditor, + expectedHttpResult: http.StatusForbidden, + expectedBody: "", + }, + { + desc: "should return 500 if service returns an error", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/upload", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusInternalServerError, + expectedBody: "", + }, + { + desc: "should return 400 if uid is invalid", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/***/snapshot/1/upload", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + { + desc: "should return 400 if snapshot_uid is invalid", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/***/upload", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, runSimpleApiTest(tt)) + } +} + +func TestCloudMigrationAPI_CancelSnapshot(t *testing.T) { + tests := []TestCase{ + { + desc: "should return 200 if everything is ok", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/cancel", + basicRole: org.RoleAdmin, + expectedHttpResult: http.StatusOK, + expectedBody: "", + }, + { + desc: "should return 403 if no used is not admin", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/cancel", + basicRole: org.RoleEditor, + expectedHttpResult: http.StatusForbidden, + expectedBody: "", + }, + { + desc: "should return 500 if service returns an error", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/cancel", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusInternalServerError, + expectedBody: "", + }, + { + desc: "should return 400 if uid is invalid", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/***/snapshot/1/cancel", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + { + desc: "should return 400 if snapshot_uid is invalid", + requestHttpMethod: http.MethodPost, + requestUrl: "/api/cloudmigration/migration/1234/snapshot/***/cancel", + basicRole: org.RoleAdmin, + serviceReturnError: true, + expectedHttpResult: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, runSimpleApiTest(tt)) + } +} + func runSimpleApiTest(tt TestCase) func(t *testing.T) { return func(t *testing.T) { // setup server diff --git a/pkg/services/cloudmigration/api/curl_commands.txt b/pkg/services/cloudmigration/api/curl_commands.txt new file mode 100644 index 00000000000..7013234aaa3 --- /dev/null +++ b/pkg/services/cloudmigration/api/curl_commands.txt @@ -0,0 +1,21 @@ +[sample token] // NOT A REAL TOKEN +eyJUb2tlbiI6ImNvbXBsZXRlbHlfZmFrZV90b2tlbl9jZG9peTFhYzdwdXlwZCIsIkluc3RhbmNlIjp7IlN0YWNrSUQiOjEyMzQ1LCJTbHVnIjoic3R1Ymluc3RhbmNlIiwiUmVnaW9uU2x1ZyI6ImZha2UtcmVnaW9uIiwiQ2x1c3RlclNsdWciOiJmYWtlLWNsdXNlciJ9fQ== + +[create session} +curl -X POST -H "Content-Type: application/json" \ + http://admin:admin@localhost:3000/api/cloudmigration/migration \ + -d '{"AuthToken":"eyJUb2tlbiI6ImNvbXBsZXRlbHlfZmFrZV90b2tlbl9jZG9peTFhYzdwdXlwZCIsIkluc3RhbmNlIjp7IlN0YWNrSUQiOjEyMzQ1LCJTbHVnIjoic3R1Ymluc3RhbmNlIiwiUmVnaW9uU2x1ZyI6ImZha2UtcmVnaW9uIiwiQ2x1c3RlclNsdWciOiJmYWtlLWNsdXNlciJ9fQ=="}' + +[create snapshot] +curl -X POST -H "Content-Type: application/json" \ + http://admin:admin@localhost:3000/api/cloudmigration/migration/{sessionUid}/snapshot + +[get snapshot list] +curl -X GET http://admin:admin@localhost:3000/api/cloudmigration/migration/{sessionUid}/snapshots?limit=100&offset=0 + +[get snapshot] +curl -X GET http://admin:admin@localhost:3000/api/cloudmigration/migration/{sessionUid}/snapshot/{snapshotUid} + +[upload snapshot] +curl -X POST -H "Content-Type: application/json" \ + http://admin:admin@localhost:3000/api/cloudmigration/migration/{sessionUid}/snapshot/{snapshotUid}/upload diff --git a/pkg/services/cloudmigration/api/dtos.go b/pkg/services/cloudmigration/api/dtos.go index 37cd12eef2e..e410ea5efea 100644 --- a/pkg/services/cloudmigration/api/dtos.go +++ b/pkg/services/cloudmigration/api/dtos.go @@ -38,7 +38,6 @@ type CreateAccessTokenResponseDTO struct { // swagger:parameters deleteCloudMigrationToken type DeleteCloudMigrationToken struct { // UID of a cloud migration token - // // in: path UID string `json:"uid"` } @@ -67,7 +66,6 @@ type CloudMigrationSessionListResponseDTO struct { // swagger:parameters getSession type GetCloudMigrationSessionRequest struct { // UID of a migration session - // // in: path UID string `json:"uid"` } @@ -92,7 +90,6 @@ type CloudMigrationSessionRequestDTO struct { // swagger:parameters runCloudMigration type RunCloudMigrationRequest struct { // UID of a migration - // // in: path UID string `json:"uid"` } @@ -138,7 +135,6 @@ const ( // swagger:parameters getCloudMigrationRun type GetMigrationRunParams struct { // RunUID of a migration run - // // in: path RunUID string `json:"runUID"` } @@ -146,7 +142,6 @@ type GetMigrationRunParams struct { // swagger:parameters getCloudMigrationRunList type GetCloudMigrationRunList struct { // UID of a migration - // // in: path UID string `json:"uid"` } @@ -154,10 +149,10 @@ type GetCloudMigrationRunList struct { // swagger:response cloudMigrationRunListResponse type CloudMigrationRunListResponse struct { // in: body - Body SnapshotListDTO + Body CloudMigrationRunListDTO } -type SnapshotListDTO struct { +type CloudMigrationRunListDTO struct { Runs []MigrateDataResponseListDTO `json:"runs"` } @@ -168,7 +163,6 @@ type MigrateDataResponseListDTO struct { // swagger:parameters deleteSession type DeleteMigrationSessionRequest struct { // UID of a migration session - // // in: path UID string `json:"uid"` } @@ -207,3 +201,138 @@ func convertMigrateDataResponseToDTO(r cloudmigration.MigrateDataResponse) Migra Items: items, } } + +// Base snapshot without results +type SnapshotDTO struct { + SnapshotUID string `json:"uid"` + Status SnapshotStatus `json:"status"` + SessionUID string `json:"sessionUid"` + Created time.Time `json:"created"` + Finished time.Time `json:"finished"` +} + +// swagger:enum SnapshotStatus +type SnapshotStatus string + +const ( + SnapshotStatusInitializing SnapshotStatus = "INITIALIZING" + SnapshotStatusCreating SnapshotStatus = "CREATING" + SnapshotStatusPendingUpload SnapshotStatus = "PENDING_UPLOAD" + SnapshotStatusUploading SnapshotStatus = "UPLOADING" + SnapshotStatusPendingProcessing SnapshotStatus = "PENDING_PROCESSING" + SnapshotStatusProcessing SnapshotStatus = "PROCESSING" + SnapshotStatusFinished SnapshotStatus = "FINISHED" + SnapshotStatusError SnapshotStatus = "ERROR" + SnapshotStatusUnknown SnapshotStatus = "UNKNOWN" +) + +func fromSnapshotStatus(status cloudmigration.SnapshotStatus) SnapshotStatus { + switch status { + case cloudmigration.SnapshotStatusInitializing: + return SnapshotStatusInitializing + case cloudmigration.SnapshotStatusCreating: + return SnapshotStatusCreating + case cloudmigration.SnapshotStatusPendingUpload: + return SnapshotStatusPendingUpload + case cloudmigration.SnapshotStatusUploading: + return SnapshotStatusUploading + case cloudmigration.SnapshotStatusPendingProcessing: + return SnapshotStatusPendingProcessing + case cloudmigration.SnapshotStatusProcessing: + return SnapshotStatusProcessing + case cloudmigration.SnapshotStatusFinished: + return SnapshotStatusFinished + case cloudmigration.SnapshotStatusError: + return SnapshotStatusError + default: + return SnapshotStatusUnknown + } +} + +// swagger:parameters createSnapshot +type CreateSnapshotRequest struct { + // UID of a session + // in: path + UID string `json:"uid"` +} + +// swagger:response createSnapshotResponse +type CreateSnapshotResponse struct { + // in: body + Body CreateSnapshotResponseDTO +} + +type CreateSnapshotResponseDTO struct { + SnapshotUID string `json:"uid"` +} + +// swagger:parameters getSnapshot +type GetSnapshotParams struct { + // Session UID of a session + // in: path + UID string `json:"uid"` + + // UID of a snapshot + // in: path + SnapshotUID string `json:"snapshotUid"` +} + +// swagger:response getSnapshotResponse +type GetSnapshotResponse struct { + // in: body + Body GetSnapshotResponseDTO +} + +type GetSnapshotResponseDTO struct { + SnapshotDTO + Results []MigrateDataResponseItemDTO `json:"results"` +} + +// swagger:parameters getShapshotList +type GetSnapshotListParams struct { + // Offset is used for pagination with limit + // in:query + // required:false + // default: 0 + Offset int `json:"offset"` + // Max limit for results returned. + // in:query + // required:false + // default: 100 + Limit int `json:"limit"` + // Session UID of a session + // in: path + UID string `json:"uid"` +} + +// swagger:response snapshotListResponse +type SnapshotListResponse struct { + // in: body + Body SnapshotListResponseDTO +} + +type SnapshotListResponseDTO struct { + Snapshots []SnapshotDTO `json:"snapshots"` +} + +// swagger:parameters uploadSnapshot +type UploadSnapshotParams struct { + // Session UID of a session + // in: path + UID string `json:"uid"` + + // UID of a snapshot + // in: path + SnapshotUID string `json:"snapshotUid"` +} + +// swagger:parameters cancelSnapshot +type CancelSnapshotParams struct { + // Session UID of a session + // in: path + UID string `json:"uid"` + + // UID of a snapshot + // in: path + SnapshotUID string `json:"snapshotUid"` +} diff --git a/pkg/services/cloudmigration/cloudmigration.go b/pkg/services/cloudmigration/cloudmigration.go index 26b67ce1a09..a49e35ea947 100644 --- a/pkg/services/cloudmigration/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigration.go @@ -22,5 +22,11 @@ type Service interface { RunMigration(ctx context.Context, migUID string) (*MigrateDataResponse, error) GetMigrationStatus(ctx context.Context, runUID string) (*CloudMigrationSnapshot, error) - GetMigrationRunList(ctx context.Context, migUID string) (*SnapshotList, error) + GetMigrationRunList(ctx context.Context, migUID string) (*CloudMigrationRunList, error) + + CreateSnapshot(ctx context.Context, sessionUid string) (*CloudMigrationSnapshot, error) + GetSnapshot(ctx context.Context, sessionUid string, snapshotUid string) (*CloudMigrationSnapshot, error) + GetSnapshotList(ctx context.Context, query ListSnapshotsQuery) ([]CloudMigrationSnapshot, error) + UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error + CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error } diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go index e29844b5d7a..e349b15c91c 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -7,6 +7,9 @@ import ( "errors" "fmt" "net/http" + "os" + "path/filepath" + "sync" "time" "github.com/grafana/grafana/pkg/api/response" @@ -17,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/cloudmigration" "github.com/grafana/grafana/pkg/services/cloudmigration/api" "github.com/grafana/grafana/pkg/services/cloudmigration/gmsclient" - "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -25,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/gcom" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -37,6 +40,9 @@ type Service struct { log *log.ConcreteLogger cfg *setting.Cfg + buildSnapshotMutex sync.Mutex + buildSnapshotError bool + features featuremgmt.FeatureToggles gmsClient gmsclient.Client @@ -398,7 +404,6 @@ func (s *Service) RunMigration(ctx context.Context, uid string) (*cloudmigration return nil, fmt.Errorf("migrate data error: %w", err) } - // TODO update cloud migration run schema to treat the result as a first-class citizen respData, err := json.Marshal(resp) if err != nil { s.log.Error("error marshalling migration response data: %w", err) @@ -419,135 +424,6 @@ func (s *Service) RunMigration(ctx context.Context, uid string) (*cloudmigration return resp, nil } -func (s *Service) getMigrationDataJSON(ctx context.Context) (*cloudmigration.MigrateDataRequest, error) { - // Data sources - dataSources, err := s.getDataSources(ctx) - if err != nil { - s.log.Error("Failed to get datasources", "err", err) - return nil, err - } - - // Dashboards - dashboards, err := s.getDashboards(ctx) - if err != nil { - s.log.Error("Failed to get dashboards", "err", err) - return nil, err - } - - // Folders - folders, err := s.getFolders(ctx) - if err != nil { - s.log.Error("Failed to get folders", "err", err) - return nil, err - } - - migrationDataSlice := make( - []cloudmigration.MigrateDataRequestItem, 0, - len(dataSources)+len(dashboards)+len(folders), - ) - for _, ds := range dataSources { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.DatasourceDataType, - RefID: ds.UID, - Name: ds.Name, - Data: ds, - }) - } - - for _, dashboard := range dashboards { - dashboard.Data.Del("id") - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.DashboardDataType, - RefID: dashboard.UID, - Name: dashboard.Title, - Data: map[string]any{"dashboard": dashboard.Data}, - }) - } - - for _, f := range folders { - migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ - Type: cloudmigration.FolderDataType, - RefID: f.UID, - Name: f.Title, - Data: f, - }) - } - - migrationData := &cloudmigration.MigrateDataRequest{ - Items: migrationDataSlice, - } - - return migrationData, nil -} - -func (s *Service) getDataSources(ctx context.Context) ([]datasources.AddDataSourceCommand, error) { - dataSources, err := s.dsService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{}) - if err != nil { - s.log.Error("Failed to get all datasources", "err", err) - return nil, err - } - - result := []datasources.AddDataSourceCommand{} - for _, dataSource := range dataSources { - // Decrypt secure json to send raw credentials - decryptedData, err := s.secretsService.DecryptJsonData(ctx, dataSource.SecureJsonData) - if err != nil { - s.log.Error("Failed to decrypt secure json data", "err", err) - return nil, err - } - dataSourceCmd := datasources.AddDataSourceCommand{ - OrgID: dataSource.OrgID, - Name: dataSource.Name, - Type: dataSource.Type, - Access: dataSource.Access, - URL: dataSource.URL, - User: dataSource.User, - Database: dataSource.Database, - BasicAuth: dataSource.BasicAuth, - BasicAuthUser: dataSource.BasicAuthUser, - WithCredentials: dataSource.WithCredentials, - IsDefault: dataSource.IsDefault, - JsonData: dataSource.JsonData, - SecureJsonData: decryptedData, - ReadOnly: dataSource.ReadOnly, - UID: dataSource.UID, - } - result = append(result, dataSourceCmd) - } - return result, err -} - -func (s *Service) getFolders(ctx context.Context) ([]folder.Folder, error) { - reqCtx := contexthandler.FromContext(ctx) - folders, err := s.folderService.GetFolders(ctx, folder.GetFoldersQuery{ - SignedInUser: reqCtx.SignedInUser, - }) - if err != nil { - return nil, err - } - - result := make([]folder.Folder, len(folders)) - for i, folder := range folders { - result[i] = *folder - } - - return result, nil -} - -func (s *Service) getDashboards(ctx context.Context) ([]dashboards.Dashboard, error) { - dashs, err := s.dashboardService.GetAllDashboards(ctx) - if err != nil { - return nil, err - } - - result := make([]dashboards.Dashboard, len(dashs)) - for i, dashboard := range dashs { - result[i] = *dashboard - } - - return result, nil -} - func (s *Service) createMigrationRun(ctx context.Context, cmr cloudmigration.CloudMigrationSnapshot) (string, error) { uid, err := s.store.CreateMigrationRun(ctx, cmr) if err != nil { @@ -565,13 +441,13 @@ func (s *Service) GetMigrationStatus(ctx context.Context, runUID string) (*cloud return cmr, nil } -func (s *Service) GetMigrationRunList(ctx context.Context, migUID string) (*cloudmigration.SnapshotList, error) { +func (s *Service) GetMigrationRunList(ctx context.Context, migUID string) (*cloudmigration.CloudMigrationRunList, error) { runs, err := s.store.GetMigrationStatusList(ctx, migUID) if err != nil { return nil, fmt.Errorf("retrieving migration statuses from db: %w", err) } - runList := &cloudmigration.SnapshotList{Runs: []cloudmigration.MigrateDataResponseList{}} + runList := &cloudmigration.CloudMigrationRunList{Runs: []cloudmigration.MigrateDataResponseList{}} for _, s := range runs { runList.Runs = append(runList.Runs, cloudmigration.MigrateDataResponseList{ RunUID: s.UID, @@ -589,6 +465,127 @@ func (s *Service) DeleteSession(ctx context.Context, uid string) (*cloudmigratio return c, nil } +func (s *Service) CreateSnapshot(ctx context.Context, sessionUid string) (*cloudmigration.CloudMigrationSnapshot, error) { + ctx, span := s.tracer.Start(ctx, "CloudMigrationService.CreateSnapshot") + defer span.End() + + // fetch session for the gms auth token + session, err := s.store.GetMigrationSessionByUID(ctx, sessionUid) + if err != nil { + return nil, fmt.Errorf("fetching migration session for uid %s: %w", sessionUid, err) + } + + // query gms to establish new snapshot + initResp, err := s.gmsClient.InitializeSnapshot(ctx, *session) + if err != nil { + return nil, fmt.Errorf("initializing snapshot with GMS for session %s: %w", sessionUid, err) + } + + // create new directory for snapshot writing + snapshotUid := util.GenerateShortUID() + dir := filepath.Join("cloudmigration.snapshots", fmt.Sprintf("snapshot-%s-%s", snapshotUid, initResp.GMSSnapshotUID)) + err = os.MkdirAll(dir, 0750) + if err != nil { + return nil, fmt.Errorf("creating snapshot directory: %w", err) + } + + // save snapshot to the db + snapshot := cloudmigration.CloudMigrationSnapshot{ + UID: snapshotUid, + SessionUID: sessionUid, + Status: cloudmigration.SnapshotStatusInitializing, + EncryptionKey: initResp.EncryptionKey, + UploadURL: initResp.UploadURL, + GMSSnapshotUID: initResp.GMSSnapshotUID, + LocalDir: dir, + } + + uid, err := s.store.CreateSnapshot(ctx, snapshot) + if err != nil { + return nil, fmt.Errorf("saving snapshot: %w", err) + } + snapshot.UID = uid + + // start building the snapshot asynchronously while we return a success response to the client + go s.buildSnapshot(context.Background(), snapshot) + + return &snapshot, nil +} + +// GetSnapshot returns the on-prem version of a snapshot, supplemented with processing status from GMS +func (s *Service) GetSnapshot(ctx context.Context, sessionUid string, snapshotUid string) (*cloudmigration.CloudMigrationSnapshot, error) { + ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSnapshot") + defer span.End() + + snapshot, err := s.store.GetSnapshotByUID(ctx, snapshotUid) + if err != nil { + return nil, fmt.Errorf("fetching snapshot for uid %s: %w", snapshotUid, err) + } + + session, err := s.store.GetMigrationSessionByUID(ctx, sessionUid) + if err != nil { + return nil, fmt.Errorf("fetching session for uid %s: %w", sessionUid, err) + } + + if snapshot.ShouldQueryGMS() { + // ask GMS for status if it's in the cloud + snapshotMeta, err := s.gmsClient.GetSnapshotStatus(ctx, *session, *snapshot) + if err != nil { + return nil, fmt.Errorf("error fetching snapshot status from GMS: sessionUid: %s, snapshotUid: %s", sessionUid, snapshotUid) + } + + // grab any result available + // TODO: figure out a more intelligent way to do this, will depend on GMS apis + snapshot.Result = snapshotMeta.Result + + if snapshotMeta.Status == cloudmigration.SnapshotStatusFinished { + // we need to update the snapshot in our db before reporting anything finished to the client + if err := s.store.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{ + UID: snapshot.UID, + Status: cloudmigration.SnapshotStatusFinished, + Result: snapshot.Result, + }); err != nil { + return nil, fmt.Errorf("error updating snapshot status: %w", err) + } + } + } + + return snapshot, nil +} + +func (s *Service) GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error) { + ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSnapshotList") + defer span.End() + + snapshotList, err := s.store.GetSnapshotList(ctx, query) + if err != nil { + return nil, fmt.Errorf("fetching snapshots for session uid %s: %w", query.SessionUID, err) + } + return snapshotList, nil +} + +func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error { + ctx, span := s.tracer.Start(ctx, "CloudMigrationService.UploadSnapshot") + defer span.End() + + snapshot, err := s.GetSnapshot(ctx, sessionUid, snapshotUid) + if err != nil { + return fmt.Errorf("fetching snapshot with uid %s: %w", snapshotUid, err) + } + + s.log.Info("Uploading snapshot with GMS ID %s in local directory %s to url %s", snapshot.GMSSnapshotUID, snapshot.LocalDir, snapshot.UploadURL) + s.log.Debug("UploadSnapshot not yet implemented, faking it") + + // start uploading the snapshot asynchronously while we return a success response to the client + go s.uploadSnapshot(context.Background(), *snapshot) + + return nil +} + +func (s *Service) CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error { + panic("not implemented") +} + func (s *Service) parseCloudMigrationConfig() (string, error) { if s.cfg == nil { return "", fmt.Errorf("cfg cannot be nil") diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go index 2f7a808f118..3365f16c77b 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go @@ -44,7 +44,7 @@ func (s *NoopServiceImpl) GetMigrationStatus(ctx context.Context, runUID string) return nil, cloudmigration.ErrFeatureDisabledError } -func (s *NoopServiceImpl) GetMigrationRunList(ctx context.Context, uid string) (*cloudmigration.SnapshotList, error) { +func (s *NoopServiceImpl) GetMigrationRunList(ctx context.Context, uid string) (*cloudmigration.CloudMigrationRunList, error) { return nil, cloudmigration.ErrFeatureDisabledError } @@ -59,3 +59,23 @@ func (s *NoopServiceImpl) CreateMigrationRun(context.Context, cloudmigration.Clo func (s *NoopServiceImpl) RunMigration(context.Context, string) (*cloudmigration.MigrateDataResponse, error) { return nil, cloudmigration.ErrFeatureDisabledError } + +func (s *NoopServiceImpl) CreateSnapshot(ctx context.Context, sessionUid string) (*cloudmigration.CloudMigrationSnapshot, error) { + return nil, cloudmigration.ErrFeatureDisabledError +} + +func (s *NoopServiceImpl) GetSnapshot(ctx context.Context, sessionUid string, snapshotUid string) (*cloudmigration.CloudMigrationSnapshot, error) { + return nil, cloudmigration.ErrFeatureDisabledError +} + +func (s *NoopServiceImpl) GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error) { + return nil, cloudmigration.ErrFeatureDisabledError +} + +func (s *NoopServiceImpl) UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error { + return cloudmigration.ErrFeatureDisabledError +} + +func (s *NoopServiceImpl) CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error { + return cloudmigration.ErrFeatureDisabledError +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go index 9f4b1d1e4ec..83ecb800381 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go @@ -109,6 +109,63 @@ func Test_CreateGetRunMigrationsAndRuns(t *testing.T) { require.NotNil(t, createResp.UID, delMigResp.UID) } +func Test_ExecuteAsyncWorkflow(t *testing.T) { + s := setUpServiceTest(t, false) + + createTokenResp, err := s.CreateToken(context.Background()) + assert.NoError(t, err) + assert.NotEmpty(t, createTokenResp.Token) + + cmd := cloudmigration.CloudMigrationSessionRequest{ + AuthToken: createTokenResp.Token, + } + + createResp, err := s.CreateSession(context.Background(), cmd) + require.NoError(t, err) + require.NotEmpty(t, createResp.UID) + require.NotEmpty(t, createResp.Slug) + + getSessionResp, err := s.GetSession(context.Background(), createResp.UID) + require.NoError(t, err) + require.NotNil(t, getSessionResp) + require.Equal(t, createResp.UID, getSessionResp.UID) + require.Equal(t, createResp.Slug, getSessionResp.Slug) + + listResp, err := s.GetSessionList(context.Background()) + require.NoError(t, err) + require.NotNil(t, listResp) + require.Equal(t, 1, len(listResp.Sessions)) + require.Equal(t, createResp.UID, listResp.Sessions[0].UID) + require.Equal(t, createResp.Slug, listResp.Sessions[0].Slug) + + sessionUid := createResp.UID + snapshotResp, err := s.CreateSnapshot(ctxWithSignedInUser(), sessionUid) + require.NoError(t, err) + require.NotEmpty(t, snapshotResp.UID) + require.Equal(t, sessionUid, snapshotResp.SessionUID) + snapshotUid := snapshotResp.UID + + snapshot, err := s.GetSnapshot(ctxWithSignedInUser(), sessionUid, snapshotUid) + require.NoError(t, err) + assert.Equal(t, snapshotResp.UID, snapshot.UID) + assert.Equal(t, snapshotResp.EncryptionKey, snapshot.EncryptionKey) + assert.Empty(t, snapshot.Result) // will change once we create a new table for migration items + + snapshots, err := s.GetSnapshotList(ctxWithSignedInUser(), cloudmigration.ListSnapshotsQuery{SessionUID: sessionUid, Limit: 100}) + require.NoError(t, err) + assert.Len(t, snapshots, 1) + assert.Equal(t, snapshotResp.UID, snapshots[0].UID) + assert.Equal(t, snapshotResp.EncryptionKey, snapshots[0].EncryptionKey) + assert.Empty(t, snapshots[0].Result) // should remain this way even after we create a new table + + err = s.UploadSnapshot(ctxWithSignedInUser(), sessionUid, snapshotUid) + require.NoError(t, err) + + assert.Panics(t, func() { + err = s.CancelSnapshot(ctxWithSignedInUser(), sessionUid, snapshotUid) + }) +} + func ctxWithSignedInUser() context.Context { c := &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{OrgID: 1}, diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go b/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go index 1c568fce0f7..4b636ecd874 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/fake/cloudmigration_fake.go @@ -122,14 +122,68 @@ func (m FakeServiceImpl) GetMigrationStatus(_ context.Context, _ string) (*cloud }, nil } -func (m FakeServiceImpl) GetMigrationRunList(_ context.Context, _ string) (*cloudmigration.SnapshotList, error) { +func (m FakeServiceImpl) GetMigrationRunList(_ context.Context, _ string) (*cloudmigration.CloudMigrationRunList, error) { if m.ReturnError { return nil, fmt.Errorf("mock error") } - return &cloudmigration.SnapshotList{ + return &cloudmigration.CloudMigrationRunList{ Runs: []cloudmigration.MigrateDataResponseList{ {RunUID: "fake_run_uid_1"}, {RunUID: "fake_run_uid_2"}, }, }, nil } + +func (m FakeServiceImpl) CreateSnapshot(ctx context.Context, sessionUid string) (*cloudmigration.CloudMigrationSnapshot, error) { + if m.ReturnError { + return nil, fmt.Errorf("mock error") + } + return &cloudmigration.CloudMigrationSnapshot{ + UID: "fake_uid", + SessionUID: sessionUid, + Status: cloudmigration.SnapshotStatusUnknown, + }, nil +} + +func (m FakeServiceImpl) GetSnapshot(ctx context.Context, sessionUid string, snapshotUid string) (*cloudmigration.CloudMigrationSnapshot, error) { + if m.ReturnError { + return nil, fmt.Errorf("mock error") + } + return &cloudmigration.CloudMigrationSnapshot{ + UID: "fake_uid", + SessionUID: "fake_uid", + Status: cloudmigration.SnapshotStatusUnknown, + }, nil +} + +func (m FakeServiceImpl) GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error) { + if m.ReturnError { + return nil, fmt.Errorf("mock error") + } + return []cloudmigration.CloudMigrationSnapshot{ + { + UID: "fake_uid", + SessionUID: query.SessionUID, + Status: cloudmigration.SnapshotStatusUnknown, + }, + { + UID: "fake_uid", + SessionUID: query.SessionUID, + Status: cloudmigration.SnapshotStatusUnknown, + }, + }, nil +} + +func (m FakeServiceImpl) UploadSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error { + if m.ReturnError { + return fmt.Errorf("mock error") + } + return nil +} + +func (m FakeServiceImpl) CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) error { + if m.ReturnError { + return fmt.Errorf("mock error") + } + return nil +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go new file mode 100644 index 00000000000..0d43a3c7127 --- /dev/null +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go @@ -0,0 +1,234 @@ +package cloudmigrationimpl + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/cloudmigration" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/util/retryer" +) + +func (s *Service) getMigrationDataJSON(ctx context.Context) (*cloudmigration.MigrateDataRequest, error) { + // Data sources + dataSources, err := s.getDataSources(ctx) + if err != nil { + s.log.Error("Failed to get datasources", "err", err) + return nil, err + } + + // Dashboards + dashboards, err := s.getDashboards(ctx) + if err != nil { + s.log.Error("Failed to get dashboards", "err", err) + return nil, err + } + + // Folders + folders, err := s.getFolders(ctx) + if err != nil { + s.log.Error("Failed to get folders", "err", err) + return nil, err + } + + migrationDataSlice := make( + []cloudmigration.MigrateDataRequestItem, 0, + len(dataSources)+len(dashboards)+len(folders), + ) + + for _, ds := range dataSources { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.DatasourceDataType, + RefID: ds.UID, + Name: ds.Name, + Data: ds, + }) + } + + for _, dashboard := range dashboards { + dashboard.Data.Del("id") + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.DashboardDataType, + RefID: dashboard.UID, + Name: dashboard.Title, + Data: map[string]any{"dashboard": dashboard.Data}, + }) + } + + for _, f := range folders { + migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{ + Type: cloudmigration.FolderDataType, + RefID: f.UID, + Name: f.Title, + Data: f, + }) + } + + migrationData := &cloudmigration.MigrateDataRequest{ + Items: migrationDataSlice, + } + + return migrationData, nil +} + +func (s *Service) getDataSources(ctx context.Context) ([]datasources.AddDataSourceCommand, error) { + dataSources, err := s.dsService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{}) + if err != nil { + s.log.Error("Failed to get all datasources", "err", err) + return nil, err + } + + result := []datasources.AddDataSourceCommand{} + for _, dataSource := range dataSources { + // Decrypt secure json to send raw credentials + decryptedData, err := s.secretsService.DecryptJsonData(ctx, dataSource.SecureJsonData) + if err != nil { + s.log.Error("Failed to decrypt secure json data", "err", err) + return nil, err + } + dataSourceCmd := datasources.AddDataSourceCommand{ + OrgID: dataSource.OrgID, + Name: dataSource.Name, + Type: dataSource.Type, + Access: dataSource.Access, + URL: dataSource.URL, + User: dataSource.User, + Database: dataSource.Database, + BasicAuth: dataSource.BasicAuth, + BasicAuthUser: dataSource.BasicAuthUser, + WithCredentials: dataSource.WithCredentials, + IsDefault: dataSource.IsDefault, + JsonData: dataSource.JsonData, + SecureJsonData: decryptedData, + ReadOnly: dataSource.ReadOnly, + UID: dataSource.UID, + } + result = append(result, dataSourceCmd) + } + return result, err +} + +func (s *Service) getFolders(ctx context.Context) ([]folder.Folder, error) { + reqCtx := contexthandler.FromContext(ctx) + folders, err := s.folderService.GetFolders(ctx, folder.GetFoldersQuery{ + SignedInUser: reqCtx.SignedInUser, + }) + if err != nil { + return nil, err + } + + result := make([]folder.Folder, len(folders)) + for i, folder := range folders { + result[i] = *folder + } + + return result, nil +} + +func (s *Service) getDashboards(ctx context.Context) ([]dashboards.Dashboard, error) { + dashs, err := s.dashboardService.GetAllDashboards(ctx) + if err != nil { + return nil, err + } + + result := make([]dashboards.Dashboard, len(dashs)) + for i, dashboard := range dashs { + result[i] = *dashboard + } + + return result, nil +} + +// asynchronous process for writing the snapshot to the filesystem and updating the snapshot status +func (s *Service) buildSnapshot(ctx context.Context, snapshotMeta cloudmigration.CloudMigrationSnapshot) { + // TODO -- make sure we can only build one snapshot at a time + s.buildSnapshotMutex.Lock() + defer s.buildSnapshotMutex.Unlock() + s.buildSnapshotError = false + + // update snapshot status to creating, add some retries since this is a background task + if err := retryer.Retry(func() (retryer.RetrySignal, error) { + err := s.store.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{ + UID: snapshotMeta.UID, + Status: cloudmigration.SnapshotStatusCreating, + }) + return retryer.FuncComplete, err + }, 10, time.Millisecond*100, time.Second*10); err != nil { + s.log.Error("failed to set snapshot status to 'creating'", "err", err) + s.buildSnapshotError = true + return + } + + // build snapshot + // just sleep for now to simulate snapshot creation happening + // need to do a couple of fancy things when we implement this: + // - some sort of regular check-in so we know we haven't timed out + // - a channel to listen for cancel events + // - retries baked into the snapshot writing process? + s.log.Debug("snapshot meta", "snapshot", snapshotMeta) + time.Sleep(3 * time.Second) + + // update snapshot status to pending upload with retry + if err := retryer.Retry(func() (retryer.RetrySignal, error) { + err := s.store.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{ + UID: snapshotMeta.UID, + Status: cloudmigration.SnapshotStatusPendingUpload, + }) + return retryer.FuncComplete, err + }, 10, time.Millisecond*100, time.Second*10); err != nil { + s.log.Error("failed to set snapshot status to 'pending upload'", "err", err) + s.buildSnapshotError = true + } +} + +// asynchronous process for and updating the snapshot status +func (s *Service) uploadSnapshot(ctx context.Context, snapshotMeta cloudmigration.CloudMigrationSnapshot) { + // TODO -- make sure we can only upload one snapshot at a time + s.buildSnapshotMutex.Lock() + defer s.buildSnapshotMutex.Unlock() + s.buildSnapshotError = false + + // update snapshot status to uploading, add some retries since this is a background task + if err := retryer.Retry(func() (retryer.RetrySignal, error) { + err := s.store.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{ + UID: snapshotMeta.UID, + Status: cloudmigration.SnapshotStatusUploading, + }) + return retryer.FuncComplete, err + }, 10, time.Millisecond*100, time.Second*10); err != nil { + s.log.Error("failed to set snapshot status to 'creating'", "err", err) + s.buildSnapshotError = true + return + } + + // upload snapshot + // just sleep for now to simulate snapshot creation happening + s.log.Debug("snapshot meta", "snapshot", snapshotMeta) + time.Sleep(3 * time.Second) + + // update snapshot status to pending processing with retry + if err := retryer.Retry(func() (retryer.RetrySignal, error) { + err := s.store.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{ + UID: snapshotMeta.UID, + Status: cloudmigration.SnapshotStatusPendingProcessing, + }) + return retryer.FuncComplete, err + }, 10, time.Millisecond*100, time.Second*10); err != nil { + s.log.Error("failed to set snapshot status to 'pending upload'", "err", err) + s.buildSnapshotError = true + } + + // simulate the rest + // processing + time.Sleep(3 * time.Second) + if err := s.store.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{ + UID: snapshotMeta.UID, + Status: cloudmigration.SnapshotStatusProcessing, + }); err != nil { + s.log.Error("updating snapshot", "err", err) + } + // end here as the GetSnapshot handler will fill in the rest when called +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/store.go b/pkg/services/cloudmigration/cloudmigrationimpl/store.go index 58f97f79fce..e499dcf5c23 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/store.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/store.go @@ -15,4 +15,9 @@ type store interface { CreateMigrationRun(ctx context.Context, cmr cloudmigration.CloudMigrationSnapshot) (string, error) GetMigrationStatus(ctx context.Context, cmrUID string) (*cloudmigration.CloudMigrationSnapshot, error) GetMigrationStatusList(ctx context.Context, migrationUID string) ([]*cloudmigration.CloudMigrationSnapshot, error) + + CreateSnapshot(ctx context.Context, snapshot cloudmigration.CloudMigrationSnapshot) (string, error) + UpdateSnapshot(ctx context.Context, snapshot cloudmigration.UpdateSnapshotCmd) error + GetSnapshotByUID(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSnapshot, error) + GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error) } diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go index 3103aebf685..22d9e809070 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store.go @@ -146,6 +146,112 @@ func (ss *sqlStore) GetMigrationStatusList(ctx context.Context, migrationUID str return runs, nil } +func (ss *sqlStore) CreateSnapshot(ctx context.Context, snapshot cloudmigration.CloudMigrationSnapshot) (string, error) { + if err := ss.encryptKey(ctx, &snapshot); err != nil { + return "", err + } + if snapshot.Result == nil { + snapshot.Result = make([]byte, 0) + } + if snapshot.UID == "" { + snapshot.UID = util.GenerateShortUID() + } + + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + snapshot.Created = time.Now() + snapshot.Updated = time.Now() + snapshot.UID = util.GenerateShortUID() + + _, err := sess.Insert(&snapshot) + if err != nil { + return err + } + return nil + }) + if err != nil { + return "", err + } + return snapshot.UID, nil +} + +// UpdateSnapshot takes a snapshot object containing a uid and updates a subset of features in the database. +func (ss *sqlStore) UpdateSnapshot(ctx context.Context, update cloudmigration.UpdateSnapshotCmd) error { + if update.UID == "" { + return fmt.Errorf("missing snapshot uid") + } + err := ss.db.InTransaction(ctx, func(ctx context.Context) error { + // Update status if set + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + if update.Status != "" { + rawSQL := "UPDATE cloud_migration_snapshot SET status=? WHERE uid=?" + if _, err := sess.Exec(rawSQL, update.Status, update.UID); err != nil { + return fmt.Errorf("updating snapshot status for uid %s: %w", update.UID, err) + } + } + return nil + }); err != nil { + return err + } + + // Update result if set + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + if len(update.Result) > 0 { + rawSQL := "UPDATE cloud_migration_snapshot SET result=? WHERE uid=?" + if _, err := sess.Exec(rawSQL, update.Result, update.UID); err != nil { + return fmt.Errorf("updating snapshot result for uid %s: %w", update.UID, err) + } + } + return nil + }); err != nil { + return err + } + + return nil + }) + + return err +} + +func (ss *sqlStore) GetSnapshotByUID(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSnapshot, error) { + var snapshot cloudmigration.CloudMigrationSnapshot + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + exist, err := sess.Where("uid=?", uid).Get(&snapshot) + if err != nil { + return err + } + if !exist { + return cloudmigration.ErrSnapshotNotFound + } + return nil + }) + + if err := ss.decryptKey(ctx, &snapshot); err != nil { + return &snapshot, err + } + + return &snapshot, err +} + +func (ss *sqlStore) GetSnapshotList(ctx context.Context, query cloudmigration.ListSnapshotsQuery) ([]cloudmigration.CloudMigrationSnapshot, error) { + var snapshots = make([]cloudmigration.CloudMigrationSnapshot, 0) + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + sess.Limit(query.Limit, query.Offset) + return sess.Find(&snapshots, &cloudmigration.CloudMigrationSnapshot{ + SessionUID: query.SessionUID, + }) + }) + if err != nil { + return nil, err + } + for i, snapshot := range snapshots { + if err := ss.decryptKey(ctx, &snapshot); err != nil { + return nil, err + } + snapshots[i] = snapshot + } + return snapshots, nil +} + func (ss *sqlStore) encryptToken(ctx context.Context, cm *cloudmigration.CloudMigrationSession) error { s, err := ss.secretsService.Encrypt(ctx, []byte(cm.AuthToken), secrets.WithoutScope()) if err != nil { @@ -171,3 +277,29 @@ func (ss *sqlStore) decryptToken(ctx context.Context, cm *cloudmigration.CloudMi return nil } + +func (ss *sqlStore) encryptKey(ctx context.Context, snapshot *cloudmigration.CloudMigrationSnapshot) error { + s, err := ss.secretsService.Encrypt(ctx, []byte(snapshot.EncryptionKey), secrets.WithoutScope()) + if err != nil { + return fmt.Errorf("encrypting key: %w", err) + } + + snapshot.EncryptionKey = base64.StdEncoding.EncodeToString(s) + + return nil +} + +func (ss *sqlStore) decryptKey(ctx context.Context, snapshot *cloudmigration.CloudMigrationSnapshot) error { + decoded, err := base64.StdEncoding.DecodeString(snapshot.EncryptionKey) + if err != nil { + return fmt.Errorf("key could not be decoded") + } + + t, err := ss.secretsService.Decrypt(ctx, decoded) + if err != nil { + return fmt.Errorf("decrypting key: %w", err) + } + snapshot.EncryptionKey = string(t) + + return nil +} diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go index 158efdf624b..186033cc2c2 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/xorm_store_test.go @@ -11,6 +11,7 @@ import ( fakeSecrets "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" ) @@ -18,7 +19,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func Test_GetAllCloudMigrations(t *testing.T) { +func Test_GetAllCloudMigrationSessions(t *testing.T) { _, s := setUpTest(t) ctx := context.Background() @@ -44,11 +45,11 @@ func Test_GetAllCloudMigrations(t *testing.T) { }) } -func Test_CreateMigration(t *testing.T) { +func Test_CreateMigrationSession(t *testing.T) { _, s := setUpTest(t) ctx := context.Background() - t.Run("creates migrations and reads it from the db", func(t *testing.T) { + t.Run("creates a session and reads it from the db", func(t *testing.T) { cm := cloudmigration.CloudMigrationSession{ AuthToken: encodeToken("token"), Slug: "fake_stack", @@ -56,15 +57,15 @@ func Test_CreateMigration(t *testing.T) { RegionSlug: "fake_slug", ClusterSlug: "fake_cluster_slug", } - mig, err := s.CreateMigrationSession(ctx, cm) + sess, err := s.CreateMigrationSession(ctx, cm) require.NoError(t, err) - require.NotEmpty(t, mig.ID) - require.NotEmpty(t, mig.UID) + require.NotEmpty(t, sess.ID) + require.NotEmpty(t, sess.UID) - getRes, err := s.GetMigrationSessionByUID(ctx, mig.UID) + getRes, err := s.GetMigrationSessionByUID(ctx, sess.UID) require.NoError(t, err) - require.Equal(t, mig.ID, getRes.ID) - require.Equal(t, mig.UID, getRes.UID) + require.Equal(t, sess.ID, getRes.ID) + require.Equal(t, sess.UID, getRes.UID) require.Equal(t, cm.AuthToken, getRes.AuthToken) require.Equal(t, cm.Slug, getRes.Slug) require.Equal(t, cm.StackID, getRes.StackID) @@ -73,7 +74,7 @@ func Test_CreateMigration(t *testing.T) { }) } -func Test_GetMigrationByUID(t *testing.T) { +func Test_GetMigrationSessionByUID(t *testing.T) { _, s := setUpTest(t) ctx := context.Background() t.Run("find session by uid", func(t *testing.T) { @@ -89,7 +90,7 @@ func Test_GetMigrationByUID(t *testing.T) { }) } -func Test_DeleteMigration(t *testing.T) { +func Test_DeleteMigrationSession(t *testing.T) { _, s := setUpTest(t) ctx := context.Background() @@ -152,7 +153,6 @@ func Test_GetMigrationStatusList(t *testing.T) { list, err := s.GetMigrationStatusList(ctx, "qwerty") require.NoError(t, err) require.Equal(t, 2, len(list)) - // TODO validate that this is ok }) t.Run("returns no error if migration was not found, just empty list", func(t *testing.T) { @@ -162,6 +162,46 @@ func Test_GetMigrationStatusList(t *testing.T) { }) } +func Test_SnapshotManagement(t *testing.T) { + _, s := setUpTest(t) + ctx := context.Background() + + t.Run("tests the snapshot lifecycle", func(t *testing.T) { + var snapshotUid string + sessionUid := util.GenerateShortUID() + + // create a snapshot + cmr := cloudmigration.CloudMigrationSnapshot{ + SessionUID: sessionUid, + Status: "initializing", + } + + snapshotUid, err := s.CreateSnapshot(ctx, cmr) + require.NoError(t, err) + require.NotEmpty(t, snapshotUid) + + //retrieve it from the db + snapshot, err := s.GetSnapshotByUID(ctx, snapshotUid) + require.NoError(t, err) + require.Equal(t, cloudmigration.SnapshotStatusInitializing, string(snapshot.Status)) + + // update its status + err = s.UpdateSnapshot(ctx, cloudmigration.UpdateSnapshotCmd{UID: snapshotUid, Status: cloudmigration.SnapshotStatusCreating}) + require.NoError(t, err) + + //retrieve it again + snapshot, err = s.GetSnapshotByUID(ctx, snapshotUid) + require.NoError(t, err) + require.Equal(t, cloudmigration.SnapshotStatusCreating, string(snapshot.Status)) + + // lists snapshots and ensures it's in there + snapshots, err := s.GetSnapshotList(ctx, cloudmigration.ListSnapshotsQuery{SessionUID: sessionUid, Offset: 0, Limit: 100}) + require.NoError(t, err) + require.Len(t, snapshots, 1) + require.Equal(t, *snapshot, snapshots[0]) + }) +} + func setUpTest(t *testing.T) (*sqlstore.SQLStore, *sqlStore) { testDB := db.InitTestDB(t) s := &sqlStore{ @@ -188,11 +228,11 @@ func setUpTest(t *testing.T) (*sqlstore.SQLStore, *sqlStore) { // insert cloud migration run test data _, err = testDB.GetSqlxSession().Exec(ctx, ` INSERT INTO - cloud_migration_snapshot (session_uid, uid, result, created, updated, finished) + cloud_migration_snapshot (session_uid, uid, result, created, updated, finished, status) VALUES - ('qwerty', 'poiuy', ?, '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000'), - ('qwerty', 'lkjhg', ?, '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000'), - ('zxcvbn', 'mnbvvc', ?, '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000'); + ('qwerty', 'poiuy', ?, '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"), + ('qwerty', 'lkjhg', ?, '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"), + ('zxcvbn', 'mnbvvc', ?, '2024-03-25 15:30:36.000', '2024-03-27 15:30:43.000', '2024-03-27 15:30:43.000', "finished"); `, []byte("ERROR"), []byte("OK"), diff --git a/pkg/services/cloudmigration/gmsclient/client.go b/pkg/services/cloudmigration/gmsclient/client.go index cafe4d742fa..6f13a60acf3 100644 --- a/pkg/services/cloudmigration/gmsclient/client.go +++ b/pkg/services/cloudmigration/gmsclient/client.go @@ -9,6 +9,8 @@ import ( type Client interface { ValidateKey(context.Context, cloudmigration.CloudMigrationSession) error MigrateData(context.Context, cloudmigration.CloudMigrationSession, cloudmigration.MigrateDataRequest) (*cloudmigration.MigrateDataResponse, error) + InitializeSnapshot(context.Context, cloudmigration.CloudMigrationSession) (*cloudmigration.InitializeSnapshotResponse, error) + GetSnapshotStatus(context.Context, cloudmigration.CloudMigrationSession, cloudmigration.CloudMigrationSnapshot) (*cloudmigration.CloudMigrationSnapshot, error) } const logPrefix = "cloudmigration.gmsclient" diff --git a/pkg/services/cloudmigration/gmsclient/gms_client.go b/pkg/services/cloudmigration/gmsclient/gms_client.go index eb37cc9d49d..428b39d4294 100644 --- a/pkg/services/cloudmigration/gmsclient/gms_client.go +++ b/pkg/services/cloudmigration/gmsclient/gms_client.go @@ -111,6 +111,14 @@ func (c *gmsClientImpl) MigrateData(ctx context.Context, cm cloudmigration.Cloud return &result, nil } +func (c *gmsClientImpl) InitializeSnapshot(context.Context, cloudmigration.CloudMigrationSession) (*cloudmigration.InitializeSnapshotResponse, error) { + panic("not implemented") +} + +func (c *gmsClientImpl) GetSnapshotStatus(context.Context, cloudmigration.CloudMigrationSession, cloudmigration.CloudMigrationSnapshot) (*cloudmigration.CloudMigrationSnapshot, error) { + panic("not implemented") +} + func convertRequestToDTO(request cloudmigration.MigrateDataRequest) MigrateDataRequestDTO { items := make([]MigrateDataRequestItemDTO, len(request.Items)) for i := 0; i < len(request.Items); i++ { diff --git a/pkg/services/cloudmigration/gmsclient/inmemory_client.go b/pkg/services/cloudmigration/gmsclient/inmemory_client.go index 5e9b74f0db0..d2baa595f6f 100644 --- a/pkg/services/cloudmigration/gmsclient/inmemory_client.go +++ b/pkg/services/cloudmigration/gmsclient/inmemory_client.go @@ -2,9 +2,12 @@ package gmsclient import ( "context" + "encoding/json" "math/rand" + "time" "github.com/grafana/grafana/pkg/services/cloudmigration" + "github.com/grafana/grafana/pkg/util" ) // NewInMemoryClient returns an implementation of Client that returns canned responses @@ -12,7 +15,9 @@ func NewInMemoryClient() Client { return &memoryClientImpl{} } -type memoryClientImpl struct{} +type memoryClientImpl struct { + snapshot *cloudmigration.InitializeSnapshotResponse +} func (c *memoryClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.CloudMigrationSession) error { return nil @@ -43,3 +48,50 @@ func (c *memoryClientImpl) MigrateData( return &result, nil } + +func (c *memoryClientImpl) InitializeSnapshot(context.Context, cloudmigration.CloudMigrationSession) (*cloudmigration.InitializeSnapshotResponse, error) { + c.snapshot = &cloudmigration.InitializeSnapshotResponse{ + EncryptionKey: util.GenerateShortUID(), + GMSSnapshotUID: util.GenerateShortUID(), + UploadURL: "localhost:3000", + } + + return c.snapshot, nil +} + +func (c *memoryClientImpl) GetSnapshotStatus(ctx context.Context, session cloudmigration.CloudMigrationSession, snapshot cloudmigration.CloudMigrationSnapshot) (*cloudmigration.CloudMigrationSnapshot, error) { + // just fake an entire response + gmsSnapshot := cloudmigration.CloudMigrationSnapshot{ + Status: cloudmigration.SnapshotStatusFinished, + GMSSnapshotUID: util.GenerateShortUID(), + Result: []byte{}, + Finished: time.Now(), + } + + result := []cloudmigration.MigrateDataResponseItem{ + { + Type: cloudmigration.DashboardDataType, + RefID: util.GenerateShortUID(), + Status: cloudmigration.ItemStatusOK, + }, + { + Type: cloudmigration.DatasourceDataType, + RefID: util.GenerateShortUID(), + Status: cloudmigration.ItemStatusError, + Error: "fake error", + }, + { + Type: cloudmigration.FolderDataType, + RefID: util.GenerateShortUID(), + Status: cloudmigration.ItemStatusOK, + }, + } + + b, err := json.Marshal(result) + if err != nil { + return nil, err + } + gmsSnapshot.Result = b + + return &gmsSnapshot, nil +} diff --git a/pkg/services/cloudmigration/model.go b/pkg/services/cloudmigration/model.go index d80ed85184f..3974af3a046 100644 --- a/pkg/services/cloudmigration/model.go +++ b/pkg/services/cloudmigration/model.go @@ -15,6 +15,7 @@ var ( ErrMigrationRunNotFound = errutil.NotFound("cloudmigrations.migrationRunNotFound").Errorf("Migration run not found") ErrMigrationNotDeleted = errutil.Internal("cloudmigrations.sessionNotDeleted").Errorf("Session not deleted") ErrTokenNotFound = errutil.NotFound("cloudmigrations.tokenNotFound").Errorf("Token not found") + ErrSnapshotNotFound = errutil.NotFound("cloudmigrations.snapshotNotFound").Errorf("Snapshot not found") ) // CloudMigration domain structs @@ -31,26 +32,64 @@ type CloudMigrationSession struct { } type CloudMigrationSnapshot struct { - ID int64 `xorm:"pk autoincr 'id'"` - UID string `xorm:"uid"` - SessionUID string `xorm:"session_uid"` - Result []byte //store raw gms response body - Created time.Time - Updated time.Time - Finished time.Time + ID int64 `xorm:"pk autoincr 'id'"` + UID string `xorm:"uid"` + SessionUID string `xorm:"session_uid"` + Status SnapshotStatus + EncryptionKey string `xorm:"encryption_key"` // stored in the unified secrets table + UploadURL string `xorm:"upload_url"` + LocalDir string `xorm:"local_directory"` + GMSSnapshotUID string `xorm:"gms_snapshot_uid"` + ErrorString string `xorm:"error_string"` + Created time.Time + Updated time.Time + Finished time.Time + + // []MigrateDataResponseItem + Result []byte `xorm:"result"` //store raw gms response body } -func (r CloudMigrationSnapshot) GetResult() (*MigrateDataResponse, error) { +type SnapshotStatus string + +const ( + SnapshotStatusInitializing = "initializing" + SnapshotStatusCreating = "creating" + SnapshotStatusPendingUpload = "pending_upload" + SnapshotStatusUploading = "uploading" + SnapshotStatusPendingProcessing = "pending_processing" + SnapshotStatusProcessing = "processing" + SnapshotStatusFinished = "finished" + SnapshotStatusError = "error" + SnapshotStatusUnknown = "unknown" +) + +// Deprecated, use GetSnapshotResult for the async workflow +func (s CloudMigrationSnapshot) GetResult() (*MigrateDataResponse, error) { var result MigrateDataResponse - err := json.Unmarshal(r.Result, &result) + err := json.Unmarshal(s.Result, &result) if err != nil { return nil, errors.New("could not parse result of run") } - result.RunUID = r.UID + result.RunUID = s.UID return &result, nil } -type SnapshotList struct { +func (s CloudMigrationSnapshot) ShouldQueryGMS() bool { + return s.Status == SnapshotStatusPendingProcessing || s.Status == SnapshotStatusProcessing +} + +func (s CloudMigrationSnapshot) GetSnapshotResult() ([]MigrateDataResponseItem, error) { + var result []MigrateDataResponseItem + if len(s.Result) > 0 { + err := json.Unmarshal(s.Result, &result) + if err != nil { + return nil, errors.New("could not parse result of run") + } + } + return result, nil +} + +type CloudMigrationRunList struct { Runs []MigrateDataResponseList } @@ -69,6 +108,18 @@ type CloudMigrationSessionListResponse struct { Sessions []CloudMigrationSessionResponse } +type ListSnapshotsQuery struct { + SessionUID string + Offset int + Limit int +} + +type UpdateSnapshotCmd struct { + UID string + Status SnapshotStatus + Result []byte //store raw gms response body +} + // access token type CreateAccessTokenResponse struct { @@ -140,3 +191,13 @@ type MigrateDataResponseItem struct { Status ItemStatus Error string } + +type CreateSessionResponse struct { + SnapshotUid string +} + +type InitializeSnapshotResponse struct { + EncryptionKey string + UploadURL string + GMSSnapshotUID string +} diff --git a/pkg/services/datasources/errors.go b/pkg/services/datasources/errors.go index e4db5a71d0f..8147b4a4e65 100644 --- a/pkg/services/datasources/errors.go +++ b/pkg/services/datasources/errors.go @@ -18,4 +18,5 @@ var ( ErrDataSourceNameInvalid = errutil.ValidationFailed("datasource.nameInvalid", errutil.WithPublicMessage("Invalid datasource name.")) ErrDataSourceURLInvalid = errutil.ValidationFailed("datasource.urlInvalid", errutil.WithPublicMessage("Invalid datasource url.")) ErrDataSourceAPIVersionInvalid = errutil.ValidationFailed("datasource.apiVersionInvalid", errutil.WithPublicMessage("Invalid datasource apiVersion.")) + ErrDataSourceUIDInvalid = errutil.ValidationFailed("datasource.uidInvalid", errutil.WithPublicMessage("Invalid datasource UID.")) ) diff --git a/pkg/services/datasources/service/legacy.go b/pkg/services/datasources/service/legacy.go index db5b008b13e..b138a906cd6 100644 --- a/pkg/services/datasources/service/legacy.go +++ b/pkg/services/datasources/service/legacy.go @@ -8,7 +8,7 @@ import ( data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/datasources" ) @@ -56,11 +56,11 @@ func (s *cachingLegacyDataSourceLookup) GetDataSourceFromDeprecatedFields(ctx co if id == 0 && name == "" { return nil, fmt.Errorf("either name or ID must be set") } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } - key := fmt.Sprintf("%d/%s/%d", user.OrgID, name, id) + key := fmt.Sprintf("%d/%s/%d", user.GetOrgID(), name, id) s.cacheMu.Lock() defer s.cacheMu.Unlock() @@ -70,13 +70,13 @@ func (s *cachingLegacyDataSourceLookup) GetDataSourceFromDeprecatedFields(ctx co } ds, err := s.retriever.GetDataSource(ctx, &datasources.GetDataSourceQuery{ - OrgID: user.OrgID, + OrgID: user.GetOrgID(), Name: name, ID: id, }) if errors.Is(err, datasources.ErrDataSourceNotFound) && name != "" { ds, err = s.retriever.GetDataSource(ctx, &datasources.GetDataSourceQuery{ - OrgID: user.OrgID, + OrgID: user.GetOrgID(), UID: name, // Sometimes name is actually the UID :( }) } diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index 27bbc95935c..601872aefc9 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -252,8 +252,8 @@ func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataS cmd.UID = uid } else if err := util.ValidateUID(cmd.UID); err != nil { logDeprecatedInvalidDsUid(ss.logger, cmd.UID, cmd.Name, "create", err) - if ss.features != nil && ss.features.IsEnabled(ctx, featuremgmt.FlagAutofixDSUID) { - return fmt.Errorf("invalid UID for datasource %s: %w", cmd.Name, err) + if ss.features != nil && ss.features.IsEnabled(ctx, featuremgmt.FlagFailWrongDSUID) { + return datasources.ErrDataSourceUIDInvalid.Errorf("invalid UID for datasource %s: %w", cmd.Name, err) } } @@ -329,8 +329,8 @@ func (ss *SqlStore) UpdateDataSource(ctx context.Context, cmd *datasources.Updat if cmd.UID != "" { if err := util.ValidateUID(cmd.UID); err != nil { logDeprecatedInvalidDsUid(ss.logger, cmd.UID, cmd.Name, "update", err) - if ss.features != nil && ss.features.IsEnabled(ctx, featuremgmt.FlagAutofixDSUID) { - cmd.UID = util.AutofixUID(cmd.UID) + if ss.features != nil && ss.features.IsEnabled(ctx, featuremgmt.FlagFailWrongDSUID) { + return datasources.ErrDataSourceUIDInvalid.Errorf("invalid UID for datasource %s: %w", cmd.Name, err) } } } diff --git a/pkg/services/datasources/service/store_test.go b/pkg/services/datasources/service/store_test.go index e0cd2e06dc2..5ade7093422 100644 --- a/pkg/services/datasources/service/store_test.go +++ b/pkg/services/datasources/service/store_test.go @@ -104,7 +104,7 @@ func TestIntegrationDataAccess(t *testing.T) { ss := SqlStore{ db: db, logger: log.NewNopLogger(), - features: featuremgmt.WithFeatures(featuremgmt.FlagAutofixDSUID), + features: featuremgmt.WithFeatures(featuremgmt.FlagFailWrongDSUID), } cmd := defaultAddDatasourceCommand cmd.UID = "test/uid" @@ -232,28 +232,21 @@ func TestIntegrationDataAccess(t *testing.T) { require.NoError(t, err) }) - t.Run("updates UID with a valid one", func(t *testing.T) { + t.Run("fails to update a datasource with an invalid uid", func(t *testing.T) { db := db.InitTestDB(t) ds := initDatasource(db) ss := SqlStore{ db: db, logger: log.NewNopLogger(), - features: featuremgmt.WithFeatures(featuremgmt.FlagAutofixDSUID), + features: featuremgmt.WithFeatures(featuremgmt.FlagFailWrongDSUID), } require.NotEmpty(t, ds.UID) cmd := defaultUpdateDatasourceCommand cmd.ID = ds.ID cmd.UID = "new/uid" - res, err := ss.UpdateDataSource(context.Background(), &cmd) - require.NoError(t, err) - require.Equal(t, "new-uid", res.UID) - - // Return the datasource with the valid UID - query := datasources.GetDataSourceQuery{UID: "new-uid", OrgID: 10} - dataSource, err := ss.GetDataSource(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, "new-uid", dataSource.UID) + _, err := ss.UpdateDataSource(context.Background(), &cmd) + require.ErrorContains(t, err, "invalid format of UID") }) }) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 51bf6ea8828..0289273c042 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -99,15 +99,6 @@ var ( Expression: "true", // enabled by default AllowSelfServe: true, }, - { - Name: "exploreContentOutline", - Description: "Content outline sidebar", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaExploreSquad, - Expression: "true", // enabled by default - FrontendOnly: true, - AllowSelfServe: true, - }, { Name: "datasourceQueryMultiStatus", Description: "Introduce HTTP 207 Multi Status for api/ds/query", @@ -480,13 +471,6 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, }, - { - Name: "prometheusIncrementalQueryInstrumentation", - Description: "Adds RudderStack events to incremental queries", - FrontendOnly: true, - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityMetricsSquad, - }, { Name: "logsExploreTableVisualisation", Description: "A table visualisation for logs in Explore", @@ -700,11 +684,24 @@ var ( FrontendOnly: true, }, { - Name: "enableNativeHTTPHistogram", - Description: "Enables native HTTP Histograms", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: hostedGrafanaTeam, + Name: "enableNativeHTTPHistogram", + Description: "Enables native HTTP Histograms", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaBackendServicesSquad, + HideFromAdminPage: true, + AllowSelfServe: false, + RequiresRestart: true, + }, + { + Name: "disableClassicHTTPHistogram", + Description: "Disables classic HTTP Histogram (use with enableNativeHTTPHistogram)", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaBackendServicesSquad, + HideFromAdminPage: true, + AllowSelfServe: false, + RequiresRestart: true, }, { Name: "formatString", @@ -1209,12 +1206,6 @@ var ( FrontendOnly: false, AllowSelfServe: false, }, - { - Name: "autofixDSUID", - Description: "Automatically migrates invalid datasource UIDs", - Stage: FeatureStageExperimental, - Owner: grafanaPluginsPlatformSquad, - }, { Name: "logsExploreTableDefaultVisualization", Description: "Sets the logs table as default visualisation in logs explore", @@ -1323,6 +1314,12 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "failWrongDSUID", + Description: "Throws an error if a datasource has an invalid UIDs", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + }, { Name: "databaseReadReplica", Description: "Use a read replica for some database queries.", @@ -1330,6 +1327,32 @@ var ( Owner: grafanaBackendServicesSquad, Expression: "false", // enabled by default }, + { + Name: "zanzana", + Description: "Use openFGA as authorization engine.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + HideFromAdminPage: true, + }, + { + Name: "passScopeToDashboardApi", + Description: "Enables the passing of scopes to dashboards fetching in Grafana", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + AllowSelfServe: false, + HideFromDocs: true, + HideFromAdminPage: true, + }, + { + Name: "alertingApiServer", + Description: "Register Alerting APIs with the K8s API server", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + RequiresRestart: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c0b5e76d4c1..cc6e62d5cbc 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -10,7 +10,6 @@ lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,f featureHighlights,GA,@grafana/grafana-as-code,false,false,false storage,experimental,@grafana/grafana-app-platform-squad,false,false,false correlations,GA,@grafana/explore-squad,false,false,false -exploreContentOutline,GA,@grafana/explore-squad,false,false,true datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,false,false,false autoMigrateOldPanels,preview,@grafana/dataviz-squad,false,false,true autoMigrateGraphPanel,preview,@grafana/dataviz-squad,false,false,true @@ -63,7 +62,6 @@ frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,false, sqlDatasourceDatabaseSelection,preview,@grafana/dataviz-squad,false,false,true recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false vizAndWidgetSplit,experimental,@grafana/dashboards-squad,false,false,true -prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,false,false,true logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false transformationsRedesign,GA,@grafana/observability-metrics,false,false,true @@ -92,7 +90,8 @@ pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,tru idForwarding,experimental,@grafana/identity-access-team,false,false,false externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false panelMonitoring,GA,@grafana/dataviz-squad,false,false,true -enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false +enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false +disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false formatString,preview,@grafana/dataviz-squad,false,false,true transformationsVariableSupport,GA,@grafana/dataviz-squad,false,false,true kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false @@ -160,7 +159,6 @@ accessActionSets,experimental,@grafana/identity-access-team,false,false,false disableNumericMetricsSortingInExpressions,experimental,@grafana/observability-metrics,false,true,false grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false queryLibrary,experimental,@grafana/explore-squad,false,false,false -autofixDSUID,experimental,@grafana/plugins-platform-backend,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true alertingListViewV2,experimental,@grafana/alerting-squad,false,false,true @@ -176,4 +174,8 @@ pinNavItems,experimental,@grafana/grafana-frontend-platform,false,false,false authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false openSearchBackendFlowEnabled,preview,@grafana/aws-datasources,false,false,false ssoSettingsLDAP,experimental,@grafana/identity-access-team,false,false,false +failWrongDSUID,experimental,@grafana/plugins-platform-backend,false,false,false databaseReadReplica,experimental,@grafana/grafana-backend-services-squad,false,false,false +zanzana,experimental,@grafana/identity-access-team,false,false,false +passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false +alertingApiServer,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d7f10257ddf..f57bbaaa50b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -51,10 +51,6 @@ const ( // Correlations page FlagCorrelations = "correlations" - // FlagExploreContentOutline - // Content outline sidebar - FlagExploreContentOutline = "exploreContentOutline" - // FlagDatasourceQueryMultiStatus // Introduce HTTP 207 Multi Status for api/ds/query FlagDatasourceQueryMultiStatus = "datasourceQueryMultiStatus" @@ -263,10 +259,6 @@ const ( // Split panels between visualizations and widgets FlagVizAndWidgetSplit = "vizAndWidgetSplit" - // FlagPrometheusIncrementalQueryInstrumentation - // Adds RudderStack events to incremental queries - FlagPrometheusIncrementalQueryInstrumentation = "prometheusIncrementalQueryInstrumentation" - // FlagLogsExploreTableVisualisation // A table visualisation for logs in Explore FlagLogsExploreTableVisualisation = "logsExploreTableVisualisation" @@ -383,6 +375,10 @@ const ( // Enables native HTTP Histograms FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram" + // FlagDisableClassicHTTPHistogram + // Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) + FlagDisableClassicHTTPHistogram = "disableClassicHTTPHistogram" + // FlagFormatString // Enable format string transformer FlagFormatString = "formatString" @@ -651,10 +647,6 @@ const ( // Enables Query Library feature in Explore FlagQueryLibrary = "queryLibrary" - // FlagAutofixDSUID - // Automatically migrates invalid datasource UIDs - FlagAutofixDSUID = "autofixDSUID" - // FlagLogsExploreTableDefaultVisualization // Sets the logs table as default visualisation in logs explore FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization" @@ -715,7 +707,23 @@ const ( // Use the new SSO Settings API to configure LDAP FlagSsoSettingsLDAP = "ssoSettingsLDAP" + // FlagFailWrongDSUID + // Throws an error if a datasource has an invalid UIDs + FlagFailWrongDSUID = "failWrongDSUID" + // FlagDatabaseReadReplica // Use a read replica for some database queries. FlagDatabaseReadReplica = "databaseReadReplica" + + // FlagZanzana + // Use openFGA as authorization engine. + FlagZanzana = "zanzana" + + // FlagPassScopeToDashboardApi + // Enables the passing of scopes to dashboards fetching in Grafana + FlagPassScopeToDashboardApi = "passScopeToDashboardApi" + + // FlagAlertingApiServer + // Register Alerting APIs with the K8s API server + FlagAlertingApiServer = "alertingApiServer" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 02df39301fd..9e36e48fc5a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -6,7 +6,7 @@ { "metadata": { "name": "accessActionSets", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-12T16:19:25Z" }, "spec": { @@ -18,7 +18,7 @@ { "metadata": { "name": "accessControlOnCall", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-10-19T16:10:09Z" }, "spec": { @@ -31,7 +31,7 @@ { "metadata": { "name": "addFieldFromCalculationStatFunctions", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-03T14:39:58Z" }, "spec": { @@ -44,7 +44,7 @@ { "metadata": { "name": "aiGeneratedDashboardChanges", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-03-05T12:01:31Z" }, "spec": { @@ -57,7 +57,7 @@ { "metadata": { "name": "alertStateHistoryLokiOnly", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-30T18:53:21Z" }, "spec": { @@ -69,7 +69,7 @@ { "metadata": { "name": "alertStateHistoryLokiPrimary", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-30T18:53:21Z" }, "spec": { @@ -81,7 +81,7 @@ { "metadata": { "name": "alertStateHistoryLokiSecondary", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-30T18:53:21Z" }, "spec": { @@ -90,10 +90,23 @@ "codeowner": "@grafana/alerting-squad" } }, + { + "metadata": { + "name": "alertingApiServer", + "resourceVersion": "1718908755156", + "creationTimestamp": "2024-06-20T18:39:15Z" + }, + "spec": { + "description": "Register Alerting APIs with the K8s API server", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "alertingBacktesting", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-12-14T14:44:14Z" }, "spec": { @@ -105,7 +118,7 @@ { "metadata": { "name": "alertingCentralAlertHistory", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-29T15:01:38Z" }, "spec": { @@ -118,7 +131,7 @@ { "metadata": { "name": "alertingDisableSendAlertsExternal", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-23T12:29:19Z" }, "spec": { @@ -132,7 +145,7 @@ { "metadata": { "name": "alertingInsights", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-14T12:58:04Z" }, "spec": { @@ -146,7 +159,7 @@ { "metadata": { "name": "alertingListViewV2", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-24T14:40:49Z" }, "spec": { @@ -159,7 +172,7 @@ { "metadata": { "name": "alertingNoDataErrorExecution", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-08-15T14:27:15Z" }, "spec": { @@ -172,7 +185,7 @@ { "metadata": { "name": "alertingNoNormalState", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-01-13T23:29:29Z" }, "spec": { @@ -185,7 +198,7 @@ { "metadata": { "name": "alertingQueryOptimization", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-10T20:52:58Z" }, "spec": { @@ -197,7 +210,7 @@ { "metadata": { "name": "alertingSaveStatePeriodic", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-23T16:03:30Z" }, "spec": { @@ -209,7 +222,7 @@ { "metadata": { "name": "alertingSimplifiedRouting", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-10T13:14:39Z" }, "spec": { @@ -221,7 +234,7 @@ { "metadata": { "name": "alertmanagerRemoteOnly", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-30T16:27:08Z" }, "spec": { @@ -233,7 +246,7 @@ { "metadata": { "name": "alertmanagerRemotePrimary", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-30T16:27:08Z" }, "spec": { @@ -245,7 +258,7 @@ { "metadata": { "name": "alertmanagerRemoteSecondary", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-30T16:27:08Z" }, "spec": { @@ -257,7 +270,7 @@ { "metadata": { "name": "angularDeprecationUI", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-08-29T14:05:47Z" }, "spec": { @@ -270,7 +283,7 @@ { "metadata": { "name": "annotationPermissionUpdate", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-31T13:30:13Z" }, "spec": { @@ -282,7 +295,7 @@ { "metadata": { "name": "authAPIAccessTokenAuth", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-02T15:45:15Z" }, "spec": { @@ -296,8 +309,8 @@ { "metadata": { "name": "authZGRPCServer", - "resourceVersion": "1718093439898", - "creationTimestamp": "2024-06-11T08:10:39Z" + "resourceVersion": "1718727528075", + "creationTimestamp": "2024-06-18T16:18:48Z" }, "spec": { "description": "Enables the gRPC server for authorization", @@ -310,7 +323,7 @@ { "metadata": { "name": "autoMigrateGraphPanel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-08T22:00:48Z" }, "spec": { @@ -323,7 +336,7 @@ { "metadata": { "name": "autoMigrateOldPanels", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-23T04:02:36Z" }, "spec": { @@ -336,7 +349,7 @@ { "metadata": { "name": "autoMigratePiechartPanel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-14T16:06:25Z" }, "spec": { @@ -349,7 +362,7 @@ { "metadata": { "name": "autoMigrateStatPanel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-14T16:06:25Z" }, "spec": { @@ -362,7 +375,7 @@ { "metadata": { "name": "autoMigrateTablePanel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-14T16:06:25Z" }, "spec": { @@ -375,7 +388,7 @@ { "metadata": { "name": "autoMigrateWorldmapPanel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-14T16:06:25Z" }, "spec": { @@ -388,7 +401,7 @@ { "metadata": { "name": "autoMigrateXYChartPanel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-03-22T15:44:37Z" }, "spec": { @@ -402,7 +415,8 @@ "metadata": { "name": "autofixDSUID", "resourceVersion": "1717578796182", - "creationTimestamp": "2024-05-03T11:32:07Z" + "creationTimestamp": "2024-05-03T11:32:07Z", + "deletionTimestamp": "2024-06-18T14:28:32Z" }, "spec": { "description": "Automatically migrates invalid datasource UIDs", @@ -413,7 +427,7 @@ { "metadata": { "name": "awsAsyncQueryCaching", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-21T15:34:07Z" }, "spec": { @@ -425,7 +439,7 @@ { "metadata": { "name": "awsDatasourcesNewFormStyling", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-12T08:59:10Z" }, "spec": { @@ -438,7 +452,7 @@ { "metadata": { "name": "awsDatasourcesTempCredentials", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-06T15:06:11Z" }, "spec": { @@ -450,7 +464,7 @@ { "metadata": { "name": "azureMonitorPrometheusExemplars", - "resourceVersion": "1717667267324", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-06-06T16:53:17Z" }, "spec": { @@ -459,24 +473,10 @@ "codeowner": "@grafana/partner-datasources" } }, - { - "metadata": { - "name": "betterPageScrolling", - "resourceVersion": "1717578796182", - "creationTimestamp": "2024-03-06T15:06:47Z", - "deletionTimestamp": "2024-06-18T09:25:56Z" - }, - "spec": { - "description": "Removes CustomScrollbar from the UI, relying on native browser scrollbars", - "stage": "GA", - "codeowner": "@grafana/grafana-frontend-platform", - "frontend": true - } - }, { "metadata": { "name": "cachingOptimizeSerializationMemoryUsage", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-12T16:56:49Z" }, "spec": { @@ -488,7 +488,7 @@ { "metadata": { "name": "canvasPanelNesting", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-05-31T19:03:34Z" }, "spec": { @@ -502,7 +502,7 @@ { "metadata": { "name": "canvasPanelPanZoom", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-02T19:52:21Z" }, "spec": { @@ -515,7 +515,7 @@ { "metadata": { "name": "cloudRBACRoles", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-10T13:19:01Z" }, "spec": { @@ -529,7 +529,7 @@ { "metadata": { "name": "cloudWatchBatchQueries", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-20T19:09:41Z" }, "spec": { @@ -541,7 +541,7 @@ { "metadata": { "name": "cloudWatchCrossAccountQuerying", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-11-28T11:39:12Z" }, "spec": { @@ -554,7 +554,7 @@ { "metadata": { "name": "cloudWatchNewLabelParsing", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-05T15:57:56Z" }, "spec": { @@ -566,7 +566,7 @@ { "metadata": { "name": "configurableSchedulerTick", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-26T16:44:12Z" }, "spec": { @@ -580,7 +580,7 @@ { "metadata": { "name": "correlations", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-09-16T13:14:27Z" }, "spec": { @@ -593,7 +593,7 @@ { "metadata": { "name": "dashboardRestore", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-16T17:36:26Z" }, "spec": { @@ -606,7 +606,7 @@ { "metadata": { "name": "dashboardScene", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-13T08:51:21Z" }, "spec": { @@ -619,7 +619,7 @@ { "metadata": { "name": "dashboardSceneForViewers", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-02T19:02:25Z" }, "spec": { @@ -632,7 +632,7 @@ { "metadata": { "name": "dashboardSceneSolo", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-11T08:08:47Z" }, "spec": { @@ -645,7 +645,7 @@ { "metadata": { "name": "dashgpt", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-08-30T20:22:05Z" }, "spec": { @@ -658,8 +658,8 @@ { "metadata": { "name": "databaseReadReplica", - "resourceVersion": "1718308641844", - "creationTimestamp": "2024-06-13T19:57:21Z" + "resourceVersion": "1718727528075", + "creationTimestamp": "2024-06-18T16:18:48Z" }, "spec": { "description": "Use a read replica for some database queries.", @@ -670,7 +670,7 @@ { "metadata": { "name": "dataplaneFrontendFallback", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-04-07T21:13:19Z" }, "spec": { @@ -684,7 +684,7 @@ { "metadata": { "name": "datasourceProxyDisableRBAC", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-21T13:05:16Z" }, "spec": { @@ -697,7 +697,7 @@ { "metadata": { "name": "datasourceQueryMultiStatus", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-05-03T16:02:20Z" }, "spec": { @@ -709,7 +709,7 @@ { "metadata": { "name": "datasourceQueryTypes", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-23T16:46:28Z" }, "spec": { @@ -722,7 +722,7 @@ { "metadata": { "name": "disableAngular", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-23T15:43:45Z" }, "spec": { @@ -733,10 +733,24 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "disableClassicHTTPHistogram", + "resourceVersion": "1718727528075", + "creationTimestamp": "2024-06-18T16:18:48Z" + }, + "spec": { + "description": "Disables classic HTTP Histogram (use with enableNativeHTTPHistogram)", + "stage": "experimental", + "codeowner": "@grafana/grafana-backend-services-squad", + "requiresRestart": true, + "hideFromAdminPage": true + } + }, { "metadata": { "name": "disableEnvelopeEncryption", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-05-24T08:34:47Z" }, "spec": { @@ -749,7 +763,7 @@ { "metadata": { "name": "disableNumericMetricsSortingInExpressions", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-16T14:52:47Z" }, "spec": { @@ -762,7 +776,7 @@ { "metadata": { "name": "disableSSEDataplane", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-04-12T16:24:34Z" }, "spec": { @@ -774,7 +788,7 @@ { "metadata": { "name": "disableSecretsCompatibility", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-07-12T20:27:37Z" }, "spec": { @@ -787,7 +801,7 @@ { "metadata": { "name": "editPanelCSVDragAndDrop", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-01-24T09:43:44Z" }, "spec": { @@ -800,7 +814,7 @@ { "metadata": { "name": "enableDatagridEditing", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-04-24T14:46:31Z" }, "spec": { @@ -810,37 +824,26 @@ "frontend": true } }, - { - "metadata": { - "name": "enableElasticsearchBackendQuerying", - "resourceVersion": "1717578796182", - "creationTimestamp": "2023-04-14T09:24:35Z", - "deletionTimestamp": "2024-06-05T15:03:29Z" - }, - "spec": { - "description": "Enable the processing of queries and responses in the Elasticsearch data source through backend", - "stage": "GA", - "codeowner": "@grafana/observability-logs", - "allowSelfServe": true - } - }, { "metadata": { "name": "enableNativeHTTPHistogram", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-03T18:23:55Z" }, "spec": { "description": "Enables native HTTP Histograms", "stage": "experimental", - "codeowner": "@grafana/hosted-grafana-team" + "codeowner": "@grafana/grafana-backend-services-squad", + "requiresRestart": true, + "hideFromAdminPage": true } }, { "metadata": { "name": "exploreContentOutline", "resourceVersion": "1717578796182", - "creationTimestamp": "2023-10-13T16:57:13Z" + "creationTimestamp": "2023-10-13T16:57:13Z", + "deletionTimestamp": "2024-06-17T09:45:00Z" }, "spec": { "description": "Content outline sidebar", @@ -853,7 +856,7 @@ { "metadata": { "name": "exploreMetrics", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-09T18:15:18Z" }, "spec": { @@ -866,7 +869,7 @@ { "metadata": { "name": "expressionParser", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-17T00:59:11Z" }, "spec": { @@ -879,7 +882,7 @@ { "metadata": { "name": "externalCorePlugins", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-22T08:50:13Z" }, "spec": { @@ -891,7 +894,7 @@ { "metadata": { "name": "externalServiceAccounts", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-28T07:26:37Z" }, "spec": { @@ -904,7 +907,7 @@ { "metadata": { "name": "extraThemes", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-05-10T13:37:04Z" }, "spec": { @@ -917,7 +920,7 @@ { "metadata": { "name": "extractFieldsNameDeduplication", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-02T15:47:42Z" }, "spec": { @@ -927,10 +930,22 @@ "frontend": true } }, + { + "metadata": { + "name": "failWrongDSUID", + "resourceVersion": "1718721033692", + "creationTimestamp": "2024-06-18T14:30:33Z" + }, + "spec": { + "description": "Throws an error if a datasource has an invalid UIDs", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, { "metadata": { "name": "faroDatasourceSelector", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-05-05T00:35:10Z" }, "spec": { @@ -943,7 +958,7 @@ { "metadata": { "name": "featureHighlights", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-02-03T11:53:23Z" }, "spec": { @@ -956,7 +971,7 @@ { "metadata": { "name": "featureToggleAdminPage", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-18T20:43:32Z" }, "spec": { @@ -970,7 +985,7 @@ { "metadata": { "name": "flameGraphItemCollapsing", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-09T14:31:07Z" }, "spec": { @@ -983,7 +998,7 @@ { "metadata": { "name": "formatString", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-13T18:17:12Z" }, "spec": { @@ -996,7 +1011,7 @@ { "metadata": { "name": "frontendSandboxMonitorOnly", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-05T11:48:25Z" }, "spec": { @@ -1009,7 +1024,7 @@ { "metadata": { "name": "grafanaAPIServerEnsureKubectlAccess", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-12-06T20:21:21Z" }, "spec": { @@ -1023,7 +1038,7 @@ { "metadata": { "name": "grafanaAPIServerWithExperimentalAPIs", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-06T18:55:22Z" }, "spec": { @@ -1037,7 +1052,7 @@ { "metadata": { "name": "grafanaManagedRecordingRules", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-22T17:53:16Z" }, "spec": { @@ -1051,7 +1066,7 @@ { "metadata": { "name": "groupByVariable", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-14T17:18:04Z" }, "spec": { @@ -1065,7 +1080,7 @@ { "metadata": { "name": "groupToNestedTableTransformation", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-07T14:28:26Z" }, "spec": { @@ -1078,7 +1093,7 @@ { "metadata": { "name": "grpcServer", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-09-26T20:25:34Z" }, "spec": { @@ -1091,7 +1106,7 @@ { "metadata": { "name": "idForwarding", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-25T15:21:28Z" }, "spec": { @@ -1103,7 +1118,7 @@ { "metadata": { "name": "individualCookiePreferences", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-02-21T10:19:07Z" }, "spec": { @@ -1115,7 +1130,7 @@ { "metadata": { "name": "influxdbBackendMigration", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-02-09T18:26:16Z", "deletionTimestamp": "2023-01-17T14:11:26Z" }, @@ -1129,7 +1144,7 @@ { "metadata": { "name": "influxdbRunQueriesInParallel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-01T10:58:24Z" }, "spec": { @@ -1141,7 +1156,7 @@ { "metadata": { "name": "influxqlStreamingParser", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-29T17:29:35Z" }, "spec": { @@ -1153,7 +1168,7 @@ { "metadata": { "name": "jitterAlertRulesWithinGroups", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-18T18:48:11Z" }, "spec": { @@ -1167,7 +1182,7 @@ { "metadata": { "name": "kubernetesAggregator", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-12T20:59:35Z" }, "spec": { @@ -1180,7 +1195,7 @@ { "metadata": { "name": "kubernetesDashboards", - "resourceVersion": "1717593661635", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-06-05T14:34:23Z" }, "spec": { @@ -1193,7 +1208,7 @@ { "metadata": { "name": "kubernetesFeatureToggles", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-18T05:32:44Z" }, "spec": { @@ -1207,7 +1222,7 @@ { "metadata": { "name": "kubernetesPlaylists", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-05T19:00:36Z" }, "spec": { @@ -1220,7 +1235,7 @@ { "metadata": { "name": "kubernetesSnapshots", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-12-05T22:31:49Z" }, "spec": { @@ -1233,7 +1248,7 @@ { "metadata": { "name": "libraryPanelRBAC", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-11T23:30:50Z" }, "spec": { @@ -1246,7 +1261,7 @@ { "metadata": { "name": "live-service-web-worker", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-01-26T17:44:20Z" }, "spec": { @@ -1259,7 +1274,7 @@ { "metadata": { "name": "logRequestsInstrumentedAsUnknown", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-06-10T08:56:55Z" }, "spec": { @@ -1271,7 +1286,7 @@ { "metadata": { "name": "logRowsPopoverMenu", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-16T09:48:10Z" }, "spec": { @@ -1284,7 +1299,7 @@ { "metadata": { "name": "logsContextDatasourceUi", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-01-27T14:12:01Z" }, "spec": { @@ -1298,7 +1313,7 @@ { "metadata": { "name": "logsExploreTableDefaultVisualization", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-02T15:28:15Z" }, "spec": { @@ -1311,7 +1326,7 @@ { "metadata": { "name": "logsExploreTableVisualisation", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-12T13:52:42Z" }, "spec": { @@ -1324,7 +1339,7 @@ { "metadata": { "name": "logsInfiniteScrolling", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-09T10:54:03Z" }, "spec": { @@ -1337,7 +1352,7 @@ { "metadata": { "name": "lokiExperimentalStreaming", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-19T10:03:51Z" }, "spec": { @@ -1349,7 +1364,7 @@ { "metadata": { "name": "lokiLogsDataplane", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-13T07:58:00Z" }, "spec": { @@ -1361,7 +1376,7 @@ { "metadata": { "name": "lokiMetricDataplane", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-04-13T13:07:08Z" }, "spec": { @@ -1374,7 +1389,7 @@ { "metadata": { "name": "lokiPredefinedOperations", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-02T10:52:36Z" }, "spec": { @@ -1387,7 +1402,7 @@ { "metadata": { "name": "lokiQueryHints", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-12-18T20:43:16Z" }, "spec": { @@ -1400,7 +1415,7 @@ { "metadata": { "name": "lokiQuerySplitting", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-02-09T17:27:02Z" }, "spec": { @@ -1414,7 +1429,7 @@ { "metadata": { "name": "lokiQuerySplittingConfig", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-20T15:51:36Z" }, "spec": { @@ -1427,7 +1442,7 @@ { "metadata": { "name": "lokiRunQueriesInParallel", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-19T09:34:01Z" }, "spec": { @@ -1439,7 +1454,7 @@ { "metadata": { "name": "lokiStructuredMetadata", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-16T16:06:14Z" }, "spec": { @@ -1451,7 +1466,7 @@ { "metadata": { "name": "managedPluginsInstall", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-18T13:17:03Z" }, "spec": { @@ -1463,7 +1478,7 @@ { "metadata": { "name": "metricsSummary", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-08-28T14:02:12Z" }, "spec": { @@ -1476,7 +1491,7 @@ { "metadata": { "name": "mlExpressions", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-13T17:37:50Z" }, "spec": { @@ -1488,7 +1503,7 @@ { "metadata": { "name": "mysqlAnsiQuotes", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-10-12T11:43:35Z" }, "spec": { @@ -1500,7 +1515,7 @@ { "metadata": { "name": "nestedFolders", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-10-26T14:15:14Z" }, "spec": { @@ -1512,7 +1527,7 @@ { "metadata": { "name": "newDashboardSharingComponent", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-03T15:02:18Z" }, "spec": { @@ -1525,7 +1540,7 @@ { "metadata": { "name": "newDashboardWithFiltersAndGroupBy", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-04T11:25:21Z" }, "spec": { @@ -1539,7 +1554,7 @@ { "metadata": { "name": "newFolderPicker", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-15T11:43:19Z" }, "spec": { @@ -1552,7 +1567,7 @@ { "metadata": { "name": "newPDFRendering", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-08T12:09:34Z" }, "spec": { @@ -1564,7 +1579,7 @@ { "metadata": { "name": "nodeGraphDotLayout", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-31T16:26:12Z" }, "spec": { @@ -1577,7 +1592,7 @@ { "metadata": { "name": "notificationBanner", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-13T09:32:34Z" }, "spec": { @@ -1589,7 +1604,7 @@ { "metadata": { "name": "oauthRequireSubClaim", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-03-25T13:22:24Z" }, "spec": { @@ -1603,7 +1618,7 @@ { "metadata": { "name": "onPremToCloudMigrations", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-22T16:09:08Z" }, "spec": { @@ -1615,8 +1630,8 @@ { "metadata": { "name": "openSearchBackendFlowEnabled", - "resourceVersion": "1718357852240", - "creationTimestamp": "2024-06-14T09:37:32Z" + "resourceVersion": "1718727528075", + "creationTimestamp": "2024-06-18T16:18:48Z" }, "spec": { "description": "Enables the backend query flow for Open Search datasource plugin", @@ -1627,7 +1642,7 @@ { "metadata": { "name": "panelFilterVariable", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-03T12:15:54Z" }, "spec": { @@ -1641,7 +1656,7 @@ { "metadata": { "name": "panelMonitoring", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-09T05:19:08Z" }, "spec": { @@ -1654,7 +1669,7 @@ { "metadata": { "name": "panelTitleSearch", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-02-15T18:26:03Z" }, "spec": { @@ -1667,7 +1682,7 @@ { "metadata": { "name": "panelTitleSearchInV1", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-13T12:04:24Z" }, "spec": { @@ -1677,10 +1692,24 @@ "requiresDevMode": true } }, + { + "metadata": { + "name": "passScopeToDashboardApi", + "resourceVersion": "1718290335877", + "creationTimestamp": "2024-06-13T14:52:15Z" + }, + "spec": { + "description": "Enables the passing of scopes to dashboards fetching in Grafana", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "pdfTables", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-06T13:39:22Z" }, "spec": { @@ -1692,7 +1721,7 @@ { "metadata": { "name": "permissionsFilterRemoveSubquery", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-08-02T07:39:25Z" }, "spec": { @@ -1704,8 +1733,8 @@ { "metadata": { "name": "pinNavItems", - "resourceVersion": "1718017263521", - "creationTimestamp": "2024-06-10T11:01:03Z" + "resourceVersion": "1718727528075", + "creationTimestamp": "2024-06-18T16:18:48Z" }, "spec": { "description": "Enables pinning of nav items", @@ -1716,7 +1745,7 @@ { "metadata": { "name": "pluginProxyPreserveTrailingSlash", - "resourceVersion": "1717581171624", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-06-05T11:36:14Z" }, "spec": { @@ -1728,7 +1757,7 @@ { "metadata": { "name": "pluginsAPIMetrics", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-21T11:36:32Z" }, "spec": { @@ -1741,7 +1770,7 @@ { "metadata": { "name": "pluginsFrontendSandbox", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-05T08:51:36Z" }, "spec": { @@ -1754,7 +1783,7 @@ { "metadata": { "name": "pluginsSkipHostEnvVars", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-15T17:09:14Z" }, "spec": { @@ -1766,7 +1795,7 @@ { "metadata": { "name": "preserveDashboardStateWhenNavigating", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-27T12:28:06Z" }, "spec": { @@ -1780,7 +1809,7 @@ { "metadata": { "name": "promQLScope", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-01-29T20:22:17Z" }, "spec": { @@ -1792,7 +1821,7 @@ { "metadata": { "name": "prometheusCodeModeMetricNamesSearch", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-04T20:38:23Z" }, "spec": { @@ -1805,7 +1834,7 @@ { "metadata": { "name": "prometheusConfigOverhaulAuth", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-26T16:09:53Z" }, "spec": { @@ -1817,7 +1846,7 @@ { "metadata": { "name": "prometheusDataplane", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-29T15:26:32Z" }, "spec": { @@ -1830,8 +1859,9 @@ { "metadata": { "name": "prometheusIncrementalQueryInstrumentation", - "resourceVersion": "1717578796182", - "creationTimestamp": "2023-07-05T19:39:49Z" + "resourceVersion": "1718727528075", + "creationTimestamp": "2023-07-05T19:39:49Z", + "deletionTimestamp": "2024-06-20T11:30:37Z" }, "spec": { "description": "Adds RudderStack events to incremental queries", @@ -1843,7 +1873,7 @@ { "metadata": { "name": "prometheusMetricEncyclopedia", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-07T18:41:05Z" }, "spec": { @@ -1857,7 +1887,7 @@ { "metadata": { "name": "prometheusPromQAIL", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-19T15:45:32Z" }, "spec": { @@ -1870,7 +1900,7 @@ { "metadata": { "name": "publicDashboards", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-04-07T18:30:19Z" }, "spec": { @@ -1883,7 +1913,7 @@ { "metadata": { "name": "publicDashboardsEmailSharing", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-01-03T19:45:15Z" }, "spec": { @@ -1897,7 +1927,7 @@ { "metadata": { "name": "publicDashboardsScene", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-03-22T14:48:21Z" }, "spec": { @@ -1910,7 +1940,7 @@ { "metadata": { "name": "queryLibrary", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-10-07T18:31:45Z", "deletionTimestamp": "2023-03-20T16:00:14Z" }, @@ -1923,7 +1953,7 @@ { "metadata": { "name": "queryOverLive", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-01-26T17:44:20Z" }, "spec": { @@ -1936,7 +1966,7 @@ { "metadata": { "name": "queryService", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-19T09:26:21Z" }, "spec": { @@ -1949,7 +1979,7 @@ { "metadata": { "name": "queryServiceFromUI", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-19T09:26:21Z" }, "spec": { @@ -1962,7 +1992,7 @@ { "metadata": { "name": "queryServiceRewrite", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-04-19T09:26:21Z" }, "spec": { @@ -1975,7 +2005,7 @@ { "metadata": { "name": "recordedQueriesMulti", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-14T12:34:22Z" }, "spec": { @@ -1987,7 +2017,7 @@ { "metadata": { "name": "recoveryThreshold", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-10T14:51:50Z" }, "spec": { @@ -2000,7 +2030,7 @@ { "metadata": { "name": "refactorVariablesTimeRange", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-06T13:12:09Z" }, "spec": { @@ -2013,7 +2043,7 @@ { "metadata": { "name": "regressionTransformation", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-24T14:49:16Z" }, "spec": { @@ -2026,7 +2056,7 @@ { "metadata": { "name": "renderAuthJWT", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-04-03T16:53:38Z" }, "spec": { @@ -2039,7 +2069,7 @@ { "metadata": { "name": "reportingRetries", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-08-31T07:47:47Z" }, "spec": { @@ -2052,7 +2082,7 @@ { "metadata": { "name": "scenes", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-07-07T06:53:02Z" }, "spec": { @@ -2065,7 +2095,7 @@ { "metadata": { "name": "scopeFilters", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-03-05T15:41:19Z" }, "spec": { @@ -2079,7 +2109,7 @@ { "metadata": { "name": "showDashboardValidationWarnings", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-10-14T13:51:05Z" }, "spec": { @@ -2091,7 +2121,7 @@ { "metadata": { "name": "sqlDatasourceDatabaseSelection", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-06T16:28:52Z" }, "spec": { @@ -2105,7 +2135,7 @@ { "metadata": { "name": "sqlExpressions", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-02-27T21:16:00Z" }, "spec": { @@ -2117,7 +2147,7 @@ { "metadata": { "name": "sseGroupByDatasource", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-07T20:02:07Z" }, "spec": { @@ -2129,7 +2159,7 @@ { "metadata": { "name": "ssoSettingsApi", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-11-08T09:50:01Z" }, "spec": { @@ -2142,8 +2172,8 @@ { "metadata": { "name": "ssoSettingsLDAP", - "resourceVersion": "1718624569822", - "creationTimestamp": "2024-06-17T11:42:49Z" + "resourceVersion": "1718727528075", + "creationTimestamp": "2024-06-18T16:18:48Z" }, "spec": { "description": "Use the new SSO Settings API to configure LDAP", @@ -2156,7 +2186,7 @@ { "metadata": { "name": "ssoSettingsSAML", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-03-14T11:04:45Z" }, "spec": { @@ -2169,7 +2199,7 @@ { "metadata": { "name": "storage", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-03-17T17:19:23Z" }, "spec": { @@ -2181,7 +2211,7 @@ { "metadata": { "name": "tableSharedCrosshair", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-12-13T09:33:14Z" }, "spec": { @@ -2194,7 +2224,7 @@ { "metadata": { "name": "teamHttpHeaders", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-10-17T10:23:54Z" }, "spec": { @@ -2206,7 +2236,7 @@ { "metadata": { "name": "tlsMemcached", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2024-05-09T19:12:08Z" }, "spec": { @@ -2219,7 +2249,7 @@ { "metadata": { "name": "topnav", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2022-06-20T14:25:43Z" }, "spec": { @@ -2231,7 +2261,7 @@ { "metadata": { "name": "traceQLStreaming", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-26T13:33:16Z" }, "spec": { @@ -2244,7 +2274,7 @@ { "metadata": { "name": "transformationsRedesign", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-07-12T16:35:49Z" }, "spec": { @@ -2258,11 +2288,8 @@ { "metadata": { "name": "transformationsVariableSupport", - "resourceVersion": "1718190346540", - "creationTimestamp": "2023-10-04T14:28:46Z", - "annotations": { - "grafana.app/updatedTimestamp": "2024-06-12 11:05:46.540869 +0000 UTC" - } + "resourceVersion": "1718727528075", + "creationTimestamp": "2023-10-04T14:28:46Z" }, "spec": { "description": "Allows using variables in transformations", @@ -2274,7 +2301,7 @@ { "metadata": { "name": "unifiedRequestLog", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-03-31T13:38:09Z" }, "spec": { @@ -2286,7 +2313,7 @@ { "metadata": { "name": "unifiedStorage", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-12-06T20:21:21Z" }, "spec": { @@ -2299,7 +2326,7 @@ { "metadata": { "name": "vizAndWidgetSplit", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-06-27T10:22:13Z" }, "spec": { @@ -2312,7 +2339,7 @@ { "metadata": { "name": "wargamesTesting", - "resourceVersion": "1717578796182", + "resourceVersion": "1718727528075", "creationTimestamp": "2023-09-13T18:32:01Z" }, "spec": { @@ -2320,6 +2347,20 @@ "stage": "experimental", "codeowner": "@grafana/hosted-grafana-team" } + }, + { + "metadata": { + "name": "zanzana", + "resourceVersion": "1718787304727", + "creationTimestamp": "2024-06-19T08:55:04Z" + }, + "spec": { + "description": "Use openFGA as authorization engine.", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } } ] } \ No newline at end of file diff --git a/pkg/services/ldap/service/ldap.go b/pkg/services/ldap/service/ldap.go index 2125b322cb6..d3d87e9b2fc 100644 --- a/pkg/services/ldap/service/ldap.go +++ b/pkg/services/ldap/service/ldap.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "sync" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -108,6 +109,27 @@ func (s *LDAPImpl) Reload(ctx context.Context, settings models.SSOSettings) erro } func (s *LDAPImpl) Validate(ctx context.Context, settings models.SSOSettings, oldSettings models.SSOSettings, requester identity.Requester) error { + ldapCfg, err := resolveServerConfig(settings.Settings["config"]) + if err != nil { + return err + } + + enabled := resolveBool(settings.Settings["enabled"], false) + if !enabled { + return nil + } + + if len(ldapCfg.Servers) == 0 { + return fmt.Errorf("no servers configured for LDAP") + } + + for i, server := range ldapCfg.Servers { + // host is required for every LDAP server config + if server.Host == "" { + return fmt.Errorf("no host configured for server with index %d", i) + } + } + return nil } diff --git a/pkg/services/ldap/service/ldap_test.go b/pkg/services/ldap/service/ldap_test.go index 74cb4ab1a8d..6cf33fc1bff 100644 --- a/pkg/services/ldap/service/ldap_test.go +++ b/pkg/services/ldap/service/ldap_test.go @@ -297,3 +297,114 @@ func TestReload(t *testing.T) { }) } } + +func TestValidate(t *testing.T) { + testCases := []struct { + description string + settings models.SSOSettings + isValid bool + containsError string + }{ + { + description: "successfully validate basic settings", + settings: models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "127.0.0.1", + }, + }, + }, + }, + }, + isValid: true, + }, + { + description: "successfully validate settings that are not enabled", + settings: models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": false, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "port": 123, + }, + }, + }, + }, + }, + isValid: true, + }, + { + description: "validation fails for invalid settings", + settings: models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": "invalid server config", + }, + }, + }, + isValid: false, + containsError: "cannot unmarshal", + }, + { + description: "validation fails when no servers are configured", + settings: models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{}, + }, + }, + }, + isValid: false, + containsError: "no servers configured", + }, + { + description: "validation fails if one server does not have a host configured", + settings: models.SSOSettings{ + Provider: "ldap", + Settings: map[string]any{ + "enabled": true, + "config": map[string]any{ + "servers": []any{ + map[string]any{ + "host": "127.0.0.1", + }, + map[string]any{ + "port": 123, + }, + }, + }, + }, + }, + isValid: false, + containsError: "no host configured", + }, + } + + for _, tt := range testCases { + t.Run(tt.description, func(t *testing.T) { + ldapImpl := &LDAPImpl{ + features: featuremgmt.WithManager(featuremgmt.FlagSsoSettingsApi), + loadingMutex: &sync.Mutex{}, + } + + err := ldapImpl.Validate(context.Background(), tt.settings, models.SSOSettings{}, nil) + + if tt.isValid { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, tt.containsError) + } + }) + } +} diff --git a/pkg/services/libraryelements/accesscontrol.go b/pkg/services/libraryelements/accesscontrol.go index 4760f9cf2ae..68d789cad06 100644 --- a/pkg/services/libraryelements/accesscontrol.go +++ b/pkg/services/libraryelements/accesscontrol.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" @@ -47,7 +47,7 @@ func LibraryPanelUIDScopeResolver(l *LibraryElementService, folderSvc folder.Ser return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 06ab60eb520..8d146d61ef5 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -292,7 +292,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-kowalski-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightFrontend, Text: "Frontend", Icon: "frontend-observability"}, "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"}, "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"}, - "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"}, + "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incident"}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 4}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfgPlugins, SortWeight: 3}, diff --git a/pkg/services/ngalert/accesscontrol/fakes/rules.go b/pkg/services/ngalert/accesscontrol/fakes/rules.go index f666419beca..52989e44eab 100644 --- a/pkg/services/ngalert/accesscontrol/fakes/rules.go +++ b/pkg/services/ngalert/accesscontrol/fakes/rules.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" ) @@ -22,8 +21,8 @@ type FakeRuleService struct { AuthorizeDatasourceAccessForRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error HasAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) (bool, error) AuthorizeAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error - HasAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) (bool, error) - AuthorizeAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) error + HasAccessInFolderFunc func(context.Context, identity.Requester, models.Namespaced) (bool, error) + AuthorizeAccessInFolderFunc func(context.Context, identity.Requester, models.Namespaced) error AuthorizeRuleChangesFunc func(context.Context, identity.Requester, *store.GroupDelta) error Calls []Call @@ -77,7 +76,7 @@ func (s *FakeRuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user i return nil } -func (s *FakeRuleService) HasAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) (bool, error) { +func (s *FakeRuleService) HasAccessInFolder(ctx context.Context, user identity.Requester, namespaced models.Namespaced) (bool, error) { s.Calls = append(s.Calls, Call{"HasAccessInFolder", []interface{}{ctx, user, namespaced}}) if s.HasAccessInFolderFunc != nil { return s.HasAccessInFolderFunc(ctx, user, namespaced) @@ -85,7 +84,7 @@ func (s *FakeRuleService) HasAccessInFolder(ctx context.Context, user identity.R return false, nil } -func (s *FakeRuleService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error { +func (s *FakeRuleService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error { s.Calls = append(s.Calls, Call{"AuthorizeAccessInFolder", []interface{}{ctx, user, namespaced}}) if s.AuthorizeAccessInFolderFunc != nil { return s.AuthorizeAccessInFolderFunc(ctx, user, namespaced) diff --git a/pkg/services/ngalert/accesscontrol/rules.go b/pkg/services/ngalert/accesscontrol/rules.go index 998b30f56a0..2b8e2fd2914 100644 --- a/pkg/services/ngalert/accesscontrol/rules.go +++ b/pkg/services/ngalert/accesscontrol/rules.go @@ -31,10 +31,6 @@ func NewRuleService(ac accesscontrol.AccessControl) *RuleService { } } -type Namespaced interface { - GetNamespaceUID() string -} - // getReadFolderAccessEvaluator constructs accesscontrol.Evaluator that checks all permissions required to read rules in specific folder func getReadFolderAccessEvaluator(folderUID string) accesscontrol.Evaluator { return accesscontrol.EvalAll( @@ -130,7 +126,7 @@ func (r *RuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user ident // - ("folders:read") read the folder // - ("alert.rules:read") read alert rules in the folder // Returns false if the requester does not have enough permissions, and error if something went wrong during the permission evaluation. -func (r *RuleService) HasAccessInFolder(ctx context.Context, user identity.Requester, rule Namespaced) (bool, error) { +func (r *RuleService) HasAccessInFolder(ctx context.Context, user identity.Requester, rule models.Namespaced) (bool, error) { eval := accesscontrol.EvalAll(getReadFolderAccessEvaluator(rule.GetNamespaceUID())) return r.HasAccess(ctx, user, eval) } @@ -140,7 +136,7 @@ func (r *RuleService) HasAccessInFolder(ctx context.Context, user identity.Reque // - ("folders:read") read the folder // - ("alert.rules:read") read alert rules in the folder // Returns error if at least one permission is missing or if something went wrong during the permission evaluation -func (r *RuleService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, rule Namespaced) error { +func (r *RuleService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, rule models.Namespaced) error { eval := accesscontrol.EvalAll(getReadFolderAccessEvaluator(rule.GetNamespaceUID())) return r.HasAccessOrError(ctx, user, eval, func() string { return fmt.Sprintf("access rules in folder '%s'", rule.GetNamespaceUID()) diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index df99909b9b4..1b40ec4a2d9 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -45,7 +45,7 @@ type RuleAccessControlService interface { AuthorizeRuleChanges(ctx context.Context, user identity.Requester, change *store.GroupDelta) error AuthorizeDatasourceAccessForRule(ctx context.Context, user identity.Requester, rule *models.AlertRule) error AuthorizeDatasourceAccessForRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) error - AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error + AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error } // API handlers. diff --git a/pkg/services/ngalert/api/testing.go b/pkg/services/ngalert/api/testing.go index 6cb35aa101e..3a84fbd6952 100644 --- a/pkg/services/ngalert/api/testing.go +++ b/pkg/services/ngalert/api/testing.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" @@ -147,7 +146,7 @@ func (f fakeRuleAccessControlService) AuthorizeAccessToRuleGroup(ctx context.Con return nil } -func (f fakeRuleAccessControlService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error { +func (f fakeRuleAccessControlService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error { return nil } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index b505bf87ca6..b379810b579 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -269,6 +269,11 @@ type AlertRule struct { NotificationSettings []NotificationSettings `xorm:"notification_settings"` // we use slice to workaround xorm mapping that does not serialize a struct to JSON unless it's a slice } +// Namespaced describes a class of resources that are stored in a specific namespace. +type Namespaced interface { + GetNamespaceUID() string +} + // AlertRuleWithOptionals This is to avoid having to pass in additional arguments deep in the call stack. Alert rule // object is created in an early validation step without knowledge about current alert rule fields or if they need to be // overridden. This is done in a later step and, in that step, we did not have knowledge about if a field was optional diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index ec86f1ed5f0..09819ac4e08 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -135,7 +135,7 @@ type AlertNG struct { stateManager *state.Manager folderService folder.Service dashboardService dashboards.DashboardService - api *api.API + Api *api.API // Alerting notification services MultiOrgAlertmanager *notifier.MultiOrgAlertmanager @@ -377,6 +377,7 @@ func (ng *AlertNG) init() error { RulesPerRuleGroupLimit: ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, Tracer: ng.tracer, Log: log.New("ngalert.state.manager"), + ResolvedRetention: ng.Cfg.UnifiedAlerting.ResolvedAlertRetention, } logger := log.New("ngalert.state.manager.persist") statePersister := state.NewSyncStatePersisiter(logger, cfg) @@ -408,7 +409,7 @@ func (ng *AlertNG) init() error { ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, ng.Log, notifier.NewNotificationSettingsValidationService(ng.store), ac.NewRuleService(ng.accesscontrol)) - ng.api = &api.API{ + ng.Api = &api.API{ Cfg: ng.Cfg, DatasourceCache: ng.DataSourceCache, DatasourceService: ng.DataSourceService, @@ -437,7 +438,7 @@ func (ng *AlertNG) init() error { Hooks: api.NewHooks(ng.Log), Tracer: ng.tracer, } - ng.api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) + ng.Api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) if err := RegisterQuotas(ng.Cfg, ng.QuotaService, ng.store); err != nil { return err @@ -515,7 +516,7 @@ func (ng *AlertNG) IsDisabled() bool { // GetHooks returns a facility for replacing handlers for paths. The handler hook for a path // is invoked after all other middleware is invoked (authentication, instrumentation). func (ng *AlertNG) GetHooks() *api.Hooks { - return ng.api.Hooks + return ng.Api.Hooks } type Historian interface { diff --git a/pkg/services/ngalert/notifier/silence_svc.go b/pkg/services/ngalert/notifier/silence_svc.go index dd40f11015e..aab960a4bcd 100644 --- a/pkg/services/ngalert/notifier/silence_svc.go +++ b/pkg/services/ngalert/notifier/silence_svc.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -24,7 +23,7 @@ type SilenceService struct { } type RuleAccessControlService interface { - HasAccessInFolder(ctx context.Context, user identity.Requester, rule accesscontrol.Namespaced) (bool, error) + HasAccessInFolder(ctx context.Context, user identity.Requester, rule models.Namespaced) (bool, error) } // SilenceAccessControlService provides access control for silences. diff --git a/pkg/services/ngalert/notifier/silence_svc_test.go b/pkg/services/ngalert/notifier/silence_svc_test.go index 5bfcf7d05cd..6e81f98bcbc 100644 --- a/pkg/services/ngalert/notifier/silence_svc_test.go +++ b/pkg/services/ngalert/notifier/silence_svc_test.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/org" @@ -61,7 +60,7 @@ func TestWithRuleMetadata(t *testing.T) { user := ac.BackgroundUser("test", 1, org.RoleNone, nil) t.Run("Attach rule metadata to silences", func(t *testing.T) { ruleAuthz := fakes.FakeRuleService{} - ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence accesscontrol.Namespaced) (bool, error) { + ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence models.Namespaced) (bool, error) { return true, nil } @@ -95,7 +94,7 @@ func TestWithRuleMetadata(t *testing.T) { }) t.Run("Don't attach full rule metadata if no access or global", func(t *testing.T) { ruleAuthz := fakes.FakeRuleService{} - ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence accesscontrol.Namespaced) (bool, error) { + ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence models.Namespaced) (bool, error) { return silence.GetNamespaceUID() == "folder1", nil } @@ -134,7 +133,7 @@ func TestWithRuleMetadata(t *testing.T) { }) t.Run("Don't check same namespace access more than once", func(t *testing.T) { ruleAuthz := fakes.FakeRuleService{} - ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence accesscontrol.Namespaced) (bool, error) { + ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence models.Namespaced) (bool, error) { return true, nil } @@ -159,7 +158,7 @@ func TestWithRuleMetadata(t *testing.T) { require.NoError(t, svc.WithRuleMetadata(context.Background(), user, silencesWithMetadata...)) assert.Lenf(t, ruleAuthz.Calls, 1, "HasAccessInFolder should be called only once per namespace") assert.Equal(t, "HasAccessInFolder", ruleAuthz.Calls[0].MethodName) - assert.Equal(t, "folder1", ruleAuthz.Calls[0].Arguments[2].(accesscontrol.Namespaced).GetNamespaceUID()) + assert.Equal(t, "folder1", ruleAuthz.Calls[0].Arguments[2].(models.Namespaced).GetNamespaceUID()) }) } diff --git a/pkg/services/ngalert/provisioning/accesscontrol.go b/pkg/services/ngalert/provisioning/accesscontrol.go index 8e503efadc1..97ae4cc6294 100644 --- a/pkg/services/ngalert/provisioning/accesscontrol.go +++ b/pkg/services/ngalert/provisioning/accesscontrol.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" ) @@ -13,7 +12,7 @@ import ( type RuleAccessControlService interface { HasAccess(ctx context.Context, user identity.Requester, evaluator ac.Evaluator) (bool, error) AuthorizeAccessToRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) error - AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error + AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error AuthorizeRuleChanges(ctx context.Context, user identity.Requester, change *store.GroupDelta) error } diff --git a/pkg/services/ngalert/provisioning/accesscontrol_test.go b/pkg/services/ngalert/provisioning/accesscontrol_test.go index 3f771ddfa3f..8df47a15634 100644 --- a/pkg/services/ngalert/provisioning/accesscontrol_test.go +++ b/pkg/services/ngalert/provisioning/accesscontrol_test.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/accesscontrol" - accesscontrol2 "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -200,7 +199,7 @@ func TestAuthorizeAccessToRule(t *testing.T) { rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) { return false, nil } - rs.AuthorizeAccessInFolderFunc = func(ctx context.Context, requester identity.Requester, namespaced accesscontrol2.Namespaced) error { + rs.AuthorizeAccessInFolderFunc = func(ctx context.Context, requester identity.Requester, namespaced models.Namespaced) error { return nil } @@ -231,7 +230,7 @@ func TestAuthorizeAccessToRule(t *testing.T) { return false, nil } expected = errors.New("test2") - rs.AuthorizeAccessInFolderFunc = func(ctx context.Context, requester identity.Requester, rule accesscontrol2.Namespaced) error { + rs.AuthorizeAccessInFolderFunc = func(ctx context.Context, requester identity.Requester, rule models.Namespaced) error { return expected } diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index 1fca30486fd..8786acc63db 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -1020,7 +1020,7 @@ func TestGetAlertRule(t *testing.T) { service, _, _, ac := initServiceWithData(t) expected := errors.New("test") - ac.AuthorizeAccessInFolderFunc = func(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error { + ac.AuthorizeAccessInFolderFunc = func(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error { assert.Equal(t, u, user) assert.EqualValues(t, rule, namespaced) return expected @@ -1034,7 +1034,7 @@ func TestGetAlertRule(t *testing.T) { assert.Equal(t, "AuthorizeRuleRead", ac.Calls[0].Method) ac.Calls = nil - ac.AuthorizeAccessInFolderFunc = func(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error { + ac.AuthorizeAccessInFolderFunc = func(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error { return nil } diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index a61bdcc00c0..13422e79b50 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -9,7 +9,6 @@ import ( mock "github.com/stretchr/testify/mock" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -161,7 +160,7 @@ type fakeRuleAccessControlService struct { mu sync.Mutex Calls []call AuthorizeAccessToRuleGroupFunc func(ctx context.Context, user identity.Requester, rules models.RulesGroup) error - AuthorizeAccessInFolderFunc func(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error + AuthorizeAccessInFolderFunc func(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error AuthorizeRuleChangesFunc func(ctx context.Context, user identity.Requester, change *store.GroupDelta) error CanReadAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error) CanWriteAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error) diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 4feca91ed4b..2014220eda9 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -15,7 +15,6 @@ import ( amalert "github.com/prometheus/alertmanager/api/v2/client/alert" amalertgroup "github.com/prometheus/alertmanager/api/v2/client/alertgroup" amgeneral "github.com/prometheus/alertmanager/api/v2/client/general" - amreceiver "github.com/prometheus/alertmanager/api/v2/client/receiver" amsilence "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/client_golang/prometheus" @@ -487,21 +486,7 @@ func (am *Alertmanager) GetStatus(ctx context.Context) (apimodels.GettableStatus } func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) { - params := amreceiver.NewGetReceiversParamsWithContext(ctx) - res, err := am.amClient.Receiver.GetReceivers(params) - if err != nil { - return []apimodels.Receiver{}, err - } - - rcvs := make([]apimodels.Receiver, len(res.Payload)) - for i, rcv := range res.Payload { - rcvs[i] = apimodels.Receiver{ - Name: *rcv.Name, - Integrations: []apimodels.Integration{}, - } - } - - return rcvs, nil + return am.mimirClient.GetReceivers(ctx) } func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*notifier.TestReceiversResult, error) { diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index 5016ab49e38..9617a0f820d 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -717,7 +717,13 @@ func TestIntegrationRemoteAlertmanagerReceivers(t *testing.T) { // We should start with the default config. rcvs, err := am.GetReceivers(context.Background()) require.NoError(t, err) - require.Equal(t, "empty-receiver", rcvs[0].Name) + require.Equal(t, []apimodels.Receiver{ + { + Active: true, + Name: "empty-receiver", + Integrations: []apimodels.Integration{}, + }, + }, rcvs) } func genAlert(active bool, labels map[string]string) amv2.PostableAlert { diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go index f3a66451163..ebb61ce9196 100644 --- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go +++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go @@ -11,7 +11,8 @@ import ( ) const ( - grafanaAlertmanagerConfigPath = "/api/v1/grafana/config" + grafanaAlertmanagerConfigPath = "/api/v1/grafana/config" + grafanaAlertmanagerReceiversPath = "/api/v1/grafana/receivers" ) type UserGrafanaConfig struct { @@ -63,3 +64,16 @@ func (mc *Mimir) CreateGrafanaAlertmanagerConfig(ctx context.Context, cfg *apimo func (mc *Mimir) DeleteGrafanaAlertmanagerConfig(ctx context.Context) error { return mc.doOK(ctx, grafanaAlertmanagerConfigPath, http.MethodDelete, nil) } + +func (mc *Mimir) GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) { + response := []apimodels.Receiver{} + + // nolint:bodyclose + // closed within `do` + _, err := mc.do(ctx, grafanaAlertmanagerReceiversPath, http.MethodGet, nil, &response) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/pkg/services/ngalert/remote/client/mimir.go b/pkg/services/ngalert/remote/client/mimir.go index cf0a0941652..1fda8206c4d 100644 --- a/pkg/services/ngalert/remote/client/mimir.go +++ b/pkg/services/ngalert/remote/client/mimir.go @@ -29,6 +29,9 @@ type MimirClient interface { DeleteGrafanaAlertmanagerConfig(ctx context.Context) error ShouldPromoteConfig() bool + + // Mimir implements an extended version of the receivers API under a different path. + GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) } type Mimir struct { diff --git a/pkg/services/ngalert/schedule/alert_rule.go b/pkg/services/ngalert/schedule/alert_rule.go index 6c3d58ec3e8..f033448f10f 100644 --- a/pkg/services/ngalert/schedule/alert_rule.go +++ b/pkg/services/ngalert/schedule/alert_rule.go @@ -8,6 +8,10 @@ import ( "time" "github.com/benbjohnson/clock" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/datasources" @@ -19,9 +23,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" ) // Rule represents a single piece of work that is executed periodically by the ruler. @@ -418,7 +419,7 @@ func (a *alertRule) evaluate(ctx context.Context, key ngmodels.AlertRuleKey, f f processDuration.Observe(a.clock.Now().Sub(start).Seconds()) start = a.clock.Now() - alerts := state.FromStateTransitionToPostableAlerts(processedStates, a.stateManager, a.appURL) + alerts := state.FromStateTransitionToPostableAlerts(e.scheduledAt, processedStates, a.stateManager, a.appURL) span.AddEvent("results processed", trace.WithAttributes( attribute.Int64("state_transitions", int64(len(processedStates))), attribute.Int64("alerts_to_send", int64(len(alerts.PostableAlerts))), diff --git a/pkg/services/ngalert/schedule/alert_rule_test.go b/pkg/services/ngalert/schedule/alert_rule_test.go index 12eb90ec661..875c098302e 100644 --- a/pkg/services/ngalert/schedule/alert_rule_test.go +++ b/pkg/services/ngalert/schedule/alert_rule_test.go @@ -2,7 +2,7 @@ package schedule import ( "bytes" - context "context" + "context" "fmt" "math" "math/rand" @@ -11,19 +11,21 @@ import ( "testing" "time" - alertingModels "github.com/grafana/alerting/models" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" prometheusModel "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" - mock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/grafana/pkg/infra/log" - definitions "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" - models "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" ) @@ -762,8 +764,94 @@ func TestRuleRoutine(t *testing.T) { require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) }) + + t.Run("when there are resolved alerts they should keep sending until retention period is over", func(t *testing.T) { + rule := gen.With(withQueryForState(t, eval.Normal), models.RuleMuts.WithInterval(time.Second)).GenerateRef() + + evalAppliedChan := make(chan time.Time) + + sender := NewSyncAlertsSenderMock() + sender.EXPECT().Send(mock.Anything, rule.GetKey(), mock.Anything).Return() + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan, sender) + sch.stateManager.ResolvedRetention = 4 * time.Second + sch.stateManager.ResendDelay = 2 * time.Second + sch.stateManager.Put([]*state.State{ + stateForRule(rule, sch.clock.Now(), eval.Alerting), // Add existing Alerting state so evals will resolve. + }) + + ruleStore.PutRule(context.Background(), rule) + factory := ruleFactoryFromScheduler(sch) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ruleInfo := factory.new(ctx, rule) + + go func() { + _ = ruleInfo.Run(rule.GetKey()) + }() + + // Evaluate 10 times: + // 1. Send resolve #1. + // 2. 2s resend delay. + // 3. Send resolve #2. + // 4. 2s resend delay. + // 5. Send resolve #3. + // 6. No more sends, 4s retention period is over. + expectedResolves := map[time.Time]struct{}{ + sch.clock.Now().Add(1 * time.Second): {}, + sch.clock.Now().Add(3 * time.Second): {}, + sch.clock.Now().Add(5 * time.Second): {}, + } + calls := 0 + for i := 1; i < 10; i++ { + ts := sch.clock.Now().Add(time.Duration(int64(i)*rule.IntervalSeconds) * time.Second) + ruleInfo.Eval(&Evaluation{ + scheduledAt: ts, + rule: rule, + }) + waitForTimeChannel(t, evalAppliedChan) + + if _, ok := expectedResolves[ts]; ok { + calls++ + prevCallAlerts, ok := sender.Calls()[calls-1].Arguments[2].(definitions.PostableAlerts) + assert.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls()[calls-1].Arguments[2])) + assert.Len(t, prevCallAlerts.PostableAlerts, 1) + } + sender.AssertNumberOfCalls(t, "Send", calls) + } + }) } func ruleFactoryFromScheduler(sch *schedule) ruleFactory { return newRuleFactory(sch.appURL, sch.disableGrafanaFolder, sch.maxAttempts, sch.alertsSender, sch.stateManager, sch.evaluatorFactory, &sch.schedulableAlertRules, sch.clock, sch.featureToggles, sch.metrics, sch.log, sch.tracer, sch.recordingWriter, sch.evalAppliedFunc, sch.stopAppliedFunc) } + +func stateForRule(rule *models.AlertRule, ts time.Time, evalState eval.State) *state.State { + s := &state.State{ + OrgID: rule.OrgID, + AlertRuleUID: rule.UID, + CacheID: 0, + State: evalState, + Annotations: make(map[string]string), + Labels: make(map[string]string), + StartsAt: ts, + EndsAt: ts, + ResolvedAt: &ts, + LastSentAt: &ts, + LastEvaluationTime: ts, + } + for k, v := range rule.Labels { + s.Labels[k] = v + } + for k, v := range state.GetRuleExtraLabels(&logtest.Fake{}, rule, "", true) { + if _, ok := s.Labels[k]; !ok { + s.Labels[k] = v + } + } + il := models.InstanceLabels(s.Labels) + s.Labels = data.Labels(il) + id := il.Fingerprint() + s.CacheID = id + + return s +} diff --git a/pkg/services/ngalert/schedule/testing.go b/pkg/services/ngalert/schedule/testing.go index 2f00c213ac3..d182329cf16 100644 --- a/pkg/services/ngalert/schedule/testing.go +++ b/pkg/services/ngalert/schedule/testing.go @@ -7,15 +7,17 @@ import ( "testing" "time" - definitions "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/stretchr/testify/mock" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" - mock "github.com/stretchr/testify/mock" ) // waitForTimeChannel blocks the execution until either the channel ch has some data or a timeout of 10 second expires. // Timeout will cause the test to fail. // Returns the data from the channel. func waitForTimeChannel(t *testing.T, ch chan time.Time) time.Time { + t.Helper() select { case result := <-ch: return result diff --git a/pkg/services/ngalert/state/compat.go b/pkg/services/ngalert/state/compat.go index 673e9e56810..1a070cfd5ac 100644 --- a/pkg/services/ngalert/state/compat.go +++ b/pkg/services/ngalert/state/compat.go @@ -10,11 +10,12 @@ import ( "github.com/benbjohnson/clock" "github.com/go-openapi/strfmt" - alertingModels "github.com/grafana/alerting/models" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/common/model" + alertingModels "github.com/grafana/alerting/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -73,7 +74,7 @@ func StateToPostableAlert(transition StateTransition, appURL *url.URL) *models.P } state := alertState.State - if alertState.Resolved { + if alertState.ResolvedAt != nil { // If this is a resolved alert, we need to send an alert with the correct labels such that they will expire the previous alert. // In most cases the labels on the state will be correct, however when the previous alert was a NoData or Error alert, we need to // ensure to modify it appropriately. @@ -139,13 +140,12 @@ func errorAlert(labels, annotations data.Labels, alertState *State, urlStr strin } } -func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateManager *Manager, appURL *url.URL) apimodels.PostableAlerts { +func FromStateTransitionToPostableAlerts(evaluatedAt time.Time, firingStates []StateTransition, stateManager *Manager, appURL *url.URL) apimodels.PostableAlerts { alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))} - ts := time.Now() sentAlerts := make([]*State, 0, len(firingStates)) for _, alertState := range firingStates { - if !alertState.NeedsSending(stateManager.ResendDelay) { + if !alertState.NeedsSending(stateManager.ResendDelay, stateManager.ResolvedRetention) { continue } alert := StateToPostableAlert(alertState, appURL) @@ -153,7 +153,7 @@ func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateMa if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager continue } - alertState.LastSentAt = ts + alertState.LastSentAt = &evaluatedAt sentAlerts = append(sentAlerts, alertState.State) } stateManager.Put(sentAlerts) diff --git a/pkg/services/ngalert/state/compat_test.go b/pkg/services/ngalert/state/compat_test.go index e0f889a96ea..bd0189badb8 100644 --- a/pkg/services/ngalert/state/compat_test.go +++ b/pkg/services/ngalert/state/compat_test.go @@ -9,12 +9,13 @@ import ( "github.com/benbjohnson/clock" "github.com/go-openapi/strfmt" - alertingModels "github.com/grafana/alerting/models" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/util" @@ -267,7 +268,9 @@ func TestStateToPostableAlertFromNodataError(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { alertState := randomTransition(tc.from, tc.to) - alertState.Resolved = tc.resolved + if tc.resolved { + alertState.ResolvedAt = &alertState.LastEvaluationTime + } alertState.Labels = data.Labels(standardLabels) result := StateToPostableAlert(alertState, appURL) require.Equal(t, tc.expectedLabels, result.Labels) @@ -339,7 +342,7 @@ func randomTransition(from, to eval.State) StateTransition { EndsAt: randomTimeInFuture(), LastEvaluationTime: randomTimeInPast(), EvaluationDuration: randomDuration(), - LastSentAt: randomTimeInPast(), + LastSentAt: util.Pointer(randomTimeInPast()), Annotations: make(map[string]string), Labels: make(map[string]string), Values: make(map[string]float64), diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 10050b60037..b4a64d4cb32 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -39,9 +39,10 @@ type Manager struct { metrics *metrics.State tracer tracing.Tracer - clock clock.Clock - cache *cache - ResendDelay time.Duration + clock clock.Clock + cache *cache + ResendDelay time.Duration + ResolvedRetention time.Duration instanceStore InstanceStore images ImageCapturer @@ -73,6 +74,9 @@ type ManagerCfg struct { DisableExecution bool + // Duration for which a resolved alert state transition will continue to be sent to the Alertmanager. + ResolvedRetention time.Duration + Tracer tracing.Tracer Log log.Logger } @@ -88,6 +92,7 @@ func NewManager(cfg ManagerCfg, statePersister StatePersister) *Manager { m := &Manager{ cache: c, ResendDelay: ResendDelay, // TODO: make this configurable + ResolvedRetention: cfg.ResolvedRetention, log: cfg.Log, metrics: cfg.Metrics, instanceStore: cfg.InstanceStore, @@ -245,7 +250,11 @@ func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.Al s.SetNormal(reason, startsAt, now) // Set Resolved property so the scheduler knows to send a postable alert // to Alertmanager. - s.Resolved = oldState == eval.Alerting || oldState == eval.Error || oldState == eval.NoData + if oldState == eval.Alerting || oldState == eval.Error || oldState == eval.NoData { + s.ResolvedAt = &now + } else { + s.ResolvedAt = nil + } s.LastEvaluationTime = now s.Values = map[string]float64{} transitions = append(transitions, StateTransition{ @@ -418,9 +427,15 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu // Set Resolved property so the scheduler knows to send a postable alert // to Alertmanager. - currentState.Resolved = oldState == eval.Alerting && currentState.State == eval.Normal + newlyResolved := false + if oldState == eval.Alerting && currentState.State == eval.Normal { + currentState.ResolvedAt = &result.EvaluatedAt + newlyResolved = true + } else if currentState.State != eval.Normal && currentState.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending. + currentState.ResolvedAt = nil + } - if shouldTakeImage(currentState.State, oldState, currentState.Image, currentState.Resolved) { + if shouldTakeImage(currentState.State, oldState, currentState.Image, newlyResolved) { image, err := takeImage(ctx, st.images, alertRule) if err != nil { logger.Warn("Failed to take an image", @@ -505,7 +520,7 @@ func (st *Manager) deleteStaleStatesFromCache(ctx context.Context, logger log.Lo s.LastEvaluationTime = evaluatedAt if oldState == eval.Alerting { - s.Resolved = true + s.ResolvedAt = &evaluatedAt image, err := takeImage(ctx, st.images, alertRule) if err != nil { logger.Warn("Failed to take an image", diff --git a/pkg/services/ngalert/state/manager_private_test.go b/pkg/services/ngalert/state/manager_private_test.go index 8740186deac..6f2993d6d88 100644 --- a/pkg/services/ngalert/state/manager_private_test.go +++ b/pkg/services/ngalert/state/manager_private_test.go @@ -563,7 +563,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t2, EndsAt: t2, LastEvaluationTime: t2, - Resolved: true, + ResolvedAt: &t2, }, }, }, @@ -622,7 +622,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -1051,7 +1051,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -1091,7 +1091,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -1133,7 +1133,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -1175,7 +1175,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -1275,7 +1275,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t2, EndsAt: t2, LastEvaluationTime: t2, - Resolved: true, + ResolvedAt: &t2, }, }, }, @@ -1304,6 +1304,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t2, EndsAt: t2, LastEvaluationTime: t3, + ResolvedAt: &t2, }, }, }, @@ -1935,7 +1936,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, }, @@ -2163,7 +2164,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -2191,7 +2192,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -2221,7 +2222,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -2251,7 +2252,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, { @@ -2314,7 +2315,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t2, EndsAt: t2, LastEvaluationTime: t2, - Resolved: true, + ResolvedAt: &t2, }, }, }, @@ -2330,6 +2331,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t2, EndsAt: t2, LastEvaluationTime: t3, + ResolvedAt: &t2, }, }, }, @@ -3060,7 +3062,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t1, EndsAt: t3, LastEvaluationTime: t3, - Resolved: true, + ResolvedAt: &t3, }, }, }, @@ -3480,7 +3482,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { StartsAt: t2, EndsAt: t2, LastEvaluationTime: t2, - Resolved: true, + ResolvedAt: &t2, }, }, }, diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index edb836b4dac..b31eab7402d 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "math/rand" + "net/url" "sort" "strings" "testing" @@ -294,7 +295,7 @@ func TestProcessEvalResults(t *testing.T) { evaluationDuration := 10 * time.Millisecond evaluationInterval := 10 * time.Second - t1 := time.Time{}.Add(evaluationInterval) + t1 := time.Unix(0, 0).Add(evaluationInterval) tn := func(n int) time.Time { return t1.Add(time.Duration(n-1) * evaluationInterval) @@ -424,6 +425,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t1, EndsAt: t1.Add(state.ResendDelay * 4), LastEvaluationTime: t1, + LastSentAt: &t1, }, }, }, @@ -471,6 +473,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t2, EndsAt: t2.Add(state.ResendDelay * 4), LastEvaluationTime: t2, + LastSentAt: &t2, }, }, }, @@ -501,6 +504,94 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(4), EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(4), + LastSentAt: util.Pointer(tn(4)), + }, + }, + }, + { + desc: "alerting -> normal resolves and sets ResolvedAt", + alertRule: baseRule, + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + }, + expectedAnnotations: 2, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, + LatestResult: newEvaluation(t2, eval.Normal), + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t2, + ResolvedAt: &t2, + LastSentAt: &t2, + }, + }, + }, + { + desc: "alerting -> normal -> normal resolves and maintains ResolvedAt", + alertRule: baseRule, + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + t3: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + }, + expectedAnnotations: 2, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Normal, + LatestResult: newEvaluation(t3, eval.Normal), + StartsAt: t2, + EndsAt: t2, + LastEvaluationTime: t3, + ResolvedAt: &t2, + LastSentAt: &t2, + }, + }, + }, + { + desc: "pending -> alerting -> normal -> pending resolves and resets ResolvedAt at t4", + alertRule: baseRuleWith(m.WithForNTimes(1)), + evalResults: map[time.Time]eval.Results{ + t1: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), + }, + t2: { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), // Alerting. + }, + t3: { + newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), + }, + tn(4): { + newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), // Pending. + }, + }, + expectedAnnotations: 4, + expectedStates: []*state.State{ + { + Labels: labels["system + rule + labels1"], + ResultFingerprint: labels1.Fingerprint(), + State: eval.Pending, + LatestResult: newEvaluation(tn(4), eval.Alerting), + StartsAt: tn(4), + EndsAt: tn(4).Add(state.ResendDelay * 4), + LastEvaluationTime: tn(4), + ResolvedAt: &t3, + LastSentAt: &t3, }, }, }, @@ -534,6 +625,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(4), EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(5), + LastSentAt: util.Pointer(tn(3)), // 30s resend delay causing the last sent at to be t3. }, }, }, @@ -564,6 +656,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(4), EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(4), + LastSentAt: &t3, // Resend delay is 30s, so last sent at is t3. }, }, }, @@ -672,6 +765,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(5), EndsAt: tn(5).Add(state.ResendDelay * 4), LastEvaluationTime: tn(5), + LastSentAt: util.Pointer(tn(5)), }, }, }, @@ -696,6 +790,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t2, EndsAt: t2.Add(state.ResendDelay * 4), LastEvaluationTime: t2, + LastSentAt: &t2, }, }, }, @@ -729,6 +824,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t2, EndsAt: t2.Add(state.ResendDelay * 4), LastEvaluationTime: t2, + LastSentAt: &t2, }, }, }, @@ -772,6 +868,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t2, EndsAt: t2.Add(state.ResendDelay * 4), LastEvaluationTime: t2, + LastSentAt: &t2, }, }, }, @@ -808,6 +905,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t2, EndsAt: t2.Add(state.ResendDelay * 4), LastEvaluationTime: t2, + LastSentAt: &t2, }, }, }, @@ -839,6 +937,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t3, EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(4), + LastSentAt: &t3, // Resend delay is 30s, so last sent at is t3. }, }, }, @@ -870,6 +969,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(4), EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(4), + LastSentAt: util.Pointer(tn(4)), }, }, }, @@ -956,6 +1056,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(5), EndsAt: tn(5).Add(state.ResendDelay * 4), LastEvaluationTime: tn(5), + LastSentAt: util.Pointer(tn(5)), }, }, }, @@ -988,6 +1089,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t2, EndsAt: t2.Add(state.ResendDelay * 4), LastEvaluationTime: t2, + LastSentAt: &t2, EvaluationDuration: evaluationDuration, Annotations: map[string]string{"annotation": "test", "Error": "[sse.dataQueryError] failed to execute query [A]: this is an error"}, }, @@ -1021,6 +1123,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t3, EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(4), + LastSentAt: &t3, // Resend delay is 30s, so last sent at is t3. }, }, }, @@ -1052,6 +1155,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(4), EndsAt: tn(4).Add(state.ResendDelay * 4), LastEvaluationTime: tn(4), + LastSentAt: util.Pointer(tn(4)), }, }, }, @@ -1139,6 +1243,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(4), EndsAt: tn(6).Add(state.ResendDelay * 4), LastEvaluationTime: tn(6), + LastSentAt: util.Pointer(tn(6)), // After 30s resend delay, last sent at is t6. }, }, }, @@ -1169,6 +1274,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(8), EndsAt: tn(8).Add(state.ResendDelay * 4), LastEvaluationTime: tn(8), + LastSentAt: util.Pointer(tn(5)), }, }, }, @@ -1199,6 +1305,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: tn(6), EndsAt: tn(6).Add(state.ResendDelay * 4), LastEvaluationTime: tn(6), + LastSentAt: util.Pointer(tn(5)), }, }, }, @@ -1265,6 +1372,7 @@ func TestProcessEvalResults(t *testing.T) { StartsAt: t3, EndsAt: t3.Add(state.ResendDelay * 4), LastEvaluationTime: t3, + LastSentAt: &t1, // Resend delay is 30s, so last sent at is t1. }, }, }, @@ -1306,8 +1414,9 @@ func TestProcessEvalResults(t *testing.T) { res[i].EvaluatedAt = evalTime } clk.Set(evalTime) - _ = st.ProcessEvalResults(context.Background(), evalTime, tc.alertRule, res, systemLabels) + processedStates := st.ProcessEvalResults(context.Background(), evalTime, tc.alertRule, res, systemLabels) results += len(res) + _ = state.FromStateTransitionToPostableAlerts(evalTime, processedStates, st, &url.URL{}) // Set LastSentAt. } states := st.GetStatesForRuleUID(tc.alertRule.OrgID, tc.alertRule.UID) @@ -1670,7 +1779,7 @@ func TestStaleResults(t *testing.T) { assert.Equal(t, models.StateReasonMissingSeries, s.StateReason) assert.Equal(t, clk.Now(), s.EndsAt) if s.CacheID == state2 { - assert.Truef(t, s.Resolved, "Returned stale state should have Resolved set to true") + assert.Equalf(t, clk.Now(), *s.ResolvedAt, "Returned stale state should have ResolvedAt set") } key, err := s.GetAlertInstanceKey() require.NoError(t, err) @@ -1819,11 +1928,11 @@ func TestDeleteStateByRuleUID(t *testing.T) { assert.Equal(t, expectedReason, s.StateReason) if oldState.State == eval.Normal { assert.Equal(t, oldState.StartsAt, s.StartsAt) - assert.False(t, s.Resolved) + assert.Zero(t, s.ResolvedAt) } else { assert.Equal(t, clk.Now(), s.StartsAt) if oldState.State == eval.Alerting { - assert.True(t, s.Resolved) + assert.Equal(t, clk.Now(), *s.ResolvedAt) } } assert.Equal(t, clk.Now(), s.EndsAt) @@ -1959,11 +2068,11 @@ func TestResetStateByRuleUID(t *testing.T) { assert.Equal(t, models.StateReasonPaused, s.StateReason) if oldState.State == eval.Normal { assert.Equal(t, oldState.StartsAt, s.StartsAt) - assert.False(t, s.Resolved) + assert.Zero(t, s.ResolvedAt) } else { assert.Equal(t, clk.Now(), s.StartsAt) if oldState.State == eval.Alerting { - assert.True(t, s.Resolved) + assert.Equal(t, clk.Now(), *s.ResolvedAt) } } assert.Equal(t, clk.Now(), s.EndsAt) diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 206747c652e..260dd92aeef 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -45,10 +45,6 @@ type State struct { // can still contain the results of previous evaluations. Error error - // Resolved is set to true if this state is the transitional state between Firing and Normal. - // All subsequent states will be false until the next transition from Firing to Normal. - Resolved bool - // Image contains an optional image for the state. It tends to be included in notifications // as a visualization to show why the alert fired. Image *models.Image @@ -65,9 +61,15 @@ type State struct { // conditions. Values map[string]float64 - StartsAt time.Time - EndsAt time.Time - LastSentAt time.Time + StartsAt time.Time + // EndsAt is different from the Prometheus EndsAt as EndsAt is updated for both Normal states + // and states that have been resolved. It cannot be used to determine when a state was resolved. + EndsAt time.Time + // ResolvedAt is set when the state is first resolved. That is to say, when the state first transitions + // from Alerting, NoData, or Error to Normal. It is reset to zero when the state transitions from Normal + // to any other state. + ResolvedAt *time.Time + LastSentAt *time.Time LastEvaluationString string LastEvaluationTime time.Time EvaluationDuration time.Duration @@ -134,14 +136,6 @@ func (a *State) SetNormal(reason string, startsAt, endsAt time.Time) { a.Error = nil } -// Resolve sets the State to Normal. It updates the StateReason, the end time, and sets Resolved to true. -func (a *State) Resolve(reason string, endsAt time.Time) { - a.State = eval.Normal - a.StateReason = reason - a.Resolved = true - a.EndsAt = endsAt -} - // Maintain updates the end time using the most recent evaluation. func (a *State) Maintain(interval int64, evaluatedAt time.Time) { a.EndsAt = nextEndsTime(interval, evaluatedAt) @@ -400,19 +394,31 @@ func resultKeepLast(state *State, rule *models.AlertRule, result eval.Result, lo } } -func (a *State) NeedsSending(resendDelay time.Duration) bool { - switch a.State { - case eval.Pending: - // We do not send notifications for pending states +// NeedsSending returns true if the given state needs to be sent to the Alertmanager. +// Reasons for sending include: +// - The state has been resolved since the last notification. +// - The state is firing and the last notification was sent at least resendDelay ago. +// - The state was resolved within the resolvedRetention period, and the last notification was sent at least resendDelay ago. +func (a *State) NeedsSending(resendDelay time.Duration, resolvedRetention time.Duration) bool { + if a.State == eval.Pending { + // We do not send notifications for pending states. return false - case eval.Normal: - // We should send a notification if the state is Normal because it was resolved - return a.Resolved - default: - // We should send, and re-send notifications, each time LastSentAt is <= LastEvaluationTime + resendDelay - nextSent := a.LastSentAt.Add(resendDelay) - return nextSent.Before(a.LastEvaluationTime) || nextSent.Equal(a.LastEvaluationTime) } + + // We should send a notification if the state has been resolved since the last notification. + if a.ResolvedAt != nil && (a.LastSentAt == nil || a.ResolvedAt.After(*a.LastSentAt)) { + return true + } + + // For normal states, we should only be sending if this is a resolved notification or a re-send of the resolved + // notification within the resolvedRetention period. + if a.State == eval.Normal && (a.ResolvedAt == nil || a.LastEvaluationTime.Sub(*a.ResolvedAt) > resolvedRetention) { + return false + } + + // We should send, and re-send notifications, each time LastSentAt is <= LastEvaluationTime + resendDelay. + // This can include normal->normal transitions that were resolved in recent past evaluations. + return a.LastSentAt == nil || !a.LastSentAt.Add(resendDelay).After(a.LastEvaluationTime) } func (a *State) Equals(b *State) bool { diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index bc00efe875a..f079d90e391 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -11,11 +11,12 @@ import ( "github.com/benbjohnson/clock" "github.com/golang/mock/gomock" "github.com/google/uuid" - "github.com/grafana/alerting/models" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/alerting/models" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -350,10 +351,11 @@ func TestEnd(t *testing.T) { func TestNeedsSending(t *testing.T) { evaluationTime, _ := time.Parse("2006-01-02", "2021-03-25") testCases := []struct { - name string - resendDelay time.Duration - expected bool - testState *State + name string + resendDelay time.Duration + resolvedRetention time.Duration + expected bool + testState *State }{ { name: "state: alerting and LastSentAt before LastEvaluationTime + ResendDelay", @@ -362,7 +364,7 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.Alerting, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-2 * time.Minute), + LastSentAt: util.Pointer(evaluationTime.Add(-2 * time.Minute)), }, }, { @@ -372,7 +374,7 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.Alerting, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime, + LastSentAt: util.Pointer(evaluationTime), }, }, { @@ -382,7 +384,7 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.Alerting, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-1 * time.Minute), + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), }, }, { @@ -400,18 +402,54 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.Alerting, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime, + LastSentAt: util.Pointer(evaluationTime), }, }, { - name: "state: normal + resolved should send without waiting", + name: "state: normal + resolved should send without waiting if ResolvedAt > LastSentAt", resendDelay: 1 * time.Minute, expected: true, testState: &State{ State: eval.Normal, - Resolved: true, + ResolvedAt: util.Pointer(evaluationTime), LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime, + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), + }, + }, + { + name: "state: normal + recently resolved should send with wait", + resendDelay: 1 * time.Minute, + resolvedRetention: 15 * time.Minute, + expected: true, + testState: &State{ + State: eval.Normal, + ResolvedAt: util.Pointer(evaluationTime.Add(-2 * time.Minute)), + LastEvaluationTime: evaluationTime, + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), + }, + }, + { + name: "state: normal + recently resolved should not send without wait", + resendDelay: 2 * time.Minute, + resolvedRetention: 15 * time.Minute, + expected: false, + testState: &State{ + State: eval.Normal, + ResolvedAt: util.Pointer(evaluationTime.Add(-2 * time.Minute)), + LastEvaluationTime: evaluationTime, + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), + }, + }, + { + name: "state: normal + not recently resolved should not send even with wait", + resendDelay: 1 * time.Minute, + resolvedRetention: 15 * time.Minute, + expected: false, + testState: &State{ + State: eval.Normal, + ResolvedAt: util.Pointer(evaluationTime.Add(-16 * time.Minute)), + LastEvaluationTime: evaluationTime, + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), }, }, { @@ -420,9 +458,9 @@ func TestNeedsSending(t *testing.T) { expected: false, testState: &State{ State: eval.Normal, - Resolved: false, + ResolvedAt: util.Pointer(time.Time{}), LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-1 * time.Minute), + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), }, }, { @@ -432,7 +470,7 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.NoData, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-1 * time.Minute), + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), }, }, { @@ -442,7 +480,7 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.NoData, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-time.Duration(rand.Int63n(59)+1) * time.Second), + LastSentAt: util.Pointer(evaluationTime.Add(-time.Duration(rand.Int63n(59)+1) * time.Second)), }, }, { @@ -452,7 +490,7 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.Error, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-1 * time.Minute), + LastSentAt: util.Pointer(evaluationTime.Add(-1 * time.Minute)), }, }, { @@ -462,14 +500,14 @@ func TestNeedsSending(t *testing.T) { testState: &State{ State: eval.Error, LastEvaluationTime: evaluationTime, - LastSentAt: evaluationTime.Add(-time.Duration(rand.Int63n(59)+1) * time.Second), + LastSentAt: util.Pointer(evaluationTime.Add(-time.Duration(rand.Int63n(59)+1) * time.Second)), }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, tc.testState.NeedsSending(tc.resendDelay)) + assert.Equal(t, tc.expected, tc.testState.NeedsSending(tc.resendDelay, tc.resolvedRetention)) }) } } @@ -531,13 +569,6 @@ func TestGetLastEvaluationValuesForCondition(t *testing.T) { }) } -func TestResolve(t *testing.T) { - s := State{State: eval.Alerting, EndsAt: time.Now().Add(time.Minute)} - expected := State{State: eval.Normal, StateReason: "This is a reason", EndsAt: time.Now(), Resolved: true} - s.Resolve("This is a reason", expected.EndsAt) - assert.Equal(t, expected, s) -} - func TestShouldTakeImage(t *testing.T) { tests := []struct { name string diff --git a/pkg/services/notifications/smtp.go b/pkg/services/notifications/smtp.go index 2c46982b352..e0588ce3cb5 100644 --- a/pkg/services/notifications/smtp.go +++ b/pkg/services/notifications/smtp.go @@ -70,7 +70,7 @@ func (sc *SmtpClient) Send(ctx context.Context, messages ...*Message) (int, erro emailsSentFailed.Inc() } - err = fmt.Errorf("failed to send notification to email addresses: %s: %w", strings.Join(msg.To, ";"), innerError) + err = fmt.Errorf("failed to send email: %w", innerError) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index 6ff7df5efe8..7fbaf59eaad 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "crypto/tls" + "errors" "fmt" "io" "net" "net/http" + "net/url" "time" "github.com/grafana/grafana/pkg/util" @@ -62,6 +64,11 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook * if err != nil { return err } + url, err := url.Parse(webhook.Url) + if err != nil { + // Should not be possible - NewRequestWithContext should also err if the URL is bad. + return err + } if webhook.ContentType == "" { webhook.ContentType = "application/json" @@ -80,7 +87,7 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook * resp, err := netClient.Do(request) if err != nil { - return err + return redactURL(err) } defer func() { if err := resp.Body.Close(); err != nil { @@ -96,16 +103,25 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook * if webhook.Validation != nil { err := webhook.Validation(body, resp.StatusCode) if err != nil { - ns.log.Debug("Webhook failed validation", "url", webhook.Url, "statuscode", resp.Status, "body", string(body)) + ns.log.Debug("Webhook failed validation", "url", url.Redacted(), "statuscode", resp.Status, "body", string(body), "error", err) return fmt.Errorf("webhook failed validation: %w", err) } } if resp.StatusCode/100 == 2 { - ns.log.Debug("Webhook succeeded", "url", webhook.Url, "statuscode", resp.Status) + ns.log.Debug("Webhook succeeded", "url", url.Redacted(), "statuscode", resp.Status) return nil } - ns.log.Debug("Webhook failed", "url", webhook.Url, "statuscode", resp.Status, "body", string(body)) + ns.log.Debug("Webhook failed", "url", url.Redacted(), "statuscode", resp.Status, "body", string(body)) return fmt.Errorf("webhook response status %v", resp.Status) } + +func redactURL(err error) error { + var e *url.Error + if !errors.As(err, &e) { + return err + } + e.URL = "" + return e +} diff --git a/pkg/services/pluginsintegration/plugincontext/plugincontext.go b/pkg/services/pluginsintegration/plugincontext/plugincontext.go index 4d170d8867f..578e863c5d5 100644 --- a/pkg/services/pluginsintegration/plugincontext/plugincontext.go +++ b/pkg/services/pluginsintegration/plugincontext/plugincontext.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" @@ -95,7 +94,7 @@ func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user } func (p *Provider) GetDataSourceInstanceSettings(ctx context.Context, uid string) (*backend.DataSourceInstanceSettings, error) { - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return nil, err } @@ -115,7 +114,7 @@ func (p *Provider) PluginContextForDataSource(ctx context.Context, datasourceSet return backend.PluginContext{}, plugins.ErrPluginNotRegistered } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { return backend.PluginContext{}, err } diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go index fab3664cd1c..7cafa1f712b 100644 --- a/pkg/services/sqlstore/database_wrapper.go +++ b/pkg/services/sqlstore/database_wrapper.go @@ -58,6 +58,27 @@ func WrapDatabaseDriverWithHooks(dbType string, tracer tracing.Tracer) string { return driverWithHooks } +// WrapDatabaseDriverWithHooks creates a fake database driver that +// executes pre and post functions which we use to gather metrics about +// database queries. It also registers the metrics. +func WrapDatabaseReplDriverWithHooks(dbType string, tracer tracing.Tracer) string { + drivers := map[string]driver.Driver{ + migrator.SQLite: &sqlite3.SQLiteDriver{}, + migrator.MySQL: &mysql.MySQLDriver{}, + migrator.Postgres: &pq.Driver{}, + } + + d, exist := drivers[dbType] + if !exist { + return dbType + } + + driverWithHooks := dbType + "ReplicaWithHooks" + sql.Register(driverWithHooks, sqlhooks.Wrap(d, &databaseQueryWrapper{log: log.New("sqlstore.metrics"), tracer: tracer})) + core.RegisterDriver(driverWithHooks, &databaseQueryWrapperDriver{dbType: dbType}) + return driverWithHooks +} + // databaseQueryWrapper satisfies the sqlhook.databaseQueryWrapper interface // which allow us to wrap all SQL queries with a `Before` & `After` hook. type databaseQueryWrapper struct { diff --git a/pkg/services/sqlstore/migrations/cloud_migrations.go b/pkg/services/sqlstore/migrations/cloud_migrations.go index 3d365995548..db134e268cc 100644 --- a/pkg/services/sqlstore/migrations/cloud_migrations.go +++ b/pkg/services/sqlstore/migrations/cloud_migrations.go @@ -5,7 +5,7 @@ import ( ) func addCloudMigrationsMigrations(mg *Migrator) { - // v1 - synchronous workflow + // --- v1 - synchronous workflow migrationTable := Table{ Name: "cloud_migration", Columns: []*Column{ @@ -65,7 +65,7 @@ func addCloudMigrationsMigrations(mg *Migrator) { Cols: []string{"uid"}, Type: UniqueIndex, })) - // v2 - asynchronous workflow refactor + // --- v2 - asynchronous workflow refactor sessionTable := Table{ Name: "cloud_migration_session", Columns: []*Column{ @@ -120,4 +120,23 @@ func addCloudMigrationsMigrations(mg *Migrator) { "updated": "updated", "finished": "finished", }) + + // --- add new columns to snapshots table + uploadUrlColumn := Column{Name: "upload_url", Type: DB_Text, Nullable: true} + mg.AddMigration("add snapshot upload_url column", NewAddColumnMigration(migrationSnapshotTable, &uploadUrlColumn)) + + statusColumn := Column{Name: "status", Type: DB_Text, Nullable: false} + mg.AddMigration("add snapshot status column", NewAddColumnMigration(migrationSnapshotTable, &statusColumn)) + + localDirColumn := Column{Name: "local_directory", Type: DB_Text, Nullable: true} + mg.AddMigration("add snapshot local_directory column", NewAddColumnMigration(migrationSnapshotTable, &localDirColumn)) + + gmsSnapshotUIDColumn := Column{Name: "gms_snapshot_uid", Type: DB_Text, Nullable: true} + mg.AddMigration("add snapshot gms_snapshot_uid column", NewAddColumnMigration(migrationSnapshotTable, &gmsSnapshotUIDColumn)) + + encryptionKeyColumn := Column{Name: "encryption_key", Type: DB_Text, Nullable: true} + mg.AddMigration("add snapshot encryption_key column", NewAddColumnMigration(migrationSnapshotTable, &encryptionKeyColumn)) + + errorStringColumn := Column{Name: "error_string", Type: DB_Text, Nullable: true} + mg.AddMigration("add snapshot error_string column", NewAddColumnMigration(migrationSnapshotTable, &errorStringColumn)) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 5aae742b6ca..a62d0bfe284 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -6,9 +6,10 @@ import ( "strconv" "strings" - "github.com/grafana/grafana/pkg/services/sqlstore/session" "golang.org/x/exp/slices" "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/services/sqlstore/session" ) var ( @@ -106,12 +107,13 @@ type LockCfg struct { type dialectFunc func() Dialect var supportedDialects = map[string]dialectFunc{ - MySQL: NewMysqlDialect, - SQLite: NewSQLite3Dialect, - Postgres: NewPostgresDialect, - MySQL + "WithHooks": NewMysqlDialect, - SQLite + "WithHooks": NewSQLite3Dialect, - Postgres + "WithHooks": NewPostgresDialect, + MySQL: NewMysqlDialect, + SQLite: NewSQLite3Dialect, + Postgres: NewPostgresDialect, + MySQL + "WithHooks": NewMysqlDialect, + MySQL + "ReplicaWithHooks": NewMysqlDialect, + SQLite + "WithHooks": NewSQLite3Dialect, + Postgres + "WithHooks": NewPostgresDialect, } func NewDialect(driverName string) Dialect { diff --git a/pkg/services/sqlstore/replstore.go b/pkg/services/sqlstore/replstore.go index c3540d79c4f..050341a3606 100644 --- a/pkg/services/sqlstore/replstore.go +++ b/pkg/services/sqlstore/replstore.go @@ -118,7 +118,7 @@ func (ss *SQLStore) initReadOnlyEngine(engine *xorm.Engine) error { ss.dbCfg = dbCfg if ss.cfg.DatabaseInstrumentQueries { - ss.dbCfg.Type = WrapDatabaseDriverWithHooks(ss.dbCfg.Type, ss.tracer) + ss.dbCfg.Type = WrapDatabaseReplDriverWithHooks(ss.dbCfg.Type, ss.tracer) } if engine == nil { diff --git a/pkg/services/store/entity/client_wrapper.go b/pkg/services/store/entity/client_wrapper.go index a4b7572d7a2..6b488d6d5e7 100644 --- a/pkg/services/store/entity/client_wrapper.go +++ b/pkg/services/store/entity/client_wrapper.go @@ -6,7 +6,7 @@ import ( grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "google.golang.org/grpc" - grpcUtils "github.com/grafana/grafana/pkg/services/store/entity/grpc" + grpcUtils "github.com/grafana/grafana/pkg/storage/unified/resource/grpc" ) func NewEntityStoreClientLocal(server EntityStoreServer) EntityStoreClient { diff --git a/pkg/services/store/entity/entity.proto b/pkg/services/store/entity/entity.proto index 9e9a4ec66d3..087b6e5384d 100644 --- a/pkg/services/store/entity/entity.proto +++ b/pkg/services/store/entity/entity.proto @@ -311,7 +311,7 @@ message EntityListRequest { // list deleted entities instead of active ones bool deleted = 12; - // Limit to a set of origin keys (empty is all) + // Deprecated: Limit to a set of origin keys (empty is all) repeated string origin_keys = 13; } diff --git a/pkg/services/store/entity/grpc/authenticator.go b/pkg/services/store/entity/grpc/authenticator.go deleted file mode 100644 index c8e52721f92..00000000000 --- a/pkg/services/store/entity/grpc/authenticator.go +++ /dev/null @@ -1,97 +0,0 @@ -package grpc - -import ( - "context" - "fmt" - "strconv" - - "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" - "github.com/grafana/grafana/pkg/services/user" - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" -) - -type Authenticator struct{} - -func (f *Authenticator) Authenticate(ctx context.Context) (context.Context, error) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, fmt.Errorf("no metadata found") - } - - // TODO: use id token instead of these fields - login := md.Get("grafana-login")[0] - if login == "" { - return nil, fmt.Errorf("no login found in context") - } - userID, err := strconv.ParseInt(md.Get("grafana-userid")[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid user id: %w", err) - } - orgID, err := strconv.ParseInt(md.Get("grafana-orgid")[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid org id: %w", err) - } - - // TODO: validate id token - /* - idToken := md.Get("grafana-idtoken")[0] - if idToken == "" { - return nil, fmt.Errorf("no id token found in context") - } - jwtToken, err := jwt.ParseSigned(idToken) - if err != nil { - return nil, fmt.Errorf("invalid id token: %w", err) - } - claims := jwt.Claims{} - err = jwtToken.UnsafeClaimsWithoutVerification(&claims) - if err != nil { - return nil, fmt.Errorf("invalid id token: %w", err) - } - // fmt.Printf("JWT CLAIMS: %+v\n", claims) - */ - - return appcontext.WithUser(ctx, &user.SignedInUser{ - Login: login, - UserID: userID, - OrgID: orgID, - }), nil -} - -var _ interceptors.Authenticator = (*Authenticator)(nil) - -func UnaryClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - ctx, err := WrapContext(ctx) - if err != nil { - return err - } - return invoker(ctx, method, req, reply, cc, opts...) -} - -var _ grpc.UnaryClientInterceptor = UnaryClientInterceptor - -func StreamClientInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { - ctx, err := WrapContext(ctx) - if err != nil { - return nil, err - } - return streamer(ctx, desc, cc, method, opts...) -} - -var _ grpc.StreamClientInterceptor = StreamClientInterceptor - -func WrapContext(ctx context.Context) (context.Context, error) { - user, err := appcontext.User(ctx) - if err != nil { - return ctx, err - } - - // set grpc metadata into the context to pass to the grpc server - return metadata.NewOutgoingContext(ctx, metadata.Pairs( - "grafana-idtoken", user.IDToken, - "grafana-userid", strconv.FormatInt(user.UserID, 10), - "grafana-orgid", strconv.FormatInt(user.OrgID, 10), - "grafana-login", user.Login, - )), nil -} diff --git a/pkg/services/store/entity/server/service.go b/pkg/services/store/entity/server/service.go index 89a150ccb39..7c2d6652c18 100644 --- a/pkg/services/store/entity/server/service.go +++ b/pkg/services/store/entity/server/service.go @@ -4,6 +4,9 @@ import ( "context" "github.com/grafana/dskit/services" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc/health/grpc_health_v1" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/modules" @@ -13,11 +16,9 @@ import ( "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" - "github.com/grafana/grafana/pkg/services/store/entity/grpc" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" "github.com/grafana/grafana/pkg/setting" - "github.com/prometheus/client_golang/prometheus" - "google.golang.org/grpc/health/grpc_health_v1" + "github.com/grafana/grafana/pkg/storage/unified/resource/grpc" ) var ( diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go index 0a7d7cce617..a63bde240c4 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -17,7 +17,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -25,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/entity/db" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" + "github.com/grafana/grafana/pkg/storage/unified/resource" ) const entityTable = "entity" @@ -75,7 +76,7 @@ type sqlEntityServer struct { db db.EntityDBInterface // needed to keep xorm engine in scope sess *session.SessionDB dialect migrator.Dialect - broadcaster Broadcaster[*entity.EntityWatchResponse] + broadcaster resource.Broadcaster[*entity.EntityWatchResponse] ctx context.Context // TODO: remove cancel context.CancelFunc tracer trace.Tracer @@ -141,7 +142,7 @@ func (s *sqlEntityServer) init() error { s.dialect = migrator.NewDialect(engine.DriverName()) // set up the broadcaster - s.broadcaster, err = NewBroadcaster(s.ctx, func(stream chan<- *entity.EntityWatchResponse) error { + s.broadcaster, err = resource.NewBroadcaster(s.ctx, func(stream chan<- *entity.EntityWatchResponse) error { // start the poller go s.poller(stream) @@ -357,7 +358,7 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { ctxLogger.Error("error getting user from ctx", "error", err) return nil, err @@ -572,7 +573,7 @@ func (s *sqlEntityServer) List(ctx context.Context, r *entity.EntityListRequest) return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { ctxLogger.Error("error getting user from ctx", "error", err) return nil, err @@ -802,7 +803,7 @@ func (s *sqlEntityServer) Watch(w entity.EntityStore_WatchServer) error { return err } - user, err := appcontext.User(w.Context()) + user, err := identity.GetRequester(w.Context()) if err != nil { ctxLogger.Error("error getting user from ctx", "error", err) return err @@ -1288,7 +1289,7 @@ func (s *sqlEntityServer) FindReferences(ctx context.Context, r *entity.Referenc return nil, err } - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil { ctxLogger.Error("error getting user from ctx", "error", err) return nil, err diff --git a/pkg/services/store/entity/sqlstash/utils.go b/pkg/services/store/entity/sqlstash/utils.go index ade92f40374..3d4666b9649 100644 --- a/pkg/services/store/entity/sqlstash/utils.go +++ b/pkg/services/store/entity/sqlstash/utils.go @@ -8,7 +8,7 @@ import ( "fmt" "text/template" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/store/entity/db" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate" ) @@ -27,7 +27,7 @@ func createETag(body []byte, meta []byte, status []byte) string { // getCurrentUser returns a string identifying the user making a request with // the given context. func getCurrentUser(ctx context.Context) (string, error) { - user, err := appcontext.User(ctx) + user, err := identity.GetRequester(ctx) if err != nil || user == nil { return "", fmt.Errorf("%w: %w", ErrUserNotFoundInContext, err) } diff --git a/pkg/services/store/resolver/ds_cache.go b/pkg/services/store/resolver/ds_cache.go index 74e945b8beb..f0be2ed28d5 100644 --- a/pkg/services/store/resolver/ds_cache.go +++ b/pkg/services/store/resolver/ds_cache.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/tsdb/grafanads" @@ -122,12 +122,12 @@ func (c *dsCache) getDS(ctx context.Context, uid string) (*dsVal, error) { } } - usr, err := appcontext.User(ctx) + usr, err := identity.GetRequester(ctx) if err != nil { return nil, nil // no user } - v, ok := c.cache[usr.OrgID] + v, ok := c.cache[usr.GetOrgID()] if !ok { return nil, nil // org not found } diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index e7168251986..3e340736498 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -301,15 +301,15 @@ func (s *Service) UpdateLastSeenAt(ctx context.Context, cmd *user.UpdateUserLast return err } - if !shouldUpdateLastSeen(u.LastSeenAt) { + if !s.shouldUpdateLastSeen(u.LastSeenAt) { return user.ErrLastSeenUpToDate } return s.store.UpdateLastSeenAt(ctx, cmd) } -func shouldUpdateLastSeen(t time.Time) bool { - return time.Since(t) > time.Minute*15 +func (s *Service) shouldUpdateLastSeen(t time.Time) bool { + return time.Since(t) > s.cfg.UserLastSeenUpdateInterval } func (s *Service) GetSignedInUser(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) { diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index 346f8545267..da30c586564 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -221,6 +221,7 @@ func TestUpdateLastSeenAt(t *testing.T) { tracer: tracing.InitializeTracerForTest(), } userService.cfg = setting.NewCfg() + userService.cfg.UserLastSeenUpdateInterval = 5 * time.Minute t.Run("update last seen at", func(t *testing.T) { userStore.ExpectedSignedInUser = &user.SignedInUser{UserID: 1, OrgID: 1, Email: "email", Login: "login", Name: "name", LastSeenAt: time.Now().Add(-20 * time.Minute)} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index ab0688afd66..336a27b7e28 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -306,6 +306,7 @@ type Cfg struct { UserInviteMaxLifetime time.Duration HiddenUsers map[string]struct{} CaseInsensitiveLogin bool // Login and Email will be considered case insensitive + UserLastSeenUpdateInterval time.Duration VerificationEmailMaxLifetime time.Duration // Service Accounts @@ -1695,6 +1696,19 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { return errors.New("the minimum supported value for the `user_invite_max_lifetime_duration` configuration is 15m (15 minutes)") } + cfg.UserLastSeenUpdateInterval, err = gtime.ParseDuration(valueAsString(users, "last_seen_update_interval", "15m")) + if err != nil { + return err + } + + if cfg.UserLastSeenUpdateInterval < time.Minute*5 { + cfg.Logger.Warn("the minimum supported value for the `last_seen_update_interval` configuration is 5m (5 minutes)") + cfg.UserLastSeenUpdateInterval = time.Minute * 5 + } else if cfg.UserLastSeenUpdateInterval > time.Hour*1 { + cfg.Logger.Warn("the maximum supported value for the `last_seen_update_interval` configuration is 1h (1 hour)") + cfg.UserLastSeenUpdateInterval = time.Hour * 1 + } + cfg.HiddenUsers = make(map[string]struct{}) hiddenUsers := users.Key("hidden_users").MustString("") for _, user := range strings.Split(hiddenUsers, ",") { diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 4cd4e9dde85..1d205dc6faa 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -6,11 +6,12 @@ import ( "strings" "time" - alertingCluster "github.com/grafana/alerting/cluster" dstls "github.com/grafana/dskit/crypto/tls" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "gopkg.in/ini.v1" + alertingCluster "github.com/grafana/alerting/cluster" + "github.com/grafana/grafana/pkg/util" ) @@ -113,6 +114,9 @@ type UnifiedAlertingSettings struct { // Retention period for Alertmanager notification log entries. NotificationLogRetention time.Duration + + // Duration for which a resolved alert state transition will continue to be sent to the Alertmanager. + ResolvedAlertRetention time.Duration } type RecordingRuleSettings struct { @@ -435,6 +439,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { return err } + uaCfg.ResolvedAlertRetention, err = gtime.ParseDuration(valueAsString(ua, "resolved_alert_retention", (15 * time.Minute).String())) + if err != nil { + return err + } + cfg.UnifiedAlerting = uaCfg return nil } diff --git a/pkg/services/store/entity/sqlstash/broadcaster.go b/pkg/storage/unified/resource/broadcaster.go similarity index 97% rename from pkg/services/store/entity/sqlstash/broadcaster.go rename to pkg/storage/unified/resource/broadcaster.go index 56a010bf2a0..547bfe576e8 100644 --- a/pkg/services/store/entity/sqlstash/broadcaster.go +++ b/pkg/storage/unified/resource/broadcaster.go @@ -1,4 +1,4 @@ -package sqlstash +package resource import ( "context" @@ -109,7 +109,7 @@ type broadcaster[T any] struct { // subscription management - cache Cache[T] + cache channelCache[T] subscribe chan chan T unsubscribe chan (<-chan T) subs map[<-chan T]chan T @@ -166,7 +166,7 @@ func (b *broadcaster[T]) init(ctx context.Context, connect ConnectFunc[T]) error // initialize our internal state b.shouldTerminate = ctx.Done() - b.cache = NewCache[T](ctx, 100) + b.cache = newChannelCache[T](ctx, 100) b.subscribe = make(chan chan T, 100) b.unsubscribe = make(chan (<-chan T), 100) b.subs = make(map[<-chan T]chan T) @@ -239,9 +239,9 @@ func (b *broadcaster[T]) stream(input <-chan T) { } } -const DefaultCacheSize = 100 +const defaultCacheSize = 100 -type Cache[T any] interface { +type channelCache[T any] interface { Len() int Add(item T) Get(i int) T @@ -260,12 +260,12 @@ type cache[T any] struct { ctx context.Context } -func NewCache[T any](ctx context.Context, size int) Cache[T] { +func newChannelCache[T any](ctx context.Context, size int) channelCache[T] { c := &cache[T]{} c.ctx = ctx if size <= 0 { - size = DefaultCacheSize + size = defaultCacheSize } c.size = size c.cache = make([]T, c.size) diff --git a/pkg/services/store/entity/sqlstash/broadcaster_test.go b/pkg/storage/unified/resource/broadcaster_test.go similarity index 96% rename from pkg/services/store/entity/sqlstash/broadcaster_test.go rename to pkg/storage/unified/resource/broadcaster_test.go index fc6a6369a2d..8eedfa01ce5 100644 --- a/pkg/services/store/entity/sqlstash/broadcaster_test.go +++ b/pkg/storage/unified/resource/broadcaster_test.go @@ -1,4 +1,4 @@ -package sqlstash +package resource import ( "context" @@ -8,7 +8,7 @@ import ( ) func TestCache(t *testing.T) { - c := NewCache[int](context.Background(), 10) + c := newChannelCache[int](context.Background(), 10) e := []int{} err := c.Range(func(i int) error { diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod new file mode 100644 index 00000000000..52164669af4 --- /dev/null +++ b/pkg/storage/unified/resource/go.mod @@ -0,0 +1,26 @@ +module github.com/grafana/grafana/pkg/storage/unified/resource + +go 1.21.10 + +require ( + github.com/grafana/authlib v0.0.0-20240611075137-331cbe4e840f + github.com/stretchr/testify v1.9.0 + google.golang.org/grpc v1.64.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/storage/unified/resource/go.sum b/pkg/storage/unified/resource/go.sum new file mode 100644 index 00000000000..f8a48ef5de5 --- /dev/null +++ b/pkg/storage/unified/resource/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/grafana/authlib v0.0.0-20240611075137-331cbe4e840f h1:hvRCAv+TgcHu3i/Sd7lFJx84iEtgzDCYuk7OWeXatD0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/storage/unified/resource/grpc/authenticator.go b/pkg/storage/unified/resource/grpc/authenticator.go new file mode 100644 index 00000000000..f77cc87de92 --- /dev/null +++ b/pkg/storage/unified/resource/grpc/authenticator.go @@ -0,0 +1,163 @@ +package grpc + +import ( + "context" + "fmt" + "strconv" + + "github.com/grafana/authlib/authn" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +const ( + mdToken = "grafana-idtoken" + mdLogin = "grafana-login" + mdUserID = "grafana-user-id" + mdUserUID = "grafana-user-uid" + mdOrgName = "grafana-org-name" + mdOrgID = "grafana-org-id" + mdOrgRole = "grafana-org-role" +) + +// This is in a package we can no import +// var _ interceptors.Authenticator = (*Authenticator)(nil) + +type Authenticator struct { + IDTokenVerifier authn.Verifier[authn.IDTokenClaims] +} + +func (f *Authenticator) Authenticate(ctx context.Context) (context.Context, error) { + r, err := identity.GetRequester(ctx) + if err == nil && r != nil { + return ctx, nil // noop, requester exists + } + + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, fmt.Errorf("no metadata found") + } + user, err := f.DecodeMetadata(ctx, md) + if err != nil { + return nil, err + } + return identity.WithRequester(ctx, user), nil +} + +func (f *Authenticator) DecodeMetadata(ctx context.Context, meta metadata.MD) (identity.Requester, error) { + // Avoid NPE/panic with getting keys + getter := func(key string) string { + v := meta.Get(key) + if len(v) > 0 { + return v[0] + } + return "" + } + + // First try the token + token := getter(mdToken) + if token != "" && f.IDTokenVerifier != nil { + claims, err := f.IDTokenVerifier.Verify(ctx, token) + if err != nil { + return nil, err + } + fmt.Printf("TODO, convert CLAIMS to an identity %+v\n", claims) + } + + user := &identity.StaticRequester{} + user.Login = getter(mdLogin) + if user.Login == "" { + return nil, fmt.Errorf("no login found in grpc metadata") + } + + // The namespaced verisons have a "-" in the key + // TODO, remove after this has been deployed to unified storage + if getter(mdUserID) == "" { + var err error + user.Namespace = identity.NamespaceUser + user.UserID, err = strconv.ParseInt(getter("grafana-userid"), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid user id: %w", err) + } + user.OrgID, err = strconv.ParseInt(getter("grafana-orgid"), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid org id: %w", err) + } + return user, nil + } + + ns, err := identity.ParseNamespaceID(getter(mdUserID)) + if err != nil { + return nil, fmt.Errorf("invalid user id: %w", err) + } + user.Namespace = ns.Namespace() + user.UserID, err = ns.ParseInt() + if err != nil { + return nil, fmt.Errorf("invalid user id: %w", err) + } + + ns, err = identity.ParseNamespaceID(getter(mdUserUID)) + if err != nil { + return nil, fmt.Errorf("invalid user id: %w", err) + } + user.UserUID = ns.ID() + + user.OrgName = getter(mdOrgName) + user.OrgID, err = strconv.ParseInt(getter(mdOrgID), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid org id: %w", err) + } + user.OrgRole = identity.RoleType(getter(mdOrgRole)) + return user, nil +} + +func UnaryClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + ctx, err := wrapContext(ctx) + if err != nil { + return err + } + return invoker(ctx, method, req, reply, cc, opts...) +} + +var _ grpc.UnaryClientInterceptor = UnaryClientInterceptor + +func StreamClientInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + ctx, err := wrapContext(ctx) + if err != nil { + return nil, err + } + return streamer(ctx, desc, cc, method, opts...) +} + +var _ grpc.StreamClientInterceptor = StreamClientInterceptor + +func wrapContext(ctx context.Context) (context.Context, error) { + user, err := identity.GetRequester(ctx) + if err != nil { + return ctx, err + } + + // set grpc metadata into the context to pass to the grpc server + return metadata.NewOutgoingContext(ctx, encodeIdentityInMetadata(user)), nil +} + +func encodeIdentityInMetadata(user identity.Requester) metadata.MD { + return metadata.Pairs( + // This should be everything needed to recreate the user + mdToken, user.GetIDToken(), + + // Or we can create it directly + mdUserID, user.GetID().String(), + mdUserUID, user.GetUID().String(), + mdOrgName, user.GetOrgName(), + mdOrgID, strconv.FormatInt(user.GetOrgID(), 10), + mdOrgRole, string(user.GetOrgRole()), + mdLogin, user.GetLogin(), + + // TODO, Remove after this is deployed to unified storage + "grafana-userid", user.GetID().ID(), + "grafana-useruid", user.GetUID().ID(), + ) +} diff --git a/pkg/storage/unified/resource/grpc/authenticator_test.go b/pkg/storage/unified/resource/grpc/authenticator_test.go new file mode 100644 index 00000000000..d871cce721c --- /dev/null +++ b/pkg/storage/unified/resource/grpc/authenticator_test.go @@ -0,0 +1,34 @@ +package grpc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +func TestBasicEncodeDecode(t *testing.T) { + before := &identity.StaticRequester{ + UserID: 123, + UserUID: "abc", + Login: "test", + Namespace: identity.NamespaceUser, + OrgID: 456, + OrgName: "org", + OrgRole: identity.RoleAdmin, + } + + auth := &Authenticator{} + + md := encodeIdentityInMetadata(before) + after, err := auth.DecodeMetadata(context.Background(), md) + require.NoError(t, err) + require.Equal(t, before.GetID(), after.GetID()) + require.Equal(t, before.GetUID(), after.GetUID()) + require.Equal(t, before.GetLogin(), after.GetLogin()) + require.Equal(t, before.GetOrgID(), after.GetOrgID()) + require.Equal(t, before.GetOrgName(), after.GetOrgName()) + require.Equal(t, before.GetOrgRole(), after.GetOrgRole()) +} diff --git a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go new file mode 100644 index 00000000000..c8141cbcdee --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go @@ -0,0 +1,541 @@ +package timeinterval + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/prometheus/alertmanager/config" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/generated/clientset/versioned" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func getTestHelper(t *testing.T) *apis.K8sTestHelper { + return apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{ + featuremgmt.FlagAlertingApiServer, + }, + }) +} + +func TestIntegrationResourceIdentifier(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + client := adminK8sClient.NotificationsV0alpha1().TimeIntervals("default") + + newInterval := &v0alpha1.TimeInterval{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TimeIntervalSpec{ + Name: "time-newInterval", + TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2), + }, + } + + t.Run("create should fail if object name is specified", func(t *testing.T) { + interval := newInterval.DeepCopy() + interval.Name = "time-newInterval" + _, err := client.Create(ctx, interval, v1.CreateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) + }) + + var resourceID string + t.Run("create should succeed and provide resource name", func(t *testing.T) { + actual, err := client.Create(ctx, newInterval, v1.CreateOptions{}) + require.NoError(t, err) + require.NotEmptyf(t, actual.Name, "Resource name should not be empty") + require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") + resourceID = actual.Name + }) + + var existingInterval *v0alpha1.TimeInterval + t.Run("resource should be available by the identifier", func(t *testing.T) { + actual, err := client.Get(ctx, resourceID, v1.GetOptions{}) + require.NoError(t, err) + require.NotEmptyf(t, actual.Name, "Resource name should not be empty") + require.Equal(t, newInterval.Spec, actual.Spec) + existingInterval = actual + }) + + t.Run("update should fail if name in the specification changes", func(t *testing.T) { + if existingInterval == nil { + t.Skip() + } + updated := existingInterval.DeepCopy() + updated.Spec.Name = "another-newInterval" + _, err := client.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) + }) +} + +func TestIntegrationTimeIntervalAccessControl(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org1 := helper.Org1 + + type testCase struct { + user apis.User + canRead bool + canUpdate bool + canCreate bool + canDelete bool + } + + reader := helper.CreateUser("IntervalsReader", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, + }, + }, + }) + writer := helper.CreateUser("IntervalsWriter", "Org1", org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, + accesscontrol.ActionAlertingNotificationsTimeIntervalsWrite, + }, + }, + }) + + deleter := helper.CreateUser("IntervalsDeleter", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, + accesscontrol.ActionAlertingNotificationsTimeIntervalsDelete, + }, + }, + }) + + testCases := []testCase{ + { + user: org1.Admin, + canRead: true, + canUpdate: true, + canCreate: true, + canDelete: true, + }, + { + user: org1.Editor, + canRead: true, + canUpdate: true, + canCreate: true, + canDelete: true, + }, + { + user: org1.Viewer, + canRead: true, + }, + { + user: reader, + canRead: true, + }, + { + user: writer, + canRead: true, + canCreate: true, + canUpdate: true, + }, + { + user: deleter, + canRead: true, + canDelete: true, + }, + } + + admin := org1.Admin + adminK8sClient, err := versioned.NewForConfig(admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TimeIntervals("default") + + for _, tc := range testCases { + t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { + k8sClient, err := versioned.NewForConfig(tc.user.NewRestConfig()) + require.NoError(t, err) + client := k8sClient.NotificationsV0alpha1().TimeIntervals("default") + + var expected = &v0alpha1.TimeInterval{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{ + "grafana.com/provenance": "", + }, + }, + Spec: v0alpha1.TimeIntervalSpec{ + Name: fmt.Sprintf("time-interval-1-%s", tc.user.Identity.GetLogin()), + TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2), + }, + } + d, err := json.Marshal(expected) + require.NoError(t, err) + + if tc.canCreate { + t.Run("should be able to create time interval", func(t *testing.T) { + actual, err := client.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.Equal(t, expected.Spec, actual.Spec) + + t.Run("should fail if already exists", func(t *testing.T) { + _, err := client.Create(ctx, actual, v1.CreateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) + + expected = actual + }) + } else { + t.Run("should be forbidden to create", func(t *testing.T) { + _, err := client.Create(ctx, expected, v1.CreateOptions{}) + require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) + }) + + // create resource to proceed with other tests + expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.NotNil(t, expected) + } + + if tc.canRead { + t.Run("should be able to list time intervals", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + }) + + t.Run("should be able to read time interval by resource identifier", func(t *testing.T) { + got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expected, got) + + t.Run("should get NotFound if resource does not exist", func(t *testing.T) { + _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to list time intervals", func(t *testing.T) { + _, err := client.List(ctx, v1.ListOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should be forbidden to read time interval by name", func(t *testing.T) { + _, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should get forbidden even if name does not exist", func(t *testing.T) { + _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + } + + updatedExpected := expected.DeepCopy() + updatedExpected.Spec.TimeIntervals = v0alpha1.IntervalGenerator{}.GenerateMany(2) + + d, err = json.Marshal(updatedExpected) + require.NoError(t, err) + + if tc.canUpdate { + t.Run("should be able to update time interval", func(t *testing.T) { + updated, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + + expected = updated + + t.Run("should get NotFound if name does not exist", func(t *testing.T) { + up := updatedExpected.DeepCopy() + up.Name = "notFound" + _, err := client.Update(ctx, up, v1.UpdateOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to update time interval", func(t *testing.T) { + _, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { + up := updatedExpected.DeepCopy() + up.Name = "notFound" + _, err := client.Update(ctx, up, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + } + + deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}} + + if tc.canDelete { + t.Run("should be able to delete time interval", func(t *testing.T) { + err := client.Delete(ctx, expected.Name, deleteOptions) + require.NoError(t, err) + + t.Run("should get NotFound if name does not exist", func(t *testing.T) { + err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to delete time interval", func(t *testing.T) { + err := client.Delete(ctx, expected.Name, deleteOptions) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { + err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + } + + if tc.canRead { + t.Run("should get empty list if no mute timings", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 0) + }) + } + }) + } +} + +func TestIntegrationTimeIntervalProvisioning(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org := helper.Org1 + + admin := org.Admin + adminK8sClient, err := versioned.NewForConfig(admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TimeIntervals("default") + + env := helper.GetEnv() + ac := acimpl.ProvideAccessControl(env.FeatureToggles) + db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac) + require.NoError(t, err) + + created, err := adminClient.Create(ctx, &v0alpha1.TimeInterval{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TimeIntervalSpec{ + Name: "time-interval-1", + TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2), + }, + }, v1.CreateOptions{}) + require.NoError(t, err) + require.Equal(t, "", created.Annotations["grafana.com/provenance"]) + + t.Run("should provide provenance status", func(t *testing.T) { + require.NoError(t, db.SetProvenance(ctx, &definitions.MuteTimeInterval{ + MuteTimeInterval: config.MuteTimeInterval{ + Name: created.Spec.Name, + }, + }, admin.Identity.GetOrgID(), "API")) + + got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "API", got.Annotations["grafana.com/provenance"]) + }) + t.Run("should not let update if provisioned", func(t *testing.T) { + updated := created.DeepCopy() + updated.Spec.TimeIntervals = v0alpha1.IntervalGenerator{}.GenerateMany(2) + + _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should not let delete if provisioned", func(t *testing.T) { + err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) +} + +func TestIntegrationTimeIntervalOptimisticConcurrency(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TimeIntervals("default") + + interval := v0alpha1.TimeInterval{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TimeIntervalSpec{ + Name: "time-interval", + TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2), + }, + } + + created, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + require.NotEmpty(t, created.ResourceVersion) + + t.Run("should forbid if version does not match", func(t *testing.T) { + updated := created.DeepCopy() + updated.ResourceVersion = "test" + _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + }) + t.Run("should update if version matches", func(t *testing.T) { + updated := created.DeepCopy() + updated.Spec.TimeIntervals = v0alpha1.IntervalGenerator{}.GenerateMany(2) + actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.EqualValues(t, updated.Spec, actualUpdated.Spec) + require.NotEqual(t, updated.ResourceVersion, actualUpdated.ResourceVersion) + }) + t.Run("should update if version is empty", func(t *testing.T) { + updated := created.DeepCopy() + updated.ResourceVersion = "" + updated.Spec.TimeIntervals = v0alpha1.IntervalGenerator{}.GenerateMany(2) + + actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.EqualValues(t, updated.Spec, actualUpdated.Spec) + require.NotEqual(t, created.ResourceVersion, actualUpdated.ResourceVersion) + }) + t.Run("should fail to delete if version does not match", func(t *testing.T) { + actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer("something"), + }, + }) + require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + }) + t.Run("should succeed if version matches", func(t *testing.T) { + actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer(actual.ResourceVersion), + }, + }) + require.NoError(t, err) + }) + t.Run("should succeed if version is empty", func(t *testing.T) { + actual, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer(actual.ResourceVersion), + }, + }) + require.NoError(t, err) + }) +} + +func TestIntegrationTimeIntervalPatch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TimeIntervals("default") + + interval := v0alpha1.TimeInterval{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TimeIntervalSpec{ + Name: "time-interval", + TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2), + }, + } + + current, err := adminClient.Create(ctx, &interval, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, current) + require.NotEmpty(t, current.ResourceVersion) + + t.Run("should patch with merge patch", func(t *testing.T) { + patch := `{ + "spec": { + "time_intervals" : [] + } + }` + + result, err := adminClient.Patch(ctx, current.Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) + require.NoError(t, err) + require.Empty(t, result.Spec.TimeIntervals) + current = result + }) + + t.Run("should patch with json patch", func(t *testing.T) { + expected := v0alpha1.IntervalGenerator{}.Generate() + + patch := []map[string]interface{}{ + { + "op": "add", + "path": "/spec/time_intervals/-", + "value": expected, + }, + } + + patchData, err := json.Marshal(patch) + require.NoError(t, err) + + result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{}) + require.NoError(t, err) + expectedSpec := *current.Spec.DeepCopy() + expectedSpec.TimeIntervals = []v0alpha1.Interval{ + expected, + } + require.EqualValues(t, expectedSpec, result.Spec) + current = result + }) +} diff --git a/pkg/tests/apis/example/example_test.go b/pkg/tests/apis/example/example_test.go deleted file mode 100644 index c702402837f..00000000000 --- a/pkg/tests/apis/example/example_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package playlist - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/tests/apis" - "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/grafana/grafana/pkg/tests/testsuite" -) - -func TestMain(m *testing.M) { - testsuite.Run(m) -} - -func TestIntegrationExampleApp(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: false, // required for experimental APIs - DisableAnonymous: true, - EnableFeatureToggles: []string{ - featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service - }, - }) - - t.Run("Check runtime info resource", func(t *testing.T) { - // Resource is not namespaced! - client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ - Group: "example.grafana.app", - Version: "v0alpha1", - Resource: "runtime", - }) - rsp, err := client.List(context.Background(), metav1.ListOptions{}) - require.NoError(t, err) - - v, ok := rsp.Object["startupTime"].(int64) - require.True(t, ok) - require.Greater(t, v, time.Now().Add(-1*time.Hour).UnixMilli()) // should be within the last hour - }) - - t.Run("Check discovery client", func(t *testing.T) { - disco := helper.NewDiscoveryClient() - resources, err := disco.ServerResourcesForGroupVersion("example.grafana.app/v0alpha1") - require.NoError(t, err) - - v1Disco, err := json.MarshalIndent(resources, "", " ") - require.NoError(t, err) - //fmt.Printf("%s", string(v1Disco)) - - require.JSONEq(t, `{ - "kind": "APIResourceList", - "apiVersion": "v1", - "groupVersion": "example.grafana.app/v0alpha1", - "resources": [ - { - "name": "dummy", - "singularName": "dummy", - "namespaced": true, - "kind": "DummyResource", - "verbs": [ - "get", - "list" - ] - }, - { - "name": "dummy/sub", - "singularName": "", - "namespaced": true, - "kind": "DummySubresource", - "verbs": [ - "get" - ] - }, - { - "name": "runtime", - "singularName": "runtime", - "namespaced": false, - "kind": "RuntimeInfo", - "verbs": [ - "list" - ] - } - ] - }`, string(v1Disco)) - - //fmt.Printf("%s", string(v1Disco)) - require.JSONEq(t, `[ - { - "version": "v0alpha1", - "freshness": "Current", - "resources": [ - { - "resource": "dummy", - "responseKind": { - "group": "", - "kind": "DummyResource", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "dummy", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "DummySubresource", - "version": "" - }, - "subresource": "sub", - "verbs": [ - "get" - ] - } - ], - "verbs": [ - "get", - "list" - ] - }, - { - "resource": "runtime", - "responseKind": { - "group": "", - "kind": "RuntimeInfo", - "version": "" - }, - "scope": "Cluster", - "singularResource": "runtime", - "verbs": [ - "list" - ] - } - ] - } - ]`, helper.GetGroupVersionInfoJSON("example.grafana.app")) - }) - - t.Run("Check dummy with subresource", func(t *testing.T) { - client := helper.Org1.Viewer.ResourceClient(t, schema.GroupVersionResource{ - Group: "example.grafana.app", - Version: "v0alpha1", - Resource: "dummy", - }).Namespace("default") - rsp, err := client.Get(context.Background(), "test2", metav1.GetOptions{}) - require.NoError(t, err) - - v, ok, err := unstructured.NestedString(rsp.Object, "spec", "Dummy") - require.NoError(t, err) - require.True(t, ok) - - require.Equal(t, "test2", v) - require.Equal(t, "DummyResource", rsp.GetObjectKind().GroupVersionKind().Kind) - - // Now a sub-resource - rsp, err = client.Get(context.Background(), "test2", metav1.GetOptions{}, "sub") - require.NoError(t, err) - - raw, err := json.MarshalIndent(rsp, "", " ") - require.NoError(t, err) - //fmt.Printf("%s", string(raw)) - require.JSONEq(t, `{ - "apiVersion": "example.grafana.app/v0alpha1", - "kind": "DummySubresource", - "info": "default/viewer-1" - }`, string(raw)) - }) -} diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index ad0bef49eb6..5fa3c0633cb 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -28,8 +28,11 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" @@ -40,6 +43,8 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) +const Org1 = "Org1" + type K8sTestHelper struct { t *testing.T env server.TestEnv @@ -63,7 +68,7 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { namespacer: request.GetNamespaceMapper(nil), } - c.Org1 = c.createTestUsers("Org1") + c.Org1 = c.createTestUsers(Org1) c.OrgB = c.createTestUsers("OrgB") c.loadAPIGroups() @@ -88,6 +93,10 @@ func (c *K8sTestHelper) loadAPIGroups() { } } +func (c *K8sTestHelper) GetEnv() server.TestEnv { + return c.env +} + func (c *K8sTestHelper) Shutdown() { err := c.env.Server.Shutdown(context.Background(), "done") require.NoError(c.t, err) @@ -143,8 +152,11 @@ func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured) string { deep := v.DeepCopy() anno := deep.GetAnnotations() - if anno["grafana.app/originKey"] != "" { - anno["grafana.app/originKey"] = "${originKey}" + if anno["grafana.app/originPath"] != "" { + anno["grafana.app/originPath"] = "${originPath}" + } + if anno["grafana.app/originHash"] != "" { + anno["grafana.app/originHash"] = "${originHash}" } if anno["grafana.app/updatedTimestamp"] != "" { anno["grafana.app/updatedTimestamp"] = "${updatedTimestamp}" @@ -169,7 +181,7 @@ func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured) string { } out, err := json.MarshalIndent(copy, "", " ") - //fmt.Printf("%s", out) + // fmt.Printf("%s", out) require.NoError(c.t, err) return string(out) } @@ -371,7 +383,16 @@ func (c *K8sTestHelper) LoadYAMLOrJSON(body string) *unstructured.Unstructured { return &unstructured.Unstructured{Object: unstructuredMap} } -func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { +func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers { + c.t.Helper() + return OrgUsers{ + Admin: c.CreateUser("admin", orgName, org.RoleAdmin, nil), + Editor: c.CreateUser("editor", orgName, org.RoleEditor, nil), + Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil), + } +} + +func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.RoleType, permissions []resourcepermissions.SetResourcePermissionCommand) User { c.t.Helper() store := c.env.SQLStore @@ -386,9 +407,17 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { require.NoError(c.t, err) orgId := int64(1) - if orgName != "Org1" { - orgId, err = orgService.GetOrCreate(context.Background(), orgName) - require.NoError(c.t, err) + if orgName != Org1 { + o, err := orgService.GetByName(context.Background(), &org.GetOrgByNameQuery{Name: orgName}) + if err != nil { + if !org.ErrOrgNotFound.Is(err) { + require.NoError(c.t, err) + } + orgId, err = orgService.GetOrCreate(context.Background(), orgName) + require.NoError(c.t, err) + } else { + orgId = o.ID + } } c.env.Cfg.AutoAssignOrg = true c.env.Cfg.AutoAssignOrgId = int(orgId) @@ -404,37 +433,52 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { require.NoError(c.t, err) baseUrl := fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr()) - createUser := func(key string, role org.RoleType) User { - u, err := userSvc.Create(context.Background(), &user.CreateUserCommand{ - DefaultOrgRole: string(role), - Password: user.Password(key), - Login: fmt.Sprintf("%s-%d", key, orgId), - OrgID: orgId, - }) - require.NoError(c.t, err) - require.Equal(c.t, orgId, u.OrgID) - require.True(c.t, u.ID > 0) - s, err := userSvc.GetSignedInUser(context.Background(), &user.GetSignedInUserQuery{ - UserID: u.ID, - Login: u.Login, - Email: u.Email, - OrgID: orgId, - }) - require.NoError(c.t, err) - require.Equal(c.t, orgId, s.OrgID) - require.Equal(c.t, role, s.OrgRole) // make sure the role was set properly + u, err := userSvc.Create(context.Background(), &user.CreateUserCommand{ + DefaultOrgRole: string(basicRole), + Password: user.Password(name), + Login: fmt.Sprintf("%s-%d", name, orgId), + OrgID: orgId, + }) + require.NoError(c.t, err) + require.Equal(c.t, orgId, u.OrgID) + require.True(c.t, u.ID > 0) - return User{ - Identity: s, - password: key, - baseURL: baseUrl, - } + s, err := userSvc.GetSignedInUser(context.Background(), &user.GetSignedInUserQuery{ + UserID: u.ID, + Login: u.Login, + Email: u.Email, + OrgID: orgId, + }) + require.NoError(c.t, err) + require.Equal(c.t, orgId, s.OrgID) + require.Equal(c.t, basicRole, s.OrgRole) // make sure the role was set properly + + usr := User{ + Identity: s, + password: name, + baseURL: baseUrl, } - return OrgUsers{ - Admin: createUser("admin", org.RoleAdmin), - Editor: createUser("editor", org.RoleEditor), - Viewer: createUser("viewer", org.RoleViewer), + + if len(permissions) > 0 { + c.SetPermissions(usr, permissions) + } + + return usr +} + +func (c *K8sTestHelper) SetPermissions(user User, permissions []resourcepermissions.SetResourcePermissionCommand) { + id, err := user.Identity.GetID().UserID() + require.NoError(c.t, err) + + permissionsStore := resourcepermissions.NewStore(c.env.Cfg, c.env.SQLStore, featuremgmt.WithFeatures()) + + for _, permission := range permissions { + _, err := permissionsStore.SetUserResourcePermission(context.Background(), + user.Identity.GetOrgID(), + accesscontrol.User{ID: id}, + permission, nil) + require.NoError(c.t, err) } } diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index a8c359959df..fd4a0939732 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -360,7 +360,7 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp "kind": "Playlist", "metadata": { "annotations": { - "grafana.app/originKey": "${originKey}", + "grafana.app/originPath": "${originPath}", "grafana.app/originName": "SQL", "grafana.app/updatedTimestamp": "${updatedTimestamp}" }, diff --git a/pkg/tsdb/influxdb/flux/macros.go b/pkg/tsdb/influxdb/flux/macros.go index 4dce9d76822..fbec2b4d1fe 100644 --- a/pkg/tsdb/influxdb/flux/macros.go +++ b/pkg/tsdb/influxdb/flux/macros.go @@ -25,27 +25,40 @@ func interpolateInterval(flux string, interval time.Duration) string { var fluxVariableFilterExp = regexp.MustCompile(`(?m)([a-zA-Z]+)\.([a-zA-Z]+)`) func interpolateFluxSpecificVariables(query queryModel) string { + rawQuery := query.RawQuery flux := query.RawQuery - matches := fluxVariableFilterExp.FindAllStringSubmatch(flux, -1) + matches := fluxVariableFilterExp.FindAllStringSubmatchIndex(rawQuery, -1) if matches != nil { timeRange := query.TimeRange from := timeRange.From.UTC().Format(time.RFC3339Nano) to := timeRange.To.UTC().Format(time.RFC3339Nano) for _, match := range matches { - switch match[2] { + // For query "range(start: v.timeRangeStart, stop: v.timeRangeStop)" + // rawQuery[match[0]:match[1]] will be v.timeRangeStart + // rawQuery[match[2]:match[3]] will be v + // rawQuery[match[4]:match[5]] will be timeRangeStart + fullMatch := rawQuery[match[0]:match[1]] + key := rawQuery[match[4]:match[5]] + + switch key { case "timeRangeStart": - flux = strings.ReplaceAll(flux, match[0], from) + flux = strings.ReplaceAll(flux, fullMatch, from) case "timeRangeStop": - flux = strings.ReplaceAll(flux, match[0], to) + flux = strings.ReplaceAll(flux, fullMatch, to) case "windowPeriod": - flux = strings.ReplaceAll(flux, match[0], query.Interval.String()) + flux = strings.ReplaceAll(flux, fullMatch, query.Interval.String()) case "bucket": - flux = strings.ReplaceAll(flux, match[0], "\""+query.Options.Bucket+"\"") + // Check if 'bucket' is part of a join query + beforeMatch := rawQuery[:match[0]] + if strings.Contains(beforeMatch, "join.") { + continue + } + flux = strings.ReplaceAll(flux, fullMatch, "\""+query.Options.Bucket+"\"") case "defaultBucket": - flux = strings.ReplaceAll(flux, match[0], "\""+query.Options.DefaultBucket+"\"") + flux = strings.ReplaceAll(flux, fullMatch, "\""+query.Options.DefaultBucket+"\"") case "organization": - flux = strings.ReplaceAll(flux, match[0], "\""+query.Options.Organization+"\"") + flux = strings.ReplaceAll(flux, fullMatch, "\""+query.Options.Organization+"\"") } } } diff --git a/pkg/tsdb/influxdb/flux/macros_test.go b/pkg/tsdb/influxdb/flux/macros_test.go index 9bf0fa3570a..283835ab24e 100644 --- a/pkg/tsdb/influxdb/flux/macros_test.go +++ b/pkg/tsdb/influxdb/flux/macros_test.go @@ -31,6 +31,11 @@ func TestInterpolate(t *testing.T) { before: `v.timeRangeStart, something.timeRangeStop, XYZ.bucket, uuUUu.defaultBucket, aBcDefG.organization, window.windowPeriod, a91{}.bucket, $__interval, $__interval_ms`, after: `2021-09-22T10:12:51.310985041Z, 2021-09-22T11:12:51.310985042Z, "grafana2", "grafana3", "grafana1", 1m1.258s, a91{}.bucket, 1m, 61258`, }, + { + name: "don't interpolate bucket variable in join query", + before: `range(start: v.timeRangeStart, stop: v.timeRangeStop) join.left(left: left |> group(), right: right,on:((l,r) => l.bucket == r.id), as: ((l, r) => ({l with name: r.name})))`, + after: `range(start: 2021-09-22T10:12:51.310985041Z, stop: 2021-09-22T11:12:51.310985042Z) join.left(left: left |> group(), right: right,on:((l,r) => l.bucket == r.id), as: ((l, r) => ({l with name: r.name})))`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index bfb397b514d..4b3e3510266 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -129,6 +129,9 @@ type TempoQuery struct { // Defines the maximum number of spans per spanset that are returned from Tempo Spss *int64 `json:"spss,omitempty"` + // For metric queries, the step size to use + Step *string `json:"step,omitempty"` + // The type of the table that is used to display the search results TableType *SearchTableType `json:"tableType,omitempty"` } diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go index b56aa89b633..152553f1824 100644 --- a/pkg/util/shortid_generator.go +++ b/pkg/util/shortid_generator.go @@ -32,7 +32,6 @@ var mtx sync.Mutex // Legacy UID pattern var validUIDCharPattern = `a-zA-Z0-9\-\_` var validUIDPattern = regexp.MustCompile(`^[` + validUIDCharPattern + `]*$`).MatchString -var validUIDReplacer = regexp.MustCompile(`[^` + validUIDCharPattern + `]`).ReplaceAllString // IsValidShortUID checks if short unique identifier contains valid characters // NOTE: future Grafana UIDs will need conform to https://github.com/kubernetes/apimachinery/blob/master/pkg/util/validation/validation.go#L43 @@ -93,13 +92,3 @@ func ValidateUID(uid string) error { } return nil } - -func AutofixUID(uid string) string { - if IsShortUIDTooLong(uid) { - return uid[:MaxUIDLength] - } - if !IsValidShortUID(uid) { - uid = validUIDReplacer(uid, "-") - } - return uid -} diff --git a/pkg/util/shortid_generator_test.go b/pkg/util/shortid_generator_test.go index ea273522889..6bc4bb05b5c 100644 --- a/pkg/util/shortid_generator_test.go +++ b/pkg/util/shortid_generator_test.go @@ -143,32 +143,3 @@ func TestValidateUID(t *testing.T) { }) } } - -func TestAutofixUID(t *testing.T) { - var tests = []struct { - name string - uid string - expected string - }{ - { - name: "return input when input is valid", - uid: "f8cc010c-ee72-4681-89d2-d46e1bd47d33", - expected: "f8cc010c-ee72-4681-89d2-d46e1bd47d33", - }, - { - name: "generate new uid when input is too long", - uid: strings.Repeat("1", MaxUIDLength+1), - expected: strings.Repeat("1", MaxUIDLength), - }, - { - name: "generate new uid when input has invalid characters", - uid: "f8cc010c.ee72.4681;89d2+d46e1bd47d33", - expected: "f8cc010c-ee72-4681-89d2-d46e1bd47d33", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, AutofixUID(tt.uid)) - }) - } -} diff --git a/pkg/util/uri_sanitize.go b/pkg/util/uri_sanitize.go new file mode 100644 index 00000000000..fdf3e226e04 --- /dev/null +++ b/pkg/util/uri_sanitize.go @@ -0,0 +1,54 @@ +package util + +import ( + "fmt" + "net/url" + "strings" +) + +const masking = "hidden" + +var sensitiveQueryChecks = map[string]func(key string, urlValues url.Values) bool{ + "auth_token": func(key string, urlValues url.Values) bool { + return true + }, + "x-amz-signature": func(key string, urlValues url.Values) bool { + return true + }, + "x-goog-signature": func(key string, urlValues url.Values) bool { + return true + }, + "sig": func(key string, urlValues url.Values) bool { + for k := range urlValues { + if strings.ToLower(k) == "sv" { + return true + } + } + return false + }, +} + +func SanitizeURI(s string) (string, error) { + if s == "" { + return s, nil + } + + u, err := url.ParseRequestURI(s) + if err != nil { + return "", fmt.Errorf("failed to sanitize URL") + } + + // strip out sensitive query strings + urlValues := u.Query() + for key := range urlValues { + lk := strings.ToLower(key) + if checker, ok := sensitiveQueryChecks[lk]; ok { + if checker(key, urlValues) { + urlValues.Set(key, masking) + } + } + } + u.RawQuery = urlValues.Encode() + + return u.String(), nil +} diff --git a/pkg/util/uri_sanitize_test.go b/pkg/util/uri_sanitize_test.go new file mode 100644 index 00000000000..4d67513606c --- /dev/null +++ b/pkg/util/uri_sanitize_test.go @@ -0,0 +1,74 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_sanitizeURI(t *testing.T) { + tests := []struct { + name string + input string + want string + expectError bool + }{ + { + name: "Receiving empty string should return it", + input: "", + want: "", + }, + { + name: "Receiving URL with auth_token should remove it", + input: "https://grafana.com/?auth_token=secret-token&q=1234", + want: "https://grafana.com/?auth_token=hidden&q=1234", + }, + { + name: "Receiving presigned URL from AWS should remove signature", + input: "https://s3.amazonaws.com/finance-department-bucket/2022/tax-certificate.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA3SGQVQG7FGA6KKA6%2F20221104%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20221104T140227Z&X-Amz-Expires=3600&X-Amz-Signature=b22&X-Amz-SignedHeaders=host", + want: "https://s3.amazonaws.com/finance-department-bucket/2022/tax-certificate.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA3SGQVQG7FGA6KKA6%2F20221104%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20221104T140227Z&X-Amz-Expires=3600&X-Amz-Signature=hidden&X-Amz-SignedHeaders=host", + }, + { + name: "Receiving presigned URL from GCP should remove signature", + input: "https://storage.googleapis.com/example-bucket/cat.jpeg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=example%40example-project.iam.gserviceaccount.com%2F20181026%2Fus-central1%2Fstorage%2Fgoog4_request&X-Goog-Date=20181026T181309Z&X-Goog-Expires=900&X-Goog-Signature=247a&X-Goog-SignedHeaders=host", + want: "https://storage.googleapis.com/example-bucket/cat.jpeg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=example%40example-project.iam.gserviceaccount.com%2F20181026%2Fus-central1%2Fstorage%2Fgoog4_request&X-Goog-Date=20181026T181309Z&X-Goog-Expires=900&X-Goog-Signature=hidden&X-Goog-SignedHeaders=host", + }, + { + name: "Receiving presigned URL with lower case query params from GCP should remove signature", + input: "https://storage.googleapis.com/example-bucket/cat.jpeg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=example%40example-project.iam.gserviceaccount.com%2F20181026%2Fus-central1%2Fstorage%2Fgoog4_request&X-Goog-Date=20181026T181309Z&X-Goog-Expires=900&x-goog-signature=247a&X-Goog-SignedHeaders=host", + want: "https://storage.googleapis.com/example-bucket/cat.jpeg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=example%40example-project.iam.gserviceaccount.com%2F20181026%2Fus-central1%2Fstorage%2Fgoog4_request&X-Goog-Date=20181026T181309Z&X-Goog-Expires=900&X-Goog-SignedHeaders=host&x-goog-signature=hidden", + }, + { + name: "Receiving presigned URL from Azure should remove signature", + input: "https://myaccount.queue.core.windows.net/myqueue/messages?se=2015-07-02T08%3A49Z&si=YWJjZGVmZw%3D%3D&sig=jDrr6cna7JPwIaxWfdH0tT5v9dc%3d&sp=p&st=2015-07-01T08%3A49Z&sv=2015-02-21&visibilitytimeout=120", + want: "https://myaccount.queue.core.windows.net/myqueue/messages?se=2015-07-02T08%3A49Z&si=YWJjZGVmZw%3D%3D&sig=hidden&sp=p&st=2015-07-01T08%3A49Z&sv=2015-02-21&visibilitytimeout=120", + }, + { + name: "Receiving presigned URL from Azure with upper case query values should remove signature", + input: "https://myaccount.queue.core.windows.net/myqueue/messages?se=2015-07-02T08%3A49Z&si=YWJjZGVmZw%3D%3D&SIG=jDrr6cna7JPwIaxWfdH0tT5v9dc%3d&sp=p&st=2015-07-01T08%3A49Z&SV=2015-02-21&visibilitytimeout=120", + want: "https://myaccount.queue.core.windows.net/myqueue/messages?SIG=hidden&SV=2015-02-21&se=2015-07-02T08%3A49Z&si=YWJjZGVmZw%3D%3D&sp=p&st=2015-07-01T08%3A49Z&visibilitytimeout=120", + }, + { + name: "Receiving valid URL string should return it parsed", + input: "https://grafana.com/?sig=testing-a-generic-parameter", + want: "https://grafana.com/?sig=testing-a-generic-parameter", + }, + { + name: "Receiving invalid URL string should return empty string", + input: "this is not a valid URL", + want: "", + expectError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, err := SanitizeURI(tt.input) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equalf(t, tt.want, url, "SanitizeURI(%v)", tt.input) + }) + } +} diff --git a/pkg/util/xorm/go.mod b/pkg/util/xorm/go.mod index e981f997db3..45b2e78666a 100644 --- a/pkg/util/xorm/go.mod +++ b/pkg/util/xorm/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/util/xorm go 1.21.10 require ( - github.com/mattn/go-sqlite3 v1.14.19 + github.com/mattn/go-sqlite3 v1.14.22 github.com/stretchr/testify v1.9.0 xorm.io/builder v0.3.6 xorm.io/core v0.7.3 diff --git a/pkg/util/xorm/go.sum b/pkg/util/xorm/go.sum index c003657520e..35ee55d83fe 100644 --- a/pkg/util/xorm/go.sum +++ b/pkg/util/xorm/go.sum @@ -17,8 +17,7 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= -github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index d8f4afb84c7..de6c19c50b7 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -3159,6 +3159,17 @@ } } }, + "CloudMigrationRunListDTO": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrateDataResponseListDTO" + } + } + } + }, "CloudMigrationSessionListResponseDTO": { "type": "object", "properties": { @@ -3632,6 +3643,14 @@ } } }, + "CreateSnapshotResponseDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + } + } + }, "CreateTeamCommand": { "type": "object", "properties": { @@ -4726,6 +4745,45 @@ } ] }, + "GetSnapshotResponseDTO": { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "finished": { + "type": "string", + "format": "date-time" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrateDataResponseItemDTO" + } + }, + "sessionUid": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "INITIALIZING", + "CREATING", + "PENDING_UPLOAD", + "UPLOADING", + "PENDING_PROCESSING", + "PROCESSING", + "FINISHED", + "ERROR", + "UNKNOWN" + ] + }, + "uid": { + "type": "string" + } + } + }, "Hit": { "type": "object", "properties": { @@ -7084,13 +7142,47 @@ "type": "integer", "format": "int64" }, - "SnapshotListDTO": { + "SnapshotDTO": { + "description": "Base snapshot without results", "type": "object", "properties": { - "runs": { + "created": { + "type": "string", + "format": "date-time" + }, + "finished": { + "type": "string", + "format": "date-time" + }, + "sessionUid": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "INITIALIZING", + "CREATING", + "PENDING_UPLOAD", + "UPLOADING", + "PENDING_PROCESSING", + "PROCESSING", + "FINISHED", + "ERROR", + "UNKNOWN" + ] + }, + "uid": { + "type": "string" + } + } + }, + "SnapshotListResponseDTO": { + "type": "object", + "properties": { + "snapshots": { "type": "array", "items": { - "$ref": "#/definitions/MigrateDataResponseListDTO" + "$ref": "#/definitions/SnapshotDTO" } } } @@ -8394,7 +8486,7 @@ "cloudMigrationRunListResponse": { "description": "", "schema": { - "$ref": "#/definitions/SnapshotListDTO" + "$ref": "#/definitions/CloudMigrationRunListDTO" } }, "cloudMigrationRunResponse": { @@ -8559,6 +8651,12 @@ "$ref": "#/definitions/ServiceAccountDTO" } }, + "createSnapshotResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/CreateSnapshotResponseDTO" + } + }, "createTeamResponse": { "description": "", "schema": { @@ -9126,6 +9224,12 @@ } } }, + "getSnapshotResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GetSnapshotResponseDTO" + } + }, "getStatusResponse": { "description": "" }, @@ -9582,6 +9686,12 @@ "$ref": "#/definitions/RoleAssignmentsDTO" } }, + "snapshotListResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/SnapshotListResponseDTO" + } + }, "unauthorisedError": { "description": "UnauthorizedError is returned when the request is not authenticated.", "schema": { diff --git a/public/api-merged.json b/public/api-merged.json index 911dcfe26f1..4a114b3fee3 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -2336,6 +2336,9 @@ "200": { "$ref": "#/responses/cloudMigrationSessionResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -2368,6 +2371,9 @@ "200": { "$ref": "#/responses/cloudMigrationRunResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -2400,6 +2406,9 @@ "200": { "$ref": "#/responses/cloudMigrationSessionResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -2427,6 +2436,9 @@ } ], "responses": { + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -2459,6 +2471,9 @@ "200": { "$ref": "#/responses/cloudMigrationRunListResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -2490,6 +2505,223 @@ "200": { "$ref": "#/responses/cloudMigrationRunResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/cloudmigration/migration/{uid}/snapshot": { + "post": { + "description": "If the snapshot initialization is successful, the snapshot uid is returned.", + "tags": [ + "migrations" + ], + "summary": "Trigger the creation of an instance snapshot associated with the provided session.", + "operationId": "createSnapshot", + "parameters": [ + { + "type": "string", + "description": "UID of a session", + "name": "uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/createSnapshotResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/cloudmigration/migration/{uid}/snapshot/{snapshotUid}": { + "get": { + "tags": [ + "migrations" + ], + "summary": "Get metadata about a snapshot, including where it is in its processing and final results.", + "operationId": "getSnapshot", + "parameters": [ + { + "type": "string", + "description": "Session UID of a session", + "name": "uid", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "UID of a snapshot", + "name": "snapshotUid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/getSnapshotResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/cloudmigration/migration/{uid}/snapshot/{snapshotUid}/cancel": { + "post": { + "description": "TODO: Implement", + "tags": [ + "migrations" + ], + "summary": "Cancel a snapshot, wherever it is in its processing chain.", + "operationId": "cancelSnapshot", + "parameters": [ + { + "type": "string", + "description": "Session UID of a session", + "name": "uid", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "UID of a snapshot", + "name": "snapshotUid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "(empty)" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/cloudmigration/migration/{uid}/snapshot/{snapshotUid}/upload": { + "post": { + "tags": [ + "migrations" + ], + "summary": "Upload a snapshot to the Grafana Migration Service for processing.", + "operationId": "uploadSnapshot", + "parameters": [ + { + "type": "string", + "description": "Session UID of a session", + "name": "uid", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "UID of a snapshot", + "name": "snapshotUid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "(empty)" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/cloudmigration/migration/{uid}/snapshots": { + "get": { + "tags": [ + "migrations" + ], + "summary": "Get a list of snapshots for a session.", + "operationId": "getShapshotList", + "parameters": [ + { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Offset is used for pagination with limit", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": 100, + "description": "Max limit for results returned.", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Session UID of a session", + "name": "uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/snapshotListResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -2569,6 +2801,9 @@ "204": { "$ref": "#/responses/cloudMigrationDeleteTokenResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -13336,6 +13571,17 @@ } } }, + "CloudMigrationRunListDTO": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrateDataResponseListDTO" + } + } + } + }, "CloudMigrationSessionListResponseDTO": { "type": "object", "properties": { @@ -13877,6 +14123,14 @@ } } }, + "CreateSnapshotResponseDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + } + } + }, "CreateTeamCommand": { "type": "object", "properties": { @@ -15270,6 +15524,45 @@ } ] }, + "GetSnapshotResponseDTO": { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "finished": { + "type": "string", + "format": "date-time" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrateDataResponseItemDTO" + } + }, + "sessionUid": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "INITIALIZING", + "CREATING", + "PENDING_UPLOAD", + "UPLOADING", + "PENDING_PROCESSING", + "PROCESSING", + "FINISHED", + "ERROR", + "UNKNOWN" + ] + }, + "uid": { + "type": "string" + } + } + }, "GettableAlertmanagers": { "type": "object", "properties": { @@ -19985,13 +20278,47 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, - "SnapshotListDTO": { + "SnapshotDTO": { + "description": "Base snapshot without results", "type": "object", "properties": { - "runs": { + "created": { + "type": "string", + "format": "date-time" + }, + "finished": { + "type": "string", + "format": "date-time" + }, + "sessionUid": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "INITIALIZING", + "CREATING", + "PENDING_UPLOAD", + "UPLOADING", + "PENDING_PROCESSING", + "PROCESSING", + "FINISHED", + "ERROR", + "UNKNOWN" + ] + }, + "uid": { + "type": "string" + } + } + }, + "SnapshotListResponseDTO": { + "type": "object", + "properties": { + "snapshots": { "type": "array", "items": { - "$ref": "#/definitions/MigrateDataResponseListDTO" + "$ref": "#/definitions/SnapshotDTO" } } } @@ -22434,7 +22761,7 @@ "cloudMigrationRunListResponse": { "description": "(empty)", "schema": { - "$ref": "#/definitions/SnapshotListDTO" + "$ref": "#/definitions/CloudMigrationRunListDTO" } }, "cloudMigrationRunResponse": { @@ -22599,6 +22926,12 @@ "$ref": "#/definitions/ServiceAccountDTO" } }, + "createSnapshotResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/CreateSnapshotResponseDTO" + } + }, "createTeamResponse": { "description": "(empty)", "schema": { @@ -23166,6 +23499,12 @@ } } }, + "getSnapshotResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/GetSnapshotResponseDTO" + } + }, "getStatusResponse": { "description": "(empty)" }, @@ -23631,6 +23970,12 @@ "$ref": "#/definitions/RoleAssignmentsDTO" } }, + "snapshotListResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/SnapshotListResponseDTO" + } + }, "unauthorisedError": { "description": "UnauthorizedError is returned when the request is not authenticated.", "schema": { diff --git a/public/app/core/components/PageNotFound/EntityNotFound.tsx b/public/app/core/components/PageNotFound/EntityNotFound.tsx index 24edfa76f5c..c749d30aa19 100644 --- a/public/app/core/components/PageNotFound/EntityNotFound.tsx +++ b/public/app/core/components/PageNotFound/EntityNotFound.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { EmptyState, TextLink, useStyles2 } from '@grafana/ui'; export interface Props { @@ -15,7 +16,7 @@ export function EntityNotFound({ entity = 'Page' }: Props) { const styles = useStyles2(getStyles); return ( -
+
We're looking but can't seem to find this {entity.toLowerCase()}. Try returning{' '} home or seeking help on the{' '} diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index a241519fed4..f4ab99631cc 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -5,7 +5,7 @@ export const ENGLISH_US = 'en-US'; export const FRENCH_FRANCE = 'fr-FR'; export const SPANISH_SPAIN = 'es-ES'; export const GERMAN_GERMANY = 'de-DE'; -export const BRAZILIAN_PORTUGUESE = 'pt-br'; +export const BRAZILIAN_PORTUGUESE = 'pt-BR'; export const CHINESE_SIMPLIFIED = 'zh-Hans'; export const PSEUDO_LOCALE = 'pseudo-LOCALE'; diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts new file mode 100644 index 00000000000..429db533901 --- /dev/null +++ b/public/app/core/internationalization/dates.ts @@ -0,0 +1,20 @@ +import '@formatjs/intl-durationformat/polyfill'; + +import { getI18next } from './index'; + +export function formatDate(value: number | Date | string, format: Intl.DateTimeFormatOptions = {}): string { + if (typeof value === 'string') { + return formatDate(new Date(value), format); + } + + const i18n = getI18next(); + const dateFormatter = new Intl.DateTimeFormat(i18n.language, format); + return dateFormatter.format(value); +} + +export function formatDuration(duration: Intl.DurationInput, options: Intl.DurationFormatOptions = {}) { + const i18n = getI18next(); + + const dateFormatter = new Intl.DurationFormat(i18n.language, options); + return dateFormatter.format(duration); +} diff --git a/public/app/core/internationalization/index.tsx b/public/app/core/internationalization/index.tsx index 812fc4f83b7..f111be88b98 100644 --- a/public/app/core/internationalization/index.tsx +++ b/public/app/core/internationalization/index.tsx @@ -7,6 +7,7 @@ import { DEFAULT_LANGUAGE, NAMESPACES, VALID_LANGUAGES } from './constants'; import { loadTranslations } from './loadTranslations'; let tFunc: TFunction | undefined; +let i18nInstance: typeof i18n; export async function initializeI18n(language: string): Promise<{ language: string | undefined }> { // This is a placeholder so we can put a 'comment' in the message json files. @@ -28,7 +29,7 @@ export async function initializeI18n(language: string): Promise<{ language: stri ns: NAMESPACES, }; - let i18nInstance = i18n; + i18nInstance = i18n; if (language === 'detect') { i18nInstance = i18nInstance.use(LanguageDetector); const detection: DetectorOptions = { order: ['navigator'], caches: [] }; @@ -79,10 +80,20 @@ export const t = (id: string, defaultMessage: string, values?: Record { - if (typeof value === 'string') { - return i18nDate(new Date(value), format); +export function getI18next() { + if (!tFunc) { + if (process.env.NODE_ENV !== 'test') { + console.warn( + 'An attempt to internationalize was made before it was initialized. This was probably caused by calling a locale-aware function in the root module scope, instead of in render' + ); + } + + if (process.env.NODE_ENV === 'development') { + throw new Error('getI18next was called before i18n was initialized'); + } + + return i18n; } - const dateFormatter = new Intl.DateTimeFormat(i18n.language, format); - return dateFormatter.format(value); -}; + + return i18nInstance || i18n; +} diff --git a/public/app/core/internationalization/types.d.ts b/public/app/core/internationalization/types.d.ts new file mode 100644 index 00000000000..7a4ac64930c --- /dev/null +++ b/public/app/core/internationalization/types.d.ts @@ -0,0 +1,13 @@ +import { + DurationFormatConstructor, + DurationFormatOptions as _DurationFormatOptions, + DurationInput as _DurationInput, +} from '@formatjs/intl-durationformat/src/types'; + +declare global { + namespace Intl { + const DurationFormat: DurationFormatConstructor; + type DurationFormatOptions = _DurationFormatOptions; + type DurationInput = _DurationInput; + } +} diff --git a/public/app/core/services/echo/backends/PerformanceBackend.ts b/public/app/core/services/echo/backends/PerformanceBackend.ts index 29ff37882bc..7fd3a18d572 100644 --- a/public/app/core/services/echo/backends/PerformanceBackend.ts +++ b/public/app/core/services/echo/backends/PerformanceBackend.ts @@ -32,9 +32,17 @@ export class PerformanceBackend implements EchoBackend { + // Just swallow this error - it's non-critical + }); this.buffer = []; }; diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 6dcaa0ebf74..b79abe733fd 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -137,7 +137,7 @@ export function getNavTitle(navId: string | undefined) { case 'testing-and-synthetics': return t('nav.testing-and-synthetics.title', 'Testing & synthetics'); case 'plugin-page-grafana-incident-app': - return t('nav.incidents.title', 'Incidents'); + return t('nav.incidents.title', 'Incident'); case 'plugin-page-grafana-ml-app': return t('nav.machine-learning.title', 'Machine learning'); case 'plugin-page-grafana-slo-app': diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx index 465072ba34d..91f8694998b 100644 --- a/public/app/features/admin/UserSessions.tsx +++ b/public/app/features/admin/UserSessions.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { ConfirmButton, ConfirmModal, Button, Stack } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; -import { i18nDate } from 'app/core/internationalization'; +import { formatDate } from 'app/core/internationalization/dates'; import { AccessControlAction, UserSession } from 'app/types'; interface Props { @@ -68,7 +68,7 @@ class BaseUserSessions extends PureComponent { sessions.map((session, index) => ( {session.isActive ? 'Now' : session.seenAt} - {i18nDate(session.createdAt, { dateStyle: 'long' })} + {formatDate(session.createdAt, { dateStyle: 'long' })} {session.clientIp} {`${session.browser} on ${session.os} ${session.osVersion}`} diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 97bb8b7eece..4c3b78d2cbb 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -689,7 +689,7 @@ describe('RuleList', () => { expect(alertsInReorder).toHaveLength(2); }); - describe('pausing rules', () => { + describe.skip('pausing rules', () => { beforeEach(() => { grantUserPermissions([ AccessControlAction.AlertingRuleRead, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index b19048af260..2192349a49c 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -8,11 +8,9 @@ import { Annotations, GrafanaAlertStateDecision, Labels, - PostableRuleGrafanaRuleDTO, + PostableRulerRuleGroupDTO, PromRulesResponse, - RulerAlertingRuleDTO, RulerGrafanaRuleDTO, - RulerRecordingRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; @@ -77,14 +75,7 @@ interface ExportRulesParams { ruleUid?: string; } -export interface ModifyExportPayload { - rules: Array; - name: string; - interval?: string | undefined; - source_tenants?: string[] | undefined; -} - -export interface AlertRuleUpdated { +export interface AlertGroupUpdated { message: string; /** * UIDs of rules updated from this request @@ -220,7 +211,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), // TODO This should be probably a separate ruler API file - rulerRuleGroup: build.query< + getRuleGroupForNamespace: build.query< RulerRuleGroupDTO, { rulerConfig: RulerDataSourceConfig; namespace: string; group: string } >({ @@ -231,6 +222,17 @@ export const alertRuleApi = alertingApi.injectEndpoints({ providesTags: ['CombinedAlertRule'], }), + deleteRuleGroupFromNamespace: build.mutation< + RulerRuleGroupDTO, + { rulerConfig: RulerDataSourceConfig; namespace: string; group: string } + >({ + query: ({ rulerConfig, namespace, group }) => { + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); + return { url: path, params, method: 'DELETE' }; + }, + invalidatesTags: ['CombinedAlertRule'], + }), + getAlertRule: build.query({ // TODO: In future, if supported in other rulers, parametrize ruler source name // For now, to make the consumption of this hook clearer, only support Grafana ruler @@ -272,7 +274,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), exportModifiedRuleGroup: build.mutation< string, - { payload: ModifyExportPayload; format: ExportFormats; nameSpaceUID: string } + { payload: PostableRulerRuleGroupDTO; format: ExportFormats; nameSpaceUID: string } >({ query: ({ payload, format, nameSpaceUID }) => ({ url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/export/`, @@ -298,13 +300,20 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), keepUnusedDataFor: 0, }), + updateRuleGroupForNamespace: build.mutation< + AlertGroupUpdated, + { rulerConfig: RulerDataSourceConfig; namespace: string; payload: PostableRulerRuleGroupDTO } + >({ + query: ({ payload, namespace, rulerConfig }) => { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); - updateRule: build.mutation({ - query: ({ payload, nameSpaceUID }) => ({ - url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/`, - data: payload, - method: 'POST', - }), + return { + url: path, + params, + data: payload, + method: 'POST', + }; + }, invalidatesTags: ['CombinedAlertRule'], }), }), diff --git a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx index 6f312ef9f01..f520b6a1ef2 100644 --- a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx +++ b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx @@ -1,13 +1,16 @@ -import { produce } from 'immer'; import React from 'react'; import { Menu } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; -import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules'; +import { + isGrafanaRulerRule, + isGrafanaRulerRulePaused, + getRuleGroupLocationFromCombinedRule, +} from 'app/features/alerting/unified/utils/rules'; import { CombinedRule } from 'app/types/unified-alerting'; -import { grafanaRulerConfig } from '../hooks/useCombinedRule'; +import { usePauseRuleInGroup } from '../hooks/useProduceNewRuleGroup'; +import { stringifyErrorLike } from '../utils/misc'; interface Props { rule: CombinedRule; @@ -22,12 +25,9 @@ interface Props { * and triggering API call to do so */ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { - // we need to fetch the group again, as maybe the group has been filtered - const [getGroup] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); const notifyApp = useAppNotification(); + const [pauseRule, updateState] = usePauseRuleInGroup(); - // Add any dependencies here - const [updateRule] = alertRuleApi.endpoints.updateRule.useMutation(); const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); const icon = isPaused ? 'play' : 'pause'; const title = isPaused ? 'Resume evaluation' : 'Pause evaluation'; @@ -39,41 +39,17 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { if (!isGrafanaRulerRule(rule.rulerRule)) { return; } - const ruleUid = rule.rulerRule.grafana_alert.uid; - const targetGroup = await getGroup({ - rulerConfig: grafanaRulerConfig, - namespace: rule.namespace.uid || rule.rulerRule.grafana_alert.namespace_uid, - group: rule.group.name, - }).unwrap(); - if (!targetGroup) { - notifyApp.error( - `Failed to ${newIsPaused ? 'pause' : 'resume'} the rule. Could not get the target group to update the rule.` - ); + try { + const ruleGroupId = getRuleGroupLocationFromCombinedRule(rule); + const ruleUID = rule.rulerRule.grafana_alert.uid; + + await pauseRule(ruleGroupId, ruleUID, newIsPaused); + } catch (error) { + notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`); return; } - // Parse the rules into correct format for API - const modifiedRules = targetGroup.rules.map((groupRule) => { - if (!(isGrafanaRulerRule(groupRule) && groupRule.grafana_alert.uid === ruleUid)) { - return groupRule; - } - return produce(groupRule, (updatedGroupRule) => { - updatedGroupRule.grafana_alert.is_paused = newIsPaused; - }); - }); - - const payload = { - interval: targetGroup.interval!, - name: targetGroup.name, - rules: modifiedRules, - }; - - await updateRule({ - nameSpaceUID: rule.namespace.uid || rule.rulerRule.grafana_alert.namespace_uid, - payload, - }).unwrap(); - onPauseChange?.(); }; @@ -81,6 +57,7 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { { setRulePause(!isPaused); }} diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index 8fe4f17e61e..1eb47c7ae96 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -171,7 +171,7 @@ describe('contact points', () => { } // check buttons in Notification Templates - const notificationTemplatesTab = screen.getByRole('tab', { name: 'Tab Notification Templates' }); + const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' }); await userEvent.click(notificationTemplatesTab); expect(screen.getByRole('link', { name: 'Add notification template' })).toHaveAttribute('aria-disabled', 'true'); }); @@ -388,7 +388,7 @@ describe('contact points', () => { expect(viewProvisioned).not.toBeDisabled(); // check buttons in Notification Templates - const notificationTemplatesTab = screen.getByRole('tab', { name: 'Tab Notification Templates' }); + const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' }); await userEvent.click(notificationTemplatesTab); expect(screen.queryByRole('link', { name: 'Add notification template' })).not.toBeInTheDocument(); }); diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index 14a5db7e24b..8a9050c6355 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { sortBy } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useFormContext, FieldErrors, FieldValues, Controller } from 'react-hook-form'; +import { Controller, FieldErrors, FieldValues, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Alert, Button, Field, Select, useStyles2 } from '@grafana/ui'; @@ -53,6 +53,7 @@ export function ChannelSubForm({ const { control, watch, register, trigger, formState, setValue } = useFormContext(); const selectedType = watch(fieldName('type')) ?? defaultValues.type; // nope, setting "default" does not work at all. + const parse_mode = watch(fieldName('settings.parse_mode')); const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers); // TODO I don't like integration specific code here but other ways require a bigger refactoring @@ -122,13 +123,14 @@ export function ChannelSubForm({ }; const notifier = notifiers.find(({ dto: { type } }) => type === selectedType); + const isTelegram = selectedType === 'telegram'; + const isParseModeNone = parse_mode === 'None'; // if there are mandatory options defined, optional options will be hidden by a collapse // if there aren't mandatory options, all options will be shown without collapse const mandatoryOptions = notifier?.dto.options.filter((o) => o.required); const optionalOptions = notifier?.dto.options.filter((o) => !o.required); const contactPointTypeInputId = `contact-point-type-${pathPrefix}`; - return (
@@ -188,6 +190,14 @@ export function ChannelSubForm({
{notifier && (
+ {isTelegram && !isParseModeNone && ( + + )} defaultValues={defaultValues} selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 1753714d312..6b60c525c7c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -4,7 +4,7 @@ import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-h import { Link, useParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { useAppNotification } from 'app/core/copy/appNotification'; @@ -12,7 +12,11 @@ import { contextSrv } from 'app/core/core'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; -import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules'; +import { + getRuleGroupLocationFromRuleWithLocation, + isGrafanaRulerRule, + isGrafanaRulerRulePaused, +} from 'app/features/alerting/unified/utils/rules'; import { useDispatch } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; @@ -23,8 +27,9 @@ import { trackAlertRuleFormCancelled, trackAlertRuleFormSaved, } from '../../../Analytics'; +import { useDeleteRuleFromGroup } from '../../../hooks/useProduceNewRuleGroup'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; -import { deleteRuleAction, saveRuleFormAction } from '../../../state/actions'; +import { saveRuleFormAction } from '../../../state/actions'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { initialAsyncRequestState } from '../../../utils/redux'; import { @@ -36,7 +41,6 @@ import { ignoreHiddenQueries, normalizeDefaultAnnotations, } from '../../../utils/rule-form'; -import * as ruleId from '../../../utils/rule-id'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameInput } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -60,6 +64,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { const [queryParams] = useQueryParams(); const [showEditYaml, setShowEditYaml] = useState(false); const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); + const [deleteRuleFromGroup, _deleteRuleState] = useDeleteRuleFromGroup(); const routeParams = useParams<{ type: string; id: string }>(); const ruleType = translateRouteParamToRuleType(routeParams.type); @@ -151,16 +156,12 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { ); }; - const deleteRule = () => { + const deleteRule = async () => { if (existing) { - const identifier = ruleId.fromRulerRule( - existing.ruleSourceName, - existing.namespace, - existing.group.name, - existing.rule - ); + const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing); - dispatch(deleteRuleAction(identifier, { navigateTo: '/alerting/list' })); + await deleteRuleFromGroup(ruleGroupIdentifier, existing.rule); + locationService.replace(returnTo); } }; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index e8490cd7e7f..29b118a52ac 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -7,8 +7,12 @@ import { useAppNotification } from 'app/core/copy/appNotification'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate'; -import { RulerRuleDTO, RulerRuleGroupDTO } from '../../../../../../types/unified-alerting-dto'; -import { alertRuleApi, ModifyExportPayload } from '../../../api/alertRuleApi'; +import { + PostableRulerRuleGroupDTO, + RulerRuleDTO, + RulerRuleGroupDTO, +} from '../../../../../../types/unified-alerting-dto'; +import { alertRuleApi } from '../../../api/alertRuleApi'; import { fetchRulerRulesGroup } from '../../../api/ruler'; import { useDataSourceFeatures } from '../../../hooks/useCombinedRule'; import { RuleFormValues } from '../../../types/rule-form'; @@ -133,7 +137,7 @@ export const getPayloadToExport = ( uid: string, formValues: RuleFormValues, existingGroup: RulerRuleGroupDTO | null | undefined -): ModifyExportPayload => { +): PostableRulerRuleGroupDTO => { const grafanaRuleDto = formValuesToRulerGrafanaRuleDTO(formValues); const updatedRule = { ...grafanaRuleDto, grafana_alert: { ...grafanaRuleDto.grafana_alert, uid: uid } }; @@ -167,7 +171,7 @@ export const getPayloadToExport = ( const useGetPayloadToExport = (values: RuleFormValues, uid: string) => { const rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group); - const payload: ModifyExportPayload = useMemo(() => { + const payload: PostableRulerRuleGroupDTO = useMemo(() => { return getPayloadToExport(uid, values, rulerGroupDto?.value); }, [uid, rulerGroupDto, values]); return { payload, loadingGroup: rulerGroupDto.loading }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index ef5c1cdac4a..5e234b9e1fa 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -1,17 +1,19 @@ import React, { useState, useCallback, useMemo } from 'react'; +import { locationService } from '@grafana/runtime'; import { ConfirmModal } from '@grafana/ui'; import { dispatch } from 'app/store/store'; import { CombinedRule } from 'app/types/unified-alerting'; -import { deleteRuleAction } from '../../state/actions'; -import { getRulesSourceName } from '../../utils/datasource'; -import { fromRulerRule } from '../../utils/rule-id'; +import { useDeleteRuleFromGroup } from '../../hooks/useProduceNewRuleGroup'; +import { fetchPromAndRulerRulesAction } from '../../state/actions'; +import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules'; type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; -export const useDeleteModal = (): DeleteModalHook => { +export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { const [ruleToDelete, setRuleToDelete] = useState(); + const [deleteRuleFromGroup, _deleteState] = useDeleteRuleFromGroup(); const dismissModal = useCallback(() => { setRuleToDelete(undefined); @@ -22,20 +24,25 @@ export const useDeleteModal = (): DeleteModalHook => { }, []); const deleteRule = useCallback( - (ruleToDelete?: CombinedRule) => { - if (ruleToDelete && ruleToDelete.rulerRule) { - const identifier = fromRulerRule( - getRulesSourceName(ruleToDelete.namespace.rulesSource), - ruleToDelete.namespace.name, - ruleToDelete.group.name, - ruleToDelete.rulerRule - ); + async (rule?: CombinedRule) => { + if (!rule?.rulerRule) { + return; + } - dispatch(deleteRuleAction(identifier, { navigateTo: '/alerting/list' })); - dismissModal(); + const location = getRuleGroupLocationFromCombinedRule(rule); + await deleteRuleFromGroup(location, rule.rulerRule); + + // refetch rules for this rules source + // @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags + dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: location.dataSourceName })); + + dismissModal(); + + if (redirectToListView) { + locationService.replace('/alerting/list'); } }, - [dismissModal] + [deleteRuleFromGroup, dismissModal, redirectToListView] ); const modal = useMemo( diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 662c582d1cd..70f2e4cc730 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -338,7 +338,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { - {checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( + {/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */} + {isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( )} diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx index 766a258f4e2..ee989f9fdf9 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx @@ -1,7 +1,7 @@ import { produce } from 'immer'; import React from 'react'; import { render, screen, userEvent } from 'test/test-utils'; -import { byLabelText } from 'testing-library-selector'; +import { byLabelText, byRole } from 'testing-library-selector'; import { config, setPluginExtensionsHook } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; @@ -24,7 +24,9 @@ jest.mock('app/core/services/context_srv'); const mockContextSrv = jest.mocked(contextSrv); const ui = { + menu: byRole('menu'), moreButton: byLabelText(/More/), + pauseButton: byRole('menuitem', { name: /Pause evaluation/ }), }; const grantAllPermissions = () => { @@ -76,6 +78,19 @@ describe('RuleActionsButtons', () => { expect(await getMenuContents()).toMatchSnapshot(); }); + it('should be able to pause a Grafana rule', async () => { + const user = userEvent.setup(); + grantAllPermissions(); + const mockRule = getGrafanaRule(); + + render(); + + await user.click(await ui.moreButton.find()); + await user.click(await ui.pauseButton.find()); + + expect(ui.menu.query()).not.toBeInTheDocument(); + }); + it('renders correct options for Cloud rule', async () => { const user = userEvent.setup(); grantAllPermissions(); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index d907fe019e8..08d4c84ef61 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -44,7 +44,9 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton const dispatch = useDispatch(); const location = useLocation(); const style = useStyles2(getStyles); - const [deleteModal, showDeleteModal] = useDeleteModal(); + + const redirectToListView = compact ? false : true; + const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView); const [showSilenceDrawer, setShowSilenceDrawer] = useState(false); diff --git a/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx b/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx index 9cd82bc4187..99ee72c4b85 100644 --- a/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx @@ -7,6 +7,7 @@ import { Icon, Tooltip, useStyles2 } from '@grafana/ui/src'; import { CombinedRule } from '../../../../../types/unified-alerting'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; +import { isGrafanaRulerRule } from '../../utils/rules'; interface RuleConfigStatusProps { rule: CombinedRule; @@ -14,11 +15,11 @@ interface RuleConfigStatusProps { export function RuleConfigStatus({ rule }: RuleConfigStatusProps) { const styles = useStyles2(getStyles); + const isGrafanaManagedRule = isGrafanaRulerRule(rule.rulerRule); - const { exceedsLimit } = useMemo( - () => checkEvaluationIntervalGlobalLimit(rule.group.interval), - [rule.group.interval] - ); + const exceedsLimit = useMemo(() => { + return isGrafanaManagedRule ? checkEvaluationIntervalGlobalLimit(rule.group.interval).exceedsLimit : false; + }, [rule.group.interval, isGrafanaManagedRule]); if (!exceedsLimit) { return null; diff --git a/public/app/features/alerting/unified/components/rules/RuleStats.test.tsx b/public/app/features/alerting/unified/components/rules/RuleStats.test.tsx new file mode 100644 index 00000000000..1292fdbd57a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleStats.test.tsx @@ -0,0 +1,69 @@ +import { totalFromStats } from './RuleStats'; + +describe('RuleStats', () => { + it('should count 0', () => { + expect( + totalFromStats({ + alerting: 0, + error: 0, + inactive: 0, + nodata: 0, + paused: 0, + pending: 0, + recording: 0, + }) + ).toBe(0); + }); + + it('should count rules', () => { + expect( + totalFromStats({ + alerting: 2, + error: 0, + inactive: 0, + nodata: 0, + paused: 0, + pending: 2, + recording: 2, + }) + ).toBe(6); + }); + + it('should not count rule health as a rule', () => { + expect( + totalFromStats({ + alerting: 0, + error: 1, + inactive: 1, + nodata: 0, + paused: 0, + pending: 0, + recording: 0, + }) + ).toBe(1); + + expect( + totalFromStats({ + alerting: 0, + error: 0, + inactive: 0, + nodata: 1, + paused: 0, + pending: 0, + recording: 1, + }) + ).toBe(1); + + expect( + totalFromStats({ + alerting: 0, + error: 0, + inactive: 1, + nodata: 0, + paused: 1, + pending: 0, + recording: 0, + }) + ).toBe(1); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleStats.tsx b/public/app/features/alerting/unified/components/rules/RuleStats.tsx index f9d67ac072b..bde656c18b9 100644 --- a/public/app/features/alerting/unified/components/rules/RuleStats.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleStats.tsx @@ -1,4 +1,4 @@ -import { isUndefined, omitBy, sum } from 'lodash'; +import { isUndefined, omitBy, pick, sum } from 'lodash'; import pluralize from 'pluralize'; import React, { Fragment } from 'react'; @@ -27,24 +27,12 @@ const emptyStats: Required = { }; export const RuleStats = ({ namespaces }: Props) => { - const stats = { ...emptyStats }; - - // sum all totals for all namespaces - namespaces.forEach(({ groups }) => { - groups.forEach((group) => { - const groupTotals = omitBy(group.totals, isUndefined); - for (let key in groupTotals) { - // @ts-ignore - stats[key] += groupTotals[key]; - } - }); - }); + const stats = statsFromNamespaces(namespaces); + const total = totalFromStats(stats); const statsComponents = getComponentsFromStats(stats); const hasStats = Boolean(statsComponents.length); - const total = sum(Object.values(stats)); - statsComponents.unshift( {total} {pluralize('rule', total)} @@ -66,6 +54,32 @@ interface RuleGroupStatsProps { group: CombinedRuleGroup; } +function statsFromNamespaces(namespaces: CombinedRuleNamespace[]): AlertGroupTotals { + const stats = { ...emptyStats }; + + // sum all totals for all namespaces + namespaces.forEach(({ groups }) => { + groups.forEach((group) => { + const groupTotals = omitBy(group.totals, isUndefined); + for (let key in groupTotals) { + // @ts-ignore + stats[key] += groupTotals[key]; + } + }); + }); + + return stats; +} + +export function totalFromStats(stats: AlertGroupTotals): number { + // countable stats will pick only the states that indicate a single rule – health indicators like "error" and "nodata" should + // not be counted because they are already counted by their state + const countableStats = pick(stats, ['alerting', 'pending', 'inactive', 'recording']); + const total = sum(Object.values(countableStats)); + + return total; +} + export const RuleGroupStats = ({ group }: RuleGroupStatsProps) => { const stats = group.totals; const evaluationInterval = group?.interval; diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap new file mode 100644 index 00000000000..44ab41ab030 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap @@ -0,0 +1,243 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`delete rule should be able to delete a Data source managed rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/mockCombinedNamespace/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": { + "name": "group-1", + "rules": [ + { + "alert": "r1", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "foo": "bar", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/mockCombinedNamespace?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/mockCombinedNamespace/mockCombinedRuleGroup?subtype=cortex", + }, +] +`; + +exports[`delete rule should be able to delete a Grafana managed rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/NAMESPACE_UID/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": { + "name": "group-1", + "rules": [ + { + "annotations": {}, + "for": "", + "grafana_alert": { + "condition": "", + "data": [], + "exec_err_state": "Error", + "namespace_uid": "NAMESPACE_UID", + "no_data_state": "NoData", + "rule_group": "my-group", + "title": "my rule", + "uid": "r1", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/NAMESPACE_UID?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/NAMESPACE_UID/mockCombinedRuleGroup?subtype=cortex", + }, +] +`; + +exports[`delete rule should delete the entire group if no more rules are left 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/mockCombinedRuleGroup?subtype=cortex", + }, +] +`; + +exports[`pause rule should be able to pause a rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex", + }, + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": true, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 705ee589e62..e4afe097cab 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -80,7 +80,7 @@ export function useCloudCombinedRulesMatching( groupName: filter?.groupName, }); - const [fetchRulerRuleGroup] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + const [fetchRulerRuleGroup] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery(); const { loading, error, value } = useAsync(async () => { if (!dsSettings) { @@ -210,7 +210,7 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti error: rulerRuleGroupError, isUninitialized: rulerRuleGroupUninitialized, }, - ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + ] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery(); useEffect(() => { if (!dsFeatures?.rulerConfig || !ruleLocation) { @@ -345,7 +345,7 @@ export function useRuleWithLocation({ isUninitialized: isUninitializedRulerGroup, error: rulerRuleGroupError, }, - ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + ] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery(); useEffect(() => { if (!dsFeatures?.rulerConfig || !ruleLocation) { diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts index b102a5b20c6..db277cea39d 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts @@ -256,4 +256,26 @@ describe('filterRules', function () { expect(filtered[0]?.groups[0]?.rules).toHaveLength(1); expect(filtered[0]?.groups[0]?.rules[0]?.name).toBe('CPU too high'); }); + + it('does not crash when trying to filter with regex-like strings', () => { + const rules = [mockCombinedRule({ name: '[alongnameinthefirstgroup]' })]; + + const ns = mockCombinedRuleNamespace({ + name: 'foo|bar', + groups: [ + // Create group with regex-like name so we can test that searching for it doesn't crash, + // and so we can test further paths of the filtering + // (we need some a group to be matched so we can test filtering by rule name as well) + mockCombinedRuleGroup('some|group', rules), + ], + }); + + const ruleQuery = '[alongnameinthefirstgroup][thishas spaces][somethingelse]'; + const namespaceQuery = 'foo|bar'; + const groupQuery = 'some|group'; + + const performFilter = () => + filterRules([ns], getFilter({ groupName: groupQuery, ruleName: ruleQuery, namespace: namespaceQuery })); + expect(performFilter).not.toThrow(); + }); }); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 3ceacb11b07..eaa13b43840 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -28,6 +28,19 @@ import { useURLSearchParams } from './useURLSearchParams'; const MAX_NEEDLE_SIZE = 25; const INFO_THRESHOLD = Infinity; +/** + * Escape query strings so that regex characters don't interfere + * with uFuzzy search methods. + * + * The fuzzy searching will take the query and generate a regex - but if the query + * contains a regex itself, then it can easily end up being split in a bad place + * and end up creating an invalid expression + */ +const escapeQueryRegex = (query: string) => { + // see https://stackoverflow.com/a/6969486 + return query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + export function useRulesFilter() { const [queryParams, updateQueryParams] = useURLSearchParams(); const searchQuery = queryParams.get('search') ?? ''; @@ -130,10 +143,12 @@ export const filterRules = ( if (namespaceFilter) { const namespaceHaystack = filteredNamespaces.map((ns) => ns.name); + const escapedQuery = escapeQueryRegex(namespaceFilter); + const ufuzzy = getSearchInstance(namespaceFilter); const [idxs, info, order] = ufuzzy.search( namespaceHaystack, - namespaceFilter, + escapedQuery, getOutOfOrderLimit(namespaceFilter), INFO_THRESHOLD ); @@ -157,9 +172,11 @@ const reduceNamespaces = (filterState: RulesFilter) => { const groupsHaystack = filteredGroups.map((g) => g.name); const ufuzzy = getSearchInstance(groupNameFilter); + const escapedQuery = escapeQueryRegex(groupNameFilter); + const [idxs, info, order] = ufuzzy.search( groupsHaystack, - groupNameFilter, + escapedQuery, getOutOfOrderLimit(groupNameFilter), INFO_THRESHOLD ); @@ -193,10 +210,11 @@ const reduceGroups = (filterState: RulesFilter) => { if (ruleNameQuery) { const rulesHaystack = filteredRules.map((r) => r.name); const ufuzzy = getSearchInstance(ruleNameQuery); + const escapedQuery = escapeQueryRegex(ruleNameQuery); const [idxs, info, order] = ufuzzy.search( rulesHaystack, - ruleNameQuery, + escapedQuery, getOutOfOrderLimit(ruleNameQuery), INFO_THRESHOLD ); diff --git a/public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx new file mode 100644 index 00000000000..c8c6ec7cf92 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx @@ -0,0 +1,235 @@ +import { HttpResponse } from 'msw'; +import React from 'react'; +import { render, userEvent } from 'test/test-utils'; +import { byRole, byText } from 'testing-library-selector'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { setupMswServer } from '../mockApi'; +import { + mockCombinedRule, + mockCombinedRuleGroup, + mockGrafanaRulerRule, + mockRulerAlertingRule, + mockRulerRecordingRule, + mockRulerRuleGroup, +} from '../mocks'; +import { grafanaRulerGroupName, grafanaRulerNamespace, grafanaRulerRule } from '../mocks/alertRuleApi'; +import { setRulerRuleGroupHandler, setUpdateRulerRuleNamespaceHandler } from '../mocks/server/configure'; +import { captureRequests, serializeRequests } from '../mocks/server/events'; +import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../mocks/server/handlers/alertRules'; +import { stringifyErrorLike } from '../utils/misc'; +import { getRuleGroupLocationFromCombinedRule } from '../utils/rules'; + +import { useDeleteRuleFromGroup, usePauseRuleInGroup } from './useProduceNewRuleGroup'; + +const server = setupMswServer(); + +beforeAll(() => { + setBackendSrv(backendSrv); +}); + +describe('pause rule', () => { + it('should be able to pause a rule', async () => { + const capture = captureRequests(); + setUpdateRulerRuleNamespaceHandler({ delay: 0 }); + + render(); + expect(byText(/uninitialized/i).get()).toBeInTheDocument(); + + await userEvent.click(byRole('button').get()); + expect(await byText(/loading/i).find()).toBeInTheDocument(); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + expect(await byText(/result/i).find()).toBeInTheDocument(); + expect(byText(/error/i).query()).not.toBeInTheDocument(); + + const requests = await capture; + const [get, update, ...rest] = await serializeRequests(requests); + + expect(update.body).toHaveProperty('rules[0].grafana_alert.is_paused', true); + expect([get, update, ...rest]).toMatchSnapshot(); + }); + + it('should throw if the rule is not found in the group', async () => { + setUpdateRulerRuleNamespaceHandler(); + render( + + ); + expect(byText(/uninitialized/i).get()).toBeInTheDocument(); + + await userEvent.click(byRole('button').get()); + expect(await byText(/error: No rule with UID/i).find()).toBeInTheDocument(); + }); + + it('should be able to handle error', async () => { + setUpdateRulerRuleNamespaceHandler({ + delay: 0, + response: new HttpResponse('oops', { status: 500 }), + }); + + render(); + + expect(await byText(/uninitialized/i).find()).toBeInTheDocument(); + + await userEvent.click(byRole('button').get()); + expect(await byText(/loading/i).find()).toBeInTheDocument(); + expect(byText(/success/i).query()).not.toBeInTheDocument(); + expect(await byText(/error: oops/i).find()).toBeInTheDocument(); + }); +}); + +describe('delete rule', () => { + it('should be able to delete a Grafana managed rule', async () => { + const rules = [ + mockCombinedRule({ + name: 'r1', + rulerRule: mockGrafanaRulerRule({ uid: 'r1' }), + }), + mockCombinedRule({ + name: 'r2', + rulerRule: mockGrafanaRulerRule({ uid: 'r2' }), + }), + ]; + const group = mockRulerRuleGroup({ + name: 'group-1', + rules: [rules[0].rulerRule!, rules[1].rulerRule!], + }); + + const getGroup = rulerRuleGroupHandler({ + delay: 0, + response: HttpResponse.json(group), + }); + + const updateNamespace = updateRulerRuleNamespaceHandler({ + response: new HttpResponse(undefined, { status: 200 }), + }); + + server.use(getGroup, updateNamespace); + + const capture = captureRequests(); + + render(); + + await userEvent.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should be able to delete a Data source managed rule', async () => { + setUpdateRulerRuleNamespaceHandler({ + response: new HttpResponse(undefined, { status: 200 }), + }); + + const rules = [ + mockCombinedRule({ + name: 'r1', + rulerRule: mockRulerAlertingRule({ alert: 'r1', labels: { foo: 'bar' } }), + }), + mockCombinedRule({ + name: 'r2', + rulerRule: mockRulerRecordingRule({ record: 'r2', labels: { bar: 'baz' } }), + }), + ]; + + const group = mockRulerRuleGroup({ + name: 'group-1', + rules: [rules[0].rulerRule!, rules[1].rulerRule!], + }); + + setRulerRuleGroupHandler({ + delay: 0, + response: HttpResponse.json(group), + }); + + const capture = captureRequests(); + + render(); + + await userEvent.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should delete the entire group if no more rules are left', async () => { + const capture = captureRequests(); + + const combined = mockCombinedRule({ + rulerRule: grafanaRulerRule, + }); + + render(); + await userEvent.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); +}); + +// this test component will cycle through the loading states +const PauseTestComponent = (options: { rulerRule?: RulerGrafanaRuleDTO }) => { + const [pauseRule, requestState] = usePauseRuleInGroup(); + + const rulerRule = options.rulerRule ?? grafanaRulerRule; + const rule = mockCombinedRule({ + rulerRule, + group: mockCombinedRuleGroup(grafanaRulerGroupName, []), + }); + const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule); + + const onClick = () => { + // always handle your errors! + pauseRule(ruleGroupID, rulerRule.grafana_alert.uid, true).catch(() => {}); + }; + + return ( + <> + +
); } const getStyles = (theme: GrafanaTheme2) => ({ row: css({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(1), marginBottom: theme.spacing(2), }), }); diff --git a/public/app/features/browse-dashboards/components/RestoreModal.tsx b/public/app/features/browse-dashboards/components/RestoreModal.tsx index 520825861fb..9b16765b963 100644 --- a/public/app/features/browse-dashboards/components/RestoreModal.tsx +++ b/public/app/features/browse-dashboards/components/RestoreModal.tsx @@ -4,7 +4,7 @@ import { ConfirmModal, Text } from '@grafana/ui'; import { Trans, t } from '../../../core/internationalization'; -interface Props { +export interface Props { isOpen: boolean; onConfirm: () => Promise; onDismiss: () => void; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 8ae39b7b575..b67870e1b90 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -173,11 +173,11 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); - expect(loader.getFromCache('fake-dash')).toBeNull(); + expect(loader.getDashboardFromCache('fake-dash')).toBeNull(); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); - expect(loader.getFromCache('fake-dash')).toBeDefined(); + expect(loader.getDashboardFromCache('fake-dash')).toBeDefined(); }); it('should load dashboard DTO from cache if requested again within 2s', async () => { @@ -186,7 +186,7 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); - expect(loader.getFromCache('fake-dash')).toBeNull(); + expect(loader.getDashboardFromCache('fake-dash')).toBeNull(); await loader.fetchDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); expect(loadDashSpy).toHaveBeenCalledTimes(1); @@ -228,7 +228,7 @@ describe('DashboardScenePageStateManager', () => { keepDashboardFromExploreInLocalStorage: false, }); - expect(loader.getFromCache('fake-dash')).toBeNull(); + expect(loader.getDashboardFromCache('fake-dash')).toBeNull(); }); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 551d4208308..b55f281e4a8 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -16,6 +16,7 @@ import { import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking'; import { DashboardDTO, DashboardRoutes } from 'app/types'; +import { getScopesFromUrl } from '../../dashboard/utils/getScopesFromUrl'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; @@ -83,7 +84,7 @@ export class DashboardScenePageStateManager extends StateManagerBase { + it('should set _changesWorker to undefined when terminate is called', () => { + const terminate = jest.fn(); + jest.spyOn(createDetectChangesWorker, 'createWorker').mockImplementation( + () => + ({ + terminate, + }) as any + ); + const changeTracker = new DashboardSceneChangeTracker({ + subscribeToEvent: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + } as any); + changeTracker.startTrackingChanges(); + + expect(changeTracker['_changesWorker']).not.toBeUndefined(); + changeTracker.terminate(); + expect(changeTracker['_changesWorker']).toBeUndefined(); + }); +}); diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts index 4bc186849e9..bf30ddd9e99 100644 --- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -188,5 +188,6 @@ export class DashboardSceneChangeTracker { public terminate() { this.stopTrackingChanges(); this._changesWorker?.terminate(); + this._changesWorker = undefined; } } diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx index 9deedbb5fb0..f53eaf052e1 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx @@ -33,7 +33,7 @@ describe('SaveDashboardDrawer', () => { expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); expect(screen.queryByTestId(selectors.pages.SaveDashboardModal.saveTimerange)).not.toBeInTheDocument(); expect(screen.getByText('No changes to save')).toBeInTheDocument(); - expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Changes/ })).not.toBeInTheDocument(); }); it('When there are no changes', async () => { @@ -61,11 +61,11 @@ describe('SaveDashboardDrawer', () => { expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); expect(screen.queryByTestId(selectors.pages.SaveDashboardModal.saveTimerange)).toBeInTheDocument(); - expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Changes/ })).not.toBeInTheDocument(); await userEvent.click(screen.getByTestId(selectors.pages.SaveDashboardModal.saveTimerange)); - expect(await screen.findByLabelText('Tab Changes')).toBeInTheDocument(); + expect(await screen.findByRole('tab', { name: /Changes/ })).toBeInTheDocument(); }); it('When refresh changed show save refresh option', async () => { @@ -94,11 +94,11 @@ describe('SaveDashboardDrawer', () => { expect(await screen.findByText('Save dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.pages.SaveDashboardModal.saveRefresh)).toBeInTheDocument(); - expect(screen.queryByLabelText('Tab Changes')).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Changes/ })).not.toBeInTheDocument(); await userEvent.click(screen.getByTestId(selectors.pages.SaveDashboardModal.saveRefresh)); - expect(await screen.findByLabelText('Tab Changes')).toBeInTheDocument(); + expect(await screen.findByRole('tab', { name: /Changes/ })).toBeInTheDocument(); }); it('Can show changes', async () => { @@ -108,7 +108,7 @@ describe('SaveDashboardDrawer', () => { openAndRender(); - await userEvent.click(await screen.findByLabelText('Tab Changes')); + await userEvent.click(await screen.findByRole('tab', { name: /Changes/ })); expect(await screen.findByText('Full JSON diff')).toBeInTheDocument(); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index bb4488f705f..91e50fba9e1 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -200,6 +200,20 @@ describe('DashboardScene', () => { expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder); }); + it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', () => { + const panel = findVizPanelByKey(scene, 'panel-1'); + const editPanel = buildPanelEditScene(panel!); + scene.setState({ + editPanel, + }); + + expect(scene.state.editPanel!['_discardChanges']).toBe(false); + + scene.exitEditMode({ skipConfirm: true }); + + expect(scene.state.editPanel!['_discardChanges']).toBe(true); + }); + it.each` prop | value ${'title'} | ${'new title'} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 83b0edbc9c3..2b04bec57de 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -317,6 +317,13 @@ export class DashboardScene extends SceneObjectBase { this.setState({ isEditing: false }); } + // if we are in edit panel, we need to onDiscard() + // so the useEffect cleanup comes later and + // doesn't try to commit the changes + if (this.state.editPanel) { + this.state.editPanel.onDiscard(); + } + // Disable grid dragging this.propagateEditModeChange(); } @@ -385,6 +392,10 @@ export class DashboardScene extends SceneObjectBase { public getPageNav(location: H.Location, navIndex: NavIndex) { const { meta, viewPanelScene, editPanel } = this.state; + if (meta.dashboardNotFound) { + return { text: 'Not found' }; + } + let pageNav: NavModelItem = { text: this.state.title, url: getDashboardUrl({ diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx new file mode 100644 index 00000000000..47d5cd076b3 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router'; +import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; + +import { selectors } from '@grafana/e2e-selectors'; +import { locationService } from '@grafana/runtime'; +import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { configureStore } from 'app/store/configureStore'; + +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; + +describe('DashboardSceneRenderer', () => { + it('should render Not Found notice when dashboard is not found', async () => { + const scene = transformSaveModelToScene({ + meta: { + isSnapshot: true, + dashboardNotFound: true, + canStar: false, + canDelete: false, + canSave: false, + canEdit: false, + canShare: false, + }, + dashboard: { + title: 'Not found', + uid: 'uid', + schemaVersion: 0, + // Disabling build in annotations to avoid mocking Grafana data source + annotations: { + list: [ + { + builtIn: 1, + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + enable: false, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + }, + }); + + const store = configureStore({}); + const context = getGrafanaContextMock(); + + render( + + + + + + + + ); + + expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 553710923cf..6164551eb1c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -7,6 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { SceneComponentProps } from '@grafana/scenes'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; import { getNavModel } from 'app/core/selectors/navModel'; import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty'; import { useSelector } from 'app/types'; @@ -23,7 +24,6 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps; + const emptyState = ( + + ); const withPanels = ( -
+
); + const notFound = meta.dashboardNotFound && ; + + let body = [withPanels]; + + if (notFound) { + body = [notFound]; + } else if (isEmpty) { + body = [emptyState, withPanels]; + } + return ( {editPanel && } @@ -55,7 +67,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps - {scopes && } + {scopes && !meta.dashboardNotFound && } {controls && hasControls && (
-
- <>{isEmpty && emptyState} - {withPanels} -
+
{body}
)} @@ -127,9 +136,6 @@ function getStyles(theme: GrafanaTheme2) { controlsWrapperWithScopes: css({ padding: theme.spacing(2, 2, 2, 0), }), - homePagePadding: css({ - padding: theme.spacing(2, 2), - }), canvasContent: css({ label: 'canvas-content', display: 'flex', diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index 797a80fe51d..ce2bfb8fa66 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { config, locationService } from '@grafana/runtime'; import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, UrlSyncContextProvider, VizPanel } from '@grafana/scenes'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; +import { DashboardMeta } from 'app/types'; import { buildPanelEditScene } from '../panel-edit/PanelEditor'; @@ -163,9 +164,27 @@ describe('NavToolbarActions', () => { expect(newShareButton).toBeInTheDocument(); }); }); + + describe('Snapshot', () => { + it('should show link button when is a snapshot', () => { + setup({ + isSnapshot: true, + }); + + expect(screen.queryByTestId('button-snapshot')).toBeInTheDocument(); + }); + it('should not show link button when is not found dashboard', () => { + setup({ + isSnapshot: true, + dashboardNotFound: true, + }); + + expect(screen.queryByTestId('button-snapshot')).not.toBeInTheDocument(); + }); + }); }); -function setup() { +function setup(meta?: DashboardMeta) { const dashboard = new DashboardScene({ $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), meta: { @@ -177,6 +196,7 @@ function setup() { canStar: true, canAdmin: true, canDelete: true, + ...meta, }, title: 'hello', uid: 'dash-1', diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 18a6a336ccf..28e2902509d 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useId, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -37,7 +37,9 @@ interface Props { } export const NavToolbarActions = React.memo(({ dashboard }) => { - const actions = ; + const id = useId(); + + const actions = ; return ; }); @@ -139,7 +141,7 @@ export function ToolbarActions({ dashboard }: Props) { toolbarActions.push({ group: 'icon-actions', - condition: meta.isSnapshot && !isEditing, + condition: meta.isSnapshot && !meta.dashboardNotFound && !isEditing, render: () => ( { expect(row2.state.y).toBe(11); }); }); + + describe('Given a scene with empty variable', () => { + it('Should preserve repeat row', async () => { + const { scene, grid } = buildScene({ variableQueryTime: 0 }, []); + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + + // Should have 3 rows, two without repeat and one with the dummy row + expect(grid.state.children.length).toBe(3); + expect(grid.state.children[1].state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior); + }); + }); }); interface SceneOptions { @@ -126,7 +139,7 @@ interface SceneOptions { repeatDirection?: RepeatDirection; } -function buildScene(options: SceneOptions) { +function buildScene(options: SceneOptions, variableOptions?: VariableValueOption[]) { const repeatBehavior = new RowRepeaterBehavior({ variableName: 'server' }); const grid = new SceneGridLayout({ @@ -203,7 +216,7 @@ function buildScene(options: SceneOptions) { isMulti: true, includeAll: true, delayMs: options.variableQueryTime, - optionsToReturn: [ + optionsToReturn: variableOptions ?? [ { label: 'A', value: 'A1' }, { label: 'B', value: 'B1' }, { label: 'C', value: 'C1' }, diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts index 11970f887b7..5ef6c6137ec 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts @@ -99,10 +99,18 @@ export class RowRepeaterBehavior extends SceneObjectBase return; } - this.setState({ treeScopes, isLoadingScopes: true }); + this.setState({ + // Update the scopes with the basic scopes otherwise they'd be lost between URL syncs + scopes: treeScopes.map(({ scopeName, path }) => ({ scope: getBasicScope(scopeName), path })), + treeScopes, + isLoadingScopes: true, + }); - this.setState({ scopes: await fetchSelectedScopes(treeScopes), isLoadingScopes: false }); + const scopes = await fetchSelectedScopes(treeScopes); + + this.setState({ scopes, isLoadingScopes: false }); } public resetDirtyScopeNames() { diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx index 2c8975b4d0e..418414da702 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx @@ -1,8 +1,9 @@ import { act, cleanup, waitFor } from '@testing-library/react'; import userEvents from '@testing-library/user-event'; -import { config } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; +import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { ScopesFiltersScene } from './ScopesFiltersScene'; @@ -33,8 +34,7 @@ import { getDashboardsContainer, getDashboardsExpand, getDashboardsSearch, - mocksNodes, - mocksScopeDashboardBindings, + getMock, mocksScopes, queryAllDashboard, queryFiltersApply, @@ -52,31 +52,7 @@ jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ - get: jest.fn().mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { - return { - items: mocksNodes.filter( - ({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '') - ), - }; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { - const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); - - return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { - return { - items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => - params.scope.includes(bindingScope) - ), - }; - } - - return {}; - }), + get: getMock, }), })); @@ -109,6 +85,7 @@ describe('ScopesScene', () => { fetchScopeSpy.mockClear(); fetchSelectedScopesSpy.mockClear(); fetchSuggestedDashboardsSpy.mockClear(); + getMock.mockClear(); dashboardScene = buildTestScene(); scopesScene = dashboardScene.state.scopes!; @@ -418,4 +395,54 @@ describe('ScopesScene', () => { }); }); }); + + describe('Dashboards API', () => { + describe('Feature flag off', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.passScopeToDashboardApi = false; + }); + + beforeEach(() => { + setDashboardAPI(undefined); + locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); + }); + + it('Legacy API should not pass the scopes', () => { + config.featureToggles.kubernetesDashboards = false; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined); + }); + + it('K8s API should not pass the scopes', () => { + config.featureToggles.kubernetesDashboards = true; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1'); + }); + }); + + describe('Feature flag on', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.passScopeToDashboardApi = true; + }); + + beforeEach(() => { + setDashboardAPI(undefined); + locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); + }); + + it('Legacy API should pass the scopes', () => { + config.featureToggles.kubernetesDashboards = false; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] }); + }); + + it('K8s API should not pass the scopes', () => { + config.featureToggles.kubernetesDashboards = true; + getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1'); + }); + }); + }); }); diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx index a7f991913c5..f0217190b57 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.tsx @@ -30,7 +30,7 @@ export class ScopesScene extends SceneObjectBase { this.addActivationHandler(() => { this._subs.add( this.state.filters.subscribeToState((newState, prevState) => { - if (newState.scopes !== prevState.scopes) { + if (!newState.isLoadingScopes && newState.scopes !== prevState.scopes) { if (this.state.isExpanded) { this.state.dashboards.fetchDashboards(this.state.filters.getSelectedScopes()); } diff --git a/public/app/features/dashboard-scene/scene/Scopes/api.ts b/public/app/features/dashboard-scene/scene/Scopes/api.ts index 71ec07cec19..07df4877a54 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/api.ts +++ b/public/app/features/dashboard-scene/scene/Scopes/api.ts @@ -3,6 +3,7 @@ import { config, getBackendSrv } from '@grafana/runtime'; import { ScopedResourceClient } from 'app/features/apiserver/client'; import { NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types'; +import { getBasicScope, mergeScopes } from './utils'; const group = 'scope.grafana.app'; const version = 'v0alpha1'; @@ -48,31 +49,12 @@ export async function fetchScope(name: string): Promise { } const response = new Promise(async (resolve) => { - const basicScope: Scope = { - metadata: { name }, - spec: { - filters: [], - title: name, - type: '', - category: '', - description: '', - }, - }; + const basicScope = getBasicScope(name); try { const serverScope = await scopesClient.get(name); - const scope = { - ...basicScope, - metadata: { - ...basicScope.metadata, - ...serverScope.metadata, - }, - spec: { - ...basicScope.spec, - ...serverScope.spec, - }, - }; + const scope = mergeScopes(basicScope, serverScope); resolve(scope); } catch (err) { diff --git a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx index 7ca1fcaa8de..8e81fc1b162 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/testUtils.tsx @@ -252,6 +252,46 @@ export const fetchScopeSpy = jest.spyOn(api, 'fetchScope'); export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes'); export const fetchSuggestedDashboardsSpy = jest.spyOn(api, 'fetchSuggestedDashboards'); +export const getMock = jest + .fn() + .mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { + return { + items: mocksNodes.filter( + ({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '') + ), + }; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { + const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); + + return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { + return { + items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => + params.scope.includes(bindingScope) + ), + }; + } + + if (url.startsWith('/api/dashboards/uid/')) { + return {}; + } + + if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) { + return { + metadata: { + name: '1', + }, + }; + } + + return {}; + }); + const selectors = { tree: { search: (nodeId: string) => `scopes-tree-${nodeId}-search`, diff --git a/public/app/features/dashboard-scene/scene/Scopes/utils.ts b/public/app/features/dashboard-scene/scene/Scopes/utils.ts new file mode 100644 index 00000000000..bbe68774966 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/utils.ts @@ -0,0 +1,28 @@ +import { Scope } from '@grafana/data'; + +export function getBasicScope(name: string): Scope { + return { + metadata: { name }, + spec: { + filters: [], + title: name, + type: '', + category: '', + description: '', + }, + }; +} + +export function mergeScopes(scope1: Scope, scope2: Scope): Scope { + return { + ...scope1, + metadata: { + ...scope1.metadata, + ...scope2.metadata, + }, + spec: { + ...scope1.spec, + ...scope2.spec, + }, + }; +} diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx index e6dcc4a9eda..0cb71d0cae1 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx @@ -26,6 +26,7 @@ describe('ShareMenu', () => { expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument(); expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument(); + expect(await screen.findByTestId(selector.shareSnapshot)).toBeInTheDocument(); }); it('should no share externally when public dashboard is disabled', async () => { config.featureToggles.publicDashboards = false; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx index 19fdf21f199..0962d87a1c0 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx @@ -4,12 +4,14 @@ import { useAsyncFn } from 'react-use'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { VizPanel } from '@grafana/scenes'; import { Menu } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; import { DashboardScene } from '../../scene/DashboardScene'; import { ShareDrawer } from '../ShareDrawer/ShareDrawer'; import { ShareExternally } from './share-externally/ShareExternally'; +import { ShareSnapshot } from './share-snapshot/ShareSnapshot'; import { buildShareUrl } from './utils'; const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu; @@ -21,30 +23,45 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc const onShareExternallyClick = () => { const drawer = new ShareDrawer({ - title: 'Share externally', + title: t('share-dashboard.menu.share-externally-title', 'Share externally'), body: new ShareExternally({}), }); dashboard.showModal(drawer); }; + const onShareSnapshotClick = () => { + const drawer = new ShareDrawer({ + title: t('share-dashboard.menu.share-snapshot-title', 'Share snapshot'), + body: new ShareSnapshot({ dashboardRef: dashboard.getRef() }), + }); + + dashboard.showModal(drawer); + }; + return ( {isPublicDashboardsEnabled() && ( )} + ); } diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/CreateSnapshot.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/CreateSnapshot.tsx new file mode 100644 index 00000000000..88774c566e0 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/CreateSnapshot.tsx @@ -0,0 +1,102 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { + Alert, + Button, + Divider, + Field, + RadioButtonGroup, + Spinner, + Stack, + Text, + TextLink, + useStyles2, +} from '@grafana/ui'; +import { Input } from '@grafana/ui/src/components/Input/Input'; +import { t } from '@grafana/ui/src/utils/i18n'; +import { Trans } from 'app/core/internationalization'; + +import { SnapshotSharingOptions } from '../../../../dashboard/services/SnapshotSrv'; +import { getExpireOptions } from '../../ShareSnapshotTab'; + +const SNAPSHOT_URL = 'https://grafana.com/docs/grafana/latest/dashboards/share-dashboards-panels/#publish-a-snapshot'; + +interface Props { + isLoading: boolean; + name: string; + selectedExpireOption: SelectableValue; + sharingOptions?: SnapshotSharingOptions; + onCancelClick: () => void; + onCreateClick: (isExternal?: boolean) => void; + onNameChange: (v: string) => void; + onExpireChange: (v: number) => void; +} +export function CreateSnapshot({ + name, + onNameChange, + onExpireChange, + selectedExpireOption, + sharingOptions, + onCancelClick, + onCreateClick, + isLoading, +}: Props) { + const styles = useStyles2(getStyles); + + return ( +
+ + + + + A Grafana dashboard snapshot publicly shares a dashboard while removing sensitive data such as queries and + panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot. + + + + + + + onNameChange(e.target.value)} /> + + + + id="expire-select-input" + options={getExpireOptions()} + value={selectedExpireOption?.value} + onChange={onExpireChange} + /> + + + + + + {sharingOptions?.externalEnabled && ( + + )} + + {isLoading && } + + + {t('snapshot.share.view-all-button', 'View all snapshots')} + + +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + paddingBottom: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/ShareSnapshot.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/ShareSnapshot.tsx new file mode 100644 index 00000000000..4cc3c700a9f --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/ShareSnapshot.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { SceneComponentProps } from '@grafana/scenes'; +import { Alert } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { ShareDrawerConfirmAction } from '../../ShareDrawer/ShareDrawerConfirmAction'; +import { ShareSnapshotTab } from '../../ShareSnapshotTab'; + +import { CreateSnapshot } from './CreateSnapshot'; +import { SnapshotActions } from './SnapshotActions'; + +const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareSnapshot; + +export class ShareSnapshot extends ShareSnapshotTab { + static Component = ShareSnapshotRenderer; +} + +function ShareSnapshotRenderer({ model }: SceneComponentProps) { + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [showDeletedAlert, setShowDeletedAlert] = useState(false); + const [step, setStep] = useState(1); + + const { snapshotName, snapshotSharingOptions, selectedExpireOption, dashboardRef } = model.useState(); + + const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => { + const response = await model.onSnapshotCreate(external); + setStep(2); + return response; + }); + const [deleteSnapshotResult, deleteSnapshot] = useAsyncFn(async (url: string) => { + const response = await model.onSnapshotDelete(url); + setStep(1); + setShowDeleteConfirmation(false); + setShowDeletedAlert(true); + return response; + }); + + const onCancelClick = () => { + dashboardRef.resolve().closeModal(); + }; + + if (showDeleteConfirmation) { + return ( + deleteSnapshot(snapshotResult.value?.deleteUrl!)} + onDismiss={() => setShowDeleteConfirmation(false)} + description={t('snapshot.share.delete-description', 'Are you sure you want to delete this snapshot?')} + isActionLoading={deleteSnapshotResult.loading} + /> + ); + } + + return ( +
+ {step === 1 && ( + <> + {showDeletedAlert && ( + setShowDeletedAlert(false)}> + + Your snapshot has been deleted. It might take up to an hour before the snapshot is cleared from any CDN + caches. + + + )} + + + )} + {step === 2 && ( + setShowDeleteConfirmation(true)} + onNewSnapshotClick={() => setStep(1)} + /> + )} +
+ ); +} diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/SnapshotActions.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/SnapshotActions.tsx new file mode 100644 index 00000000000..903a58df867 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/SnapshotActions.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { Button, ClipboardButton, Stack } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +interface Props { + url: string; + onDeleteClick: () => void; + onNewSnapshotClick: () => void; +} +export const SnapshotActions = ({ url, onDeleteClick, onNewSnapshotClick }: Props) => { + return ( + + url}> + Copy link + + + + + ); +}; diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx index 5331b22a93b..8acccc7cd11 100644 --- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx @@ -6,9 +6,12 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { getBackendSrv } from '@grafana/runtime'; import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; import { t, Trans } from 'app/core/internationalization'; import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv'; +import { dispatch } from 'app/store/store'; import { transformSceneToSaveModel, trimDashboardForSnapshot } from '../serialization/transformSceneToSaveModel'; import { DashboardInteractions } from '../utils/interactions'; @@ -17,7 +20,7 @@ import { SceneShareTabState } from './types'; const selectors = e2eSelectors.pages.ShareDashboardModal.SnapshotScene; -const getExpireOptions = () => { +export const getExpireOptions = () => { const DEFAULT_EXPIRE_OPTION: SelectableValue = { label: t('share-modal.snapshot.expire-week', '1 Week'), value: 60 * 60 * 24 * 7, @@ -46,17 +49,17 @@ const getDefaultExpireOption = () => { export interface ShareSnapshotTabState extends SceneShareTabState { panelRef?: SceneObjectRef; - snapshotName?: string; - selectedExpireOption?: SelectableValue; + snapshotName: string; + selectedExpireOption: SelectableValue; snapshotSharingOptions?: SnapshotSharingOptions; } export class ShareSnapshotTab extends SceneObjectBase { public tabId = shareDashboardType.snapshot; - static Component = ShareSnapshoTabRenderer; + static Component = ShareSnapshotTabRenderer; - public constructor(state: ShareSnapshotTabState) { + public constructor(state: Omit) { super({ ...state, snapshotName: state.dashboardRef.resolve().state.title, @@ -124,7 +127,11 @@ export class ShareSnapshotTab extends SceneObjectBase { }; try { - return await getDashboardSnapshotSrv().create(cmdData); + const response = await getDashboardSnapshotSrv().create(cmdData); + dispatch( + notifyApp(createSuccessNotification(t('snapshot.share.success-creation', 'Your snapshot has been created'))) + ); + return response; } finally { if (external) { DashboardInteractions.publishSnapshotClicked({ @@ -139,9 +146,17 @@ export class ShareSnapshotTab extends SceneObjectBase { } } }; + + public onSnapshotDelete = async (url: string) => { + const response = await getBackendSrv().get(url); + dispatch( + notifyApp(createSuccessNotification(t('snapshot.share.success-delete', 'Your snapshot has been deleted'))) + ); + return response; + }; } -function ShareSnapshoTabRenderer({ model }: SceneComponentProps) { +function ShareSnapshotTabRenderer({ model }: SceneComponentProps) { const { snapshotName, selectedExpireOption, modalRef, snapshotSharingOptions } = model.useState(); const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => { diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 146349d8a10..6ea255ad5e4 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -4,13 +4,15 @@ import { ResourceClient } from 'app/features/apiserver/types'; import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; -import { DashboardDTO, DashboardDataDTO } from 'app/types'; +import { DashboardDTO, DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types'; + +import { getScopesFromUrl } from '../utils/getScopesFromUrl'; export interface DashboardAPI { /** Get a dashboard with the access control metadata */ getDashboardDTO(uid: string): Promise; /** Save dashboard */ - saveDashboard(options: SaveDashboardCommand): Promise; + saveDashboard(options: SaveDashboardCommand): Promise; /** Delete a dashboard */ deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; } @@ -19,10 +21,10 @@ export interface DashboardAPI { class LegacyDashboardAPI implements DashboardAPI { constructor() {} - saveDashboard(options: SaveDashboardCommand): Promise { + saveDashboard(options: SaveDashboardCommand): Promise { dashboardWatcher.ignoreNextSave(); - return getBackendSrv().post('/api/dashboards/db/', { + return getBackendSrv().post('/api/dashboards/db/', { dashboard: options.dashboard, message: options.message ?? '', overwrite: options.overwrite ?? false, @@ -35,13 +37,18 @@ class LegacyDashboardAPI implements DashboardAPI { } getDashboardDTO(uid: string): Promise { - return getBackendSrv().get(`/api/dashboards/uid/${uid}`); + const scopesSearchParams = getScopesFromUrl(); + const scopes = scopesSearchParams?.getAll('scopes') ?? []; + const queryParams = scopes.length > 0 ? { scopes } : undefined; + + return getBackendSrv().get(`/api/dashboards/uid/${uid}`, queryParams); } } // Implemented using /apis/dashboards.grafana.app/* class K8sDashboardAPI implements DashboardAPI { private client: ResourceClient; + constructor(private legacy: DashboardAPI) { this.client = new ScopedResourceClient({ group: 'dashboard.grafana.app', @@ -50,7 +57,7 @@ class K8sDashboardAPI implements DashboardAPI { }); } - saveDashboard(options: SaveDashboardCommand): Promise { + saveDashboard(options: SaveDashboardCommand): Promise { return this.legacy.saveDashboard(options); } @@ -82,3 +89,10 @@ export function getDashboardAPI() { } return instance; } + +export function setDashboardAPI(override: DashboardAPI | undefined) { + if (process.env.NODE_ENV !== 'test') { + throw new Error('dashboardAPI can be only overridden in test environment'); + } + instance = override; +} diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx index 9e1b14a33ed..867885d095f 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -89,7 +89,7 @@ describe('LinksSettings', () => { const linklessDashboard = createDashboardModelFixture({ links: [] }); setup(linklessDashboard); - const linksTab = screen.getByRole('tab', { name: 'Tab Links' }); + const linksTab = screen.getByRole('tab', { name: 'Links' }); expect(linksTab).toBeInTheDocument(); expect(linksTab).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('button', { name: 'Add dashboard link' })).toBeInTheDocument(); diff --git a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.test.tsx b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.test.tsx index c92baacc746..5e2067ce961 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.test.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.test.tsx @@ -1,16 +1,18 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; +import { createTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { PanelModel } from '../../state'; -import PanelHeaderCorner, { Props } from './PanelHeaderCorner'; +import { PanelHeaderCorner, Props } from './PanelHeaderCorner'; const setup = () => { const testPanel = new PanelModel({ title: 'test', description: 'test panel' }); const props: Props = { panel: testPanel, + theme: createTheme(), }; return render(); }; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx index c761570fdc4..1d5c7a5a8e1 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx @@ -5,7 +5,7 @@ import { renderMarkdown, LinkModelSupplier, ScopedVars, IconName } from '@grafan import { GrafanaTheme2 } from '@grafana/data/'; import { selectors } from '@grafana/e2e-selectors'; import { locationService, getTemplateSrv } from '@grafana/runtime'; -import { Tooltip, PopoverContent, Icon } from '@grafana/ui'; +import { Tooltip, PopoverContent, Icon, Themeable2, withTheme2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui/'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -17,7 +17,7 @@ enum InfoMode { Links = 'Links', } -export interface Props { +export interface Props extends Themeable2 { panel: PanelModel; title?: string; description?: string; @@ -45,22 +45,23 @@ export class PanelHeaderCorner extends Component { }; getInfoContent = (): JSX.Element => { - const { panel } = this.props; + const { panel, theme } = this.props; const markdown = panel.description || ''; const interpolatedMarkdown = getTemplateSrv().replace(markdown, panel.scopedVars); const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown); const links = this.props.links && this.props.links.getLinks(panel.replaceVariables); + const styles = getContentStyles(theme); return ( -
+
{links && links.length > 0 && ( -
    +
      {links.map((link, idx) => { return (
    • - + {link.title}
    • @@ -102,7 +103,7 @@ export class PanelHeaderCorner extends Component { } } -export default PanelHeaderCorner; +export default withTheme2(PanelHeaderCorner); interface PanelInfoCornerProps { infoMode: InfoMode; @@ -135,6 +136,25 @@ const iconMap: Record = { [InfoMode.Links]: 'external-link-alt', }; +const getContentStyles = (theme: GrafanaTheme2) => ({ + content: css({ + overflow: 'auto', + + code: { + whiteSpace: 'normal', + wordWrap: 'break-word', + }, + + 'pre > code': { + display: 'block', + }, + }), + cornerLinks: css({ + listStyle: 'none', + paddingLeft: 0, + }), +}); + const getStyles = (theme: GrafanaTheme2) => { return { icon: css({ diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index 22c5b789d75..ba06819bc6c 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -77,7 +77,7 @@ export class DashboardLoaderSrv { }; }); } else if (uid) { - const cachedDashboard = stateManager.getFromCache(uid); + const cachedDashboard = stateManager.getDashboardFromCache(uid); if (cachedDashboard) { return Promise.resolve(cachedDashboard); } @@ -92,7 +92,9 @@ export class DashboardLoaderSrv { return result; }) .catch(() => { - return this._dashboardLoadFailed('Not found', true); + const dash = this._dashboardLoadFailed('Not found', true); + dash.dashboard.uid = uid; + return dash; }); } else { throw new Error('Dashboard uid or slug required'); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 4d77b54728d..599c4ad2a8b 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -61,7 +61,7 @@ async function fetchDashboard( switch (args.routeName) { case DashboardRoutes.Home: { const stateManager = getDashboardScenePageStateManager(); - const cachedDashboard = stateManager.getFromCache(HOME_DASHBOARD_CACHE_KEY); + const cachedDashboard = stateManager.getDashboardFromCache(HOME_DASHBOARD_CACHE_KEY); if (cachedDashboard) { return cachedDashboard; diff --git a/public/app/features/dashboard/utils/getScopesFromUrl.ts b/public/app/features/dashboard/utils/getScopesFromUrl.ts new file mode 100644 index 00000000000..e9539037fe5 --- /dev/null +++ b/public/app/features/dashboard/utils/getScopesFromUrl.ts @@ -0,0 +1,13 @@ +import { config, locationService } from '@grafana/runtime'; + +export function getScopesFromUrl(): URLSearchParams | undefined { + if (!config.featureToggles.scopeFilters || !config.featureToggles.passScopeToDashboardApi) { + return undefined; + } + + const queryParams = locationService.getSearchObject(); + const rawScopes = queryParams['scopes'] ?? []; + const scopes = Array.isArray(rawScopes) ? rawScopes : [rawScopes]; + + return new URLSearchParams(scopes.map((scope) => ['scopes', String(scope)])); +} diff --git a/public/app/features/datasources/components/DataSourceCategories.tsx b/public/app/features/datasources/components/DataSourceCategories.tsx index 7ae50234dea..76c73cad02f 100644 --- a/public/app/features/datasources/components/DataSourceCategories.tsx +++ b/public/app/features/datasources/components/DataSourceCategories.tsx @@ -1,7 +1,8 @@ +import { css } from '@emotion/css'; import React from 'react'; -import { DataSourcePluginMeta } from '@grafana/data'; -import { LinkButton } from '@grafana/ui'; +import { DataSourcePluginMeta, GrafanaTheme2 } from '@grafana/data'; +import { LinkButton, useStyles2 } from '@grafana/ui'; import { DataSourcePluginCategory } from 'app/types'; import { ROUTES } from '../../connections/constants'; @@ -18,13 +19,14 @@ export type Props = { export function DataSourceCategories({ categories, onClickDataSourceType }: Props) { const moreDataSourcesLink = `${ROUTES.AddNewConnection}?cat=data-source`; + const styles = useStyles2(getStyles); return ( <> {/* Categories */} {categories.map(({ id, title, plugins }) => ( -
      -
      +
      +
      {title}
      @@ -32,7 +34,7 @@ export function DataSourceCategories({ categories, onClickDataSourceType }: Prop ))} {/* Find more */} -
      +
      Find more data source plugins @@ -40,3 +42,17 @@ export function DataSourceCategories({ categories, onClickDataSourceType }: Prop ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + category: css({ + marginBottom: theme.spacing(2), + }), + header: css({ + fontSize: theme.typography.h5.fontSize, + marginBottom: theme.spacing(1), + }), + more: css({ + margin: theme.spacing(4), + textAlign: 'center', + }), +}); diff --git a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx index 44b2a989a60..3e91de93e83 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx @@ -16,7 +16,10 @@ export interface ContentOutlineContextProps { outlineItems: ContentOutlineItemContextProps[]; register: RegisterFunction; unregister: (id: string) => void; - unregisterAllChildren: (parentId: string, childType: ITEM_TYPES) => void; + unregisterAllChildren: ( + parentIdGetter: (items: ContentOutlineItemContextProps[]) => string | undefined, + childType: ITEM_TYPES + ) => void; updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void; updateItem: (id: string, properties: Partial>) => void; } @@ -193,16 +196,23 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }: ); }, []); - const unregisterAllChildren = useCallback((parentId: string, childType: ITEM_TYPES) => { - setOutlineItems((prevItems) => - prevItems.map((item) => { - if (item.id === parentId) { - item.children = item.children?.filter((child) => child.type !== childType); + const unregisterAllChildren = useCallback( + (parentIdGetter: (items: ContentOutlineItemContextProps[]) => string | undefined, childType: ITEM_TYPES) => { + setOutlineItems((prevItems) => { + const parentId = parentIdGetter(prevItems); + if (!parentId) { + return prevItems; } - return item; - }) - ); - }, []); + return prevItems.map((item) => { + if (item.id === parentId) { + item.children = item.children?.filter((child) => child.type !== childType); + } + return item; + }); + }); + }, + [] + ); useEffect(() => { setOutlineItems((prevItems) => { diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index 64fb4622982..734e206848c 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -82,15 +82,15 @@ describe('ExploreQueryInspector', () => { }); it('should render 4 Tabs if queryResponse has no error', () => { setup(); - expect(screen.getAllByLabelText(/tab/i)).toHaveLength(4); + expect(screen.getAllByRole('tab')).toHaveLength(4); }); it('should render 5 Tabs if queryResponse has error', () => { setup({ queryResponse: { error: 'Bad gateway' } }); - expect(screen.getAllByLabelText(/tab/i)).toHaveLength(5); + expect(screen.getAllByRole('tab')).toHaveLength(5); }); it('should display query data when click on expanding', () => { setup(); - fireEvent.click(screen.getByLabelText(/tab query/i)); + fireEvent.click(screen.getByRole('tab', { name: /query/i })); fireEvent.click(screen.getByText(/expand all/i)); expect(screen.getByText(/very unique test value/i)).toBeInTheDocument(); }); @@ -134,7 +134,7 @@ describe('ExploreQueryInspector', () => { }, }); - fireEvent.click(screen.getByLabelText(/tab data/i)); + fireEvent.click(screen.getByRole('tab', { name: /data/i })); // assert series values are formatted to 3 digits (xx.x or x.xx) expect(screen.getByText(/71.2/i)).toBeInTheDocument(); expect(screen.getByText(/72.3/i)).toBeInTheDocument(); diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index b762b57b6f5..2cbf320e0ca 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -4,7 +4,7 @@ import React, { useMemo } from 'react'; import { shallowEqual } from 'react-redux'; import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data'; -import { reportInteraction, config } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { defaultIntervals, PageToolbar, @@ -212,21 +212,19 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle - Outline - - ), + + Outline + , { - if (onHiddenSeriesChanged) { + if (onHiddenSeriesChangedRef.current) { const hiddenFrames: string[] = []; dataWithConfig.forEach((frame) => { const allFieldsHidden = frame.fields.map((field) => field.config?.custom?.hideFrom?.viz).every(identity); @@ -163,9 +165,9 @@ export function ExploreGraph({ hiddenFrames.push(getFrameDisplayName(frame)); } }); - onHiddenSeriesChanged(hiddenFrames); + onHiddenSeriesChangedRef.current(hiddenFrames); } - }, [dataWithConfig, onHiddenSeriesChanged]); + }, [dataWithConfig]); const panelContext: PanelContext = { eventsScope: 'explore', diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index a8f70e47270..e5f46da4923 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -1,11 +1,11 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; +import { Provider } from 'react-redux'; import { DataFrame, EventBusSrv, - ExploreLogsPanelState, ExplorePanelsState, LoadingState, LogLevel, @@ -13,25 +13,20 @@ import { standardTransformersRegistry, toUtc, createDataFrame, + ExploreLogsPanelState, } from '@grafana/data'; import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; import { config } from '@grafana/runtime'; -import store from 'app/core/store'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; +import { configureStore } from 'app/store/configureStore'; + +import { initialExploreState } from '../state/main'; +import { makeExplorePaneState } from '../state/utils'; import { Logs } from './Logs'; import { visualisationTypeKey } from './utils/logs'; import { getMockElasticFrame, getMockLokiFrame } from './utils/testMocks.test'; -jest.mock('app/core/store', () => { - return { - getBool: jest.fn(), - getObject: jest.fn((_a, b) => b), - get: jest.fn(), - set: jest.fn(), - }; -}); - const reportInteraction = jest.fn(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -45,26 +40,11 @@ jest.mock('app/core/utils/shortLinks', () => ({ createAndCopyShortLink: (url: string) => createAndCopyShortLink(url), })); -jest.mock('app/store/store', () => ({ - getState: jest.fn().mockReturnValue({ - explore: { - panes: { - left: { - datasource: 'id', - queries: [{ refId: 'A', expr: '', queryType: 'range', datasource: { type: 'loki', uid: 'id' } }], - range: { raw: { from: 'now-1h', to: 'now' } }, - }, - }, - }, - }), - dispatch: jest.fn(), -})); - -const changePanelState = jest.fn(); +const fakeChangePanelState = jest.fn().mockReturnValue({ type: 'fakeAction' }); jest.mock('../state/explorePane', () => ({ ...jest.requireActual('../state/explorePane'), changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { - return changePanelState(exploreId, panel, panelState); + return fakeChangePanelState(exploreId, panel, panelState); }, })); @@ -72,6 +52,7 @@ describe('Logs', () => { let originalHref = window.location.href; beforeEach(() => { + localStorage.clear(); jest.clearAllMocks(); }); @@ -120,6 +101,7 @@ describe('Logs', () => { ]; const testDataFrame = dataFrame ?? getMockLokiFrame(); + return ( { /> ); }; + const setup = (partialProps?: Partial>, dataFrame?: DataFrame, logs?: LogRowModel[]) => { - return render(getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)); + const fakeStore = configureStore({ + explore: { + ...initialExploreState, + panes: { + left: makeExplorePaneState(), + }, + }, + }); + + const { rerender } = render( + + {getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)} + + ); + return { rerender, store: fakeStore }; }; describe('scrolling behavior', () => { @@ -216,40 +213,47 @@ describe('Logs', () => { it('should render a load more button', () => { const scanningStarted = jest.fn(); + const store = configureStore({ + explore: { + ...initialExploreState, + }, + }); render( - undefined} - logsVolumeEnabled={true} - onSetLogsVolumeEnabled={() => null} - onClickFilterLabel={() => null} - onClickFilterOutLabel={() => null} - logsVolumeData={undefined} - loadLogsVolumeData={() => undefined} - logRows={[]} - onStartScanning={scanningStarted} - timeZone={'utc'} - width={50} - loading={false} - loadingState={LoadingState.Done} - absoluteRange={{ - from: toUtc('2019-01-01 10:00:00').valueOf(), - to: toUtc('2019-01-01 16:00:00').valueOf(), - }} - range={{ - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { from: 'now-1h', to: 'now' }, - }} - addResultsToCache={() => {}} - onChangeTime={() => {}} - clearCache={() => {}} - getFieldLinks={() => { - return []; - }} - eventBus={new EventBusSrv()} - isFilterLabelActive={jest.fn()} - /> + + undefined} + logsVolumeEnabled={true} + onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} + logsVolumeData={undefined} + loadLogsVolumeData={() => undefined} + logRows={[]} + onStartScanning={scanningStarted} + timeZone={'utc'} + width={50} + loading={false} + loadingState={LoadingState.Done} + absoluteRange={{ + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }} + range={{ + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { from: 'now-1h', to: 'now' }, + }} + addResultsToCache={() => {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + eventBus={new EventBusSrv()} + isFilterLabelActive={jest.fn()} + /> + ); const button = screen.getByRole('button', { name: /scan for older logs/i, @@ -259,40 +263,47 @@ describe('Logs', () => { }); it('should render a stop scanning button', () => { + const store = configureStore({ + explore: { + ...initialExploreState, + }, + }); render( - undefined} - logsVolumeEnabled={true} - onSetLogsVolumeEnabled={() => null} - onClickFilterLabel={() => null} - onClickFilterOutLabel={() => null} - logsVolumeData={undefined} - loadLogsVolumeData={() => undefined} - logRows={[]} - scanning={true} - timeZone={'utc'} - width={50} - loading={false} - loadingState={LoadingState.Done} - absoluteRange={{ - from: toUtc('2019-01-01 10:00:00').valueOf(), - to: toUtc('2019-01-01 16:00:00').valueOf(), - }} - range={{ - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { from: 'now-1h', to: 'now' }, - }} - addResultsToCache={() => {}} - onChangeTime={() => {}} - clearCache={() => {}} - getFieldLinks={() => { - return []; - }} - eventBus={new EventBusSrv()} - isFilterLabelActive={jest.fn()} - /> + + undefined} + logsVolumeEnabled={true} + onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} + logsVolumeData={undefined} + loadLogsVolumeData={() => undefined} + logRows={[]} + scanning={true} + timeZone={'utc'} + width={50} + loading={false} + loadingState={LoadingState.Done} + absoluteRange={{ + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }} + range={{ + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { from: 'now-1h', to: 'now' }, + }} + addResultsToCache={() => {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + eventBus={new EventBusSrv()} + isFilterLabelActive={jest.fn()} + /> + ); expect( @@ -304,42 +315,48 @@ describe('Logs', () => { it('should render a stop scanning button', () => { const scanningStopped = jest.fn(); - + const store = configureStore({ + explore: { + ...initialExploreState, + }, + }); render( - undefined} - logsVolumeEnabled={true} - onSetLogsVolumeEnabled={() => null} - onClickFilterLabel={() => null} - onClickFilterOutLabel={() => null} - logsVolumeData={undefined} - loadLogsVolumeData={() => undefined} - logRows={[]} - scanning={true} - onStopScanning={scanningStopped} - timeZone={'utc'} - width={50} - loading={false} - loadingState={LoadingState.Done} - absoluteRange={{ - from: toUtc('2019-01-01 10:00:00').valueOf(), - to: toUtc('2019-01-01 16:00:00').valueOf(), - }} - range={{ - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { from: 'now-1h', to: 'now' }, - }} - addResultsToCache={() => {}} - onChangeTime={() => {}} - clearCache={() => {}} - getFieldLinks={() => { - return []; - }} - eventBus={new EventBusSrv()} - isFilterLabelActive={jest.fn()} - /> + + undefined} + logsVolumeEnabled={true} + onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} + logsVolumeData={undefined} + loadLogsVolumeData={() => undefined} + logRows={[]} + scanning={true} + onStopScanning={scanningStopped} + timeZone={'utc'} + width={50} + loading={false} + loadingState={LoadingState.Done} + absoluteRange={{ + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }} + range={{ + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { from: 'now-1h', to: 'now' }, + }} + addResultsToCache={() => {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + eventBus={new EventBusSrv()} + isFilterLabelActive={jest.fn()} + /> + ); const button = screen.getByRole('button', { @@ -363,12 +380,12 @@ describe('Logs', () => { describe('for permalinking', () => { it('should dispatch a `changePanelState` event without the id', () => { const panelState = { logs: { id: '1' } }; - const { rerender } = setup({ loading: false, panelState }); + const { rerender, store } = setup({ loading: false, panelState }); - rerender(getComponent({ loading: true, exploreId: 'right', panelState })); - rerender(getComponent({ loading: false, exploreId: 'right', panelState })); + rerender({getComponent({ loading: true, exploreId: 'right', panelState })}); + rerender({getComponent({ loading: false, exploreId: 'right', panelState })}); - expect(changePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); + expect(fakeChangePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); }); it('should scroll the scrollElement into view if rows contain id', () => { @@ -491,23 +508,17 @@ describe('Logs', () => { }); it('should use default state from localstorage - table', async () => { - const oldGet = store.get; - store.get = jest.fn().mockReturnValue('table'); localStorage.setItem(visualisationTypeKey, 'table'); setup({}); const table = await screen.findByTestId('logRowsTable'); expect(table).toBeInTheDocument(); - store.get = oldGet; }); it('should use default state from localstorage - logs', async () => { - const oldGet = store.get; - store.get = jest.fn().mockReturnValue('logs'); localStorage.setItem(visualisationTypeKey, 'logs'); setup({}); const table = await screen.findByTestId('logRows'); expect(table).toBeInTheDocument(); - store.get = oldGet; }); it('should change visualisation to table on toggle (elastic)', async () => { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index a68e8e79e35..fb671e75b54 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1,38 +1,41 @@ import { css, cx } from '@emotion/css'; import { capitalize, groupBy } from 'lodash'; import memoizeOne from 'memoize-one'; -import React, { createRef, PureComponent } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { usePrevious, useUnmount } from 'react-use'; import { - AbsoluteTimeRange, - CoreApp, + SplitOpen, + LogRowModel, + LogsMetaItem, DataFrame, - DataHoverClearEvent, - DataHoverEvent, + DataQuery, + AbsoluteTimeRange, + GrafanaTheme2, + LoadingState, + TimeZone, + RawTimeRange, DataQueryResponse, + LogRowContextOptions, + LinkModel, EventBus, - ExploreLogsPanelState, ExplorePanelsState, Field, - GrafanaTheme2, - LinkModel, - LoadingState, - LogLevel, - LogRowContextOptions, - LogRowModel, - LogsDedupDescription, - LogsDedupStrategy, - LogsMetaItem, - LogsSortOrder, - rangeUtil, - RawTimeRange, - serializeStateToUrlParam, - SplitOpen, TimeRange, + LogsDedupStrategy, + LogsSortOrder, + LogLevel, + DataTopic, + CoreApp, + LogsDedupDescription, + rangeUtil, + ExploreLogsPanelState, + DataHoverClearEvent, + DataHoverEvent, + serializeStateToUrlParam, urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { DataQuery, DataTopic, TimeZone } from '@grafana/schema'; import { Button, InlineField, @@ -50,21 +53,21 @@ import { Trans } from 'app/core/internationalization'; import store from 'app/core/store'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; +import { LogRows } from 'app/features/logs/components/LogRows'; +import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; +import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; -import { dispatch, getState } from 'app/store/store'; +import { getState } from 'app/store/store'; +import { ExploreItemState, useDispatch } from 'app/types'; -import { ExploreItemState } from '../../../types'; -import { LogRows } from '../../logs/components/LogRows'; -import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal'; -import { dedupLogRows, filterLogLevels, LogLevelColor } from '../../logs/logsModel'; -import { ContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; +import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; import { getUrlStateFromPaneState } from '../hooks/useStateSync'; import { changePanelState } from '../state/explorePane'; import { LogsFeedback } from './LogsFeedback'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; -import { getLogsTableHeight, LogsTableWrap } from './LogsTableWrap'; +import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; @@ -119,25 +122,6 @@ interface Props extends Themeable2 { export type LogsVisualisationType = 'table' | 'logs'; -interface State { - showLabels: boolean; - showTime: boolean; - wrapLogMessage: boolean; - prettifyLogMessage: boolean; - dedupStrategy: LogsDedupStrategy; - hiddenLogLevels: LogLevel[]; - logsSortOrder: LogsSortOrder; - isFlipping: boolean; - displayedFields: string[]; - forceEscape: boolean; - contextOpen: boolean; - contextRow?: LogRowModel; - tableFrame?: DataFrame; - visualisationType?: LogsVisualisationType; - logsContainer?: HTMLDivElement; - pinLineButtonTooltipTitle?: PopoverContent; -} - // we need to define the order of these explicitly const DEDUP_OPTIONS = [ LogsDedupStrategy.none, @@ -162,82 +146,90 @@ const getDefaultVisualisationType = (): LogsVisualisationType => { const PINNED_LOGS_LIMIT = 3; -class UnthemedLogs extends PureComponent { - flipOrderTimer?: number; - cancelFlippingTimer?: number; - topLogsRef = createRef(); - logsVolumeEventBus: EventBus; - static contextType = ContentOutlineContext; - declare context: React.ContextType; - // @ts-ignore - private toggleLegendRef: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void> = - React.createRef(); +const UnthemedLogs: React.FunctionComponent = (props: Props) => { + const { + width, + splitOpen, + logRows, + logsMeta, + logsVolumeEnabled, + logsVolumeData, + loadLogsVolumeData, + loading = false, + onClickFilterLabel, + onClickFilterOutLabel, + timeZone, + scanning, + scanRange, + showContextToggle, + absoluteRange, + onChangeTime, + getFieldLinks, + theme, + logsQueries, + clearCache, + addResultsToCache, + exploreId, + getRowContext, + getLogRowContextUi, + getRowContextQuery, + loadMoreLogs, + panelState, + eventBus, + } = props; + const [showLabels, setShowLabels] = useState(store.getBool(SETTINGS_KEYS.showLabels, false)); + const [showTime, setShowTime] = useState(store.getBool(SETTINGS_KEYS.showTime, true)); + const [wrapLogMessage, setWrapLogMessage] = useState(store.getBool(SETTINGS_KEYS.wrapLogMessage, true)); + const [prettifyLogMessage, setPrettifyLogMessage] = useState( + store.getBool(SETTINGS_KEYS.prettifyLogMessage, false) + ); + const [dedupStrategy, setDedupStrategy] = useState(LogsDedupStrategy.none); + const [hiddenLogLevels, setHiddenLogLevels] = useState([]); + const [logsSortOrder, setLogsSortOrder] = useState( + store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending + ); + const [isFlipping, setIsFlipping] = useState(false); + const [displayedFields, setDisplayedFields] = useState([]); + const [forceEscape, setForceEscape] = useState(false); + const [contextOpen, setContextOpen] = useState(false); + const [contextRow, setContextRow] = useState(undefined); + const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState('Pin to content outline'); + const [visualisationType, setVisualisationType] = useState( + panelState?.logs?.visualisationType ?? getDefaultVisualisationType() + ); + const [scrollIntoView, setScrollIntoView] = useState<((element: HTMLElement) => void) | undefined>(undefined); + const logsContainerRef = useRef(undefined); + const dispatch = useDispatch(); + const previousLoading = usePrevious(loading); - state: State = { - showLabels: store.getBool(SETTINGS_KEYS.showLabels, false), - showTime: store.getBool(SETTINGS_KEYS.showTime, true), - wrapLogMessage: store.getBool(SETTINGS_KEYS.wrapLogMessage, true), - prettifyLogMessage: store.getBool(SETTINGS_KEYS.prettifyLogMessage, false), - dedupStrategy: LogsDedupStrategy.none, - hiddenLogLevels: [], - logsSortOrder: store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending, - isFlipping: false, - displayedFields: [], - forceEscape: false, - contextOpen: false, - contextRow: undefined, - tableFrame: undefined, - visualisationType: this.props.panelState?.logs?.visualisationType ?? getDefaultVisualisationType(), - logsContainer: undefined, - pinLineButtonTooltipTitle: 'Pin to content outline', - }; + const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false }); + const { register, unregister, outlineItems, updateItem, unregisterAllChildren } = useContentOutlineContext() ?? {}; + const flipOrderTimer = useRef(undefined); + const cancelFlippingTimer = useRef(undefined); + const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {}); + const topLogsRef = useRef(null); - constructor(props: Props) { - super(props); - this.logsVolumeEventBus = props.eventBus.newScopedBus('logsvolume', { onlyLocal: false }); - } + const tableHeight = getLogsTableHeight(); + const styles = getStyles(theme, wrapLogMessage, tableHeight); + const hasData = logRows && logRows.length > 0; + const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; - componentDidMount(): void { - this.registerLogLevelsWithContentOutline(); - } - - componentWillUnmount() { - if (this.flipOrderTimer) { - window.clearTimeout(this.flipOrderTimer); - } - - if (this.cancelFlippingTimer) { - window.clearTimeout(this.cancelFlippingTimer); - } - - // If we're unmounting logs (e.g. switching to another datasource), we need to remove the table specific panel state, otherwise it will persist in the explore url - if ( - this.props?.panelState?.logs?.columns || - this.props?.panelState?.logs?.refId || - this.props?.panelState?.logs?.labelFieldName - ) { - dispatch( - changePanelState(this.props.exploreId, 'logs', { - ...this.props.panelState?.logs, - columns: undefined, - visualisationType: this.state.visualisationType, - labelFieldName: undefined, - refId: undefined, - }) - ); - } - } - - registerLogLevelsWithContentOutline = () => { + const registerLogLevelsWithContentOutline = useCallback(() => { const levelsArr = Object.keys(LogLevelColor); - const logVolumeDataFrames = new Set(this.props.logsVolumeData?.data); + const logVolumeDataFrames = new Set(logsVolumeData?.data); // TODO remove this once filtering multiple log volumes is supported - const numberOfLogVolumes = this.getNumberOfLogVolumes(); + const logVolData = logsVolumeData?.data.filter( + (frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations + ); + const grouped = groupBy(logVolData, 'meta.custom.datasourceName'); + const numberOfLogVolumes = Object.keys(grouped).length; // clean up all current log levels - const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root'); - if (logsParent) { - this.context?.unregisterAllChildren(logsParent.id, 'filter'); + if (unregisterAllChildren) { + unregisterAllChildren((items) => { + const logsParent = items?.find((item) => item.panelId === 'Logs' && item.level === 'root'); + return logsParent?.id; + }, 'filter'); } // check if we have dataFrames that return the same level @@ -255,279 +247,322 @@ class UnthemedLogs extends PureComponent { const logLevels = new Set(sortedLLArray); - if (logLevels.size > 1 && this.props.logsVolumeEnabled && numberOfLogVolumes === 1) { + if (logLevels.size > 1 && logsVolumeEnabled && numberOfLogVolumes === 1) { logLevels.forEach((level) => { - const allLevelsSelected = this.state.hiddenLogLevels.length === 0; - const currentLevelSelected = !this.state.hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr); - this.context?.register({ - title: level.levelStr, - icon: 'gf-logs', - panelId: 'Logs', - level: 'child', - type: 'filter', - highlight: currentLevelSelected && !allLevelsSelected, - onClick: (e: React.MouseEvent) => { - this.toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); - }, - ref: null, - color: LogLevelColor[level.logLevel], - }); + const allLevelsSelected = hiddenLogLevels.length === 0; + const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr); + if (register) { + register({ + title: level.levelStr, + icon: 'gf-logs', + panelId: 'Logs', + level: 'child', + type: 'filter', + highlight: currentLevelSelected && !allLevelsSelected, + onClick: (e: React.MouseEvent) => { + toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); + }, + ref: null, + color: LogLevelColor[level.logLevel], + }); + } }); } - }; + }, [logsVolumeData?.data, unregisterAllChildren, logsVolumeEnabled, hiddenLogLevels, register, toggleLegendRef]); - updatePanelState = (logsPanelState: Partial) => { - const state: ExploreItemState | undefined = getState().explore.panes[this.props.exploreId]; - if (state?.panelsState) { - dispatch( - changePanelState(this.props.exploreId, 'logs', { - ...state.panelsState.logs, - columns: logsPanelState.columns ?? this.props.panelState?.logs?.columns, - visualisationType: logsPanelState.visualisationType ?? this.state.visualisationType, - labelFieldName: logsPanelState.labelFieldName, - refId: logsPanelState.refId ?? this.props.panelState?.logs?.refId, - }) - ); - } - }; - - getNumberOfLogVolumes() { - const data = this.props.logsVolumeData?.data.filter( - (frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations - ); - const grouped = groupBy(data, 'meta.custom.datasourceName'); - const numberOfLogVolumes = Object.keys(grouped).length; - return numberOfLogVolumes; - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { - if (this.props.loading && !prevProps.loading && this.props.panelState?.logs?.id) { + useEffect(() => { + if (loading && !previousLoading && panelState?.logs?.id) { // loading stopped, so we need to remove any permalinked log lines - delete this.props.panelState.logs.id; + delete panelState.logs.id; dispatch( - changePanelState(this.props.exploreId, 'logs', { - ...this.props.panelState, + changePanelState(exploreId, 'logs', { + ...panelState, }) ); } - if (this.props.panelState?.logs?.visualisationType !== prevProps.panelState?.logs?.visualisationType) { - const visualisationType = this.props.panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); + }, [dispatch, exploreId, loading, panelState, previousLoading]); - this.setState({ - visualisationType: visualisationType, + useEffect(() => { + const visualisationType = panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); + setVisualisationType(visualisationType); + + store.set(visualisationTypeKey, visualisationType); + }, [panelState?.logs?.visualisationType]); + + useEffect(() => { + registerLogLevelsWithContentOutline(); + }, [logsVolumeData?.data, hiddenLogLevels, registerLogLevelsWithContentOutline]); + + useUnmount(() => { + if (flipOrderTimer) { + window.clearTimeout(flipOrderTimer.current); + } + if (cancelFlippingTimer) { + window.clearTimeout(cancelFlippingTimer.current); + } + }); + + useUnmount(() => { + // If we're unmounting logs (e.g. switching to another datasource), we need to remove the table specific panel state, otherwise it will persist in the explore url + if (panelState?.logs?.columns || panelState?.logs?.refId || panelState?.logs?.labelFieldName) { + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState?.logs, + columns: undefined, + visualisationType: visualisationType, + labelFieldName: undefined, + refId: undefined, + }) + ); + } + }); + + const updatePanelState = useCallback( + (logsPanelState: Partial) => { + const state: ExploreItemState | undefined = getState().explore.panes[exploreId]; + if (state?.panelsState) { + dispatch( + changePanelState(exploreId, 'logs', { + ...state.panelsState.logs, + columns: logsPanelState.columns ?? panelState?.logs?.columns, + visualisationType: logsPanelState.visualisationType ?? visualisationType, + labelFieldName: logsPanelState.labelFieldName, + refId: logsPanelState.refId ?? panelState?.logs?.refId, + }) + ); + } + }, + [dispatch, exploreId, panelState?.logs?.columns, panelState?.logs?.refId, visualisationType] + ); + + // actions + const onLogRowHover = useCallback( + (row?: LogRowModel) => { + if (!row) { + props.eventBus.publish(new DataHoverClearEvent()); + } else { + props.eventBus.publish( + new DataHoverEvent({ + point: { + time: row.timeEpochMs, + }, + }) + ); + } + }, + [props.eventBus] + ); + + const onLogsContainerRef = useCallback( + (node: HTMLDivElement) => { + logsContainerRef.current = node; + + // In theory this should be just a function passed down to LogRows but: + // - LogRow.componentDidMount which calls scrollIntoView is called BEFORE the logsContainerRef is set + // - the if check below if (logsContainerRef.current) was falsy and scrolling doesn't happen + // - and LogRow.scrollToLogRow marks the line as scrolled anyway (and won't perform scrolling when the ref is set) + // - see more details in https://github.com/facebook/react/issues/29897 + // We can change it once LogRow is converted into a functional component + setScrollIntoView(() => (element: HTMLElement) => { + if (config.featureToggles.logsInfiniteScrolling) { + if (logsContainerRef.current) { + topLogsRef.current?.scrollIntoView(); + logsContainerRef.current.scroll({ + behavior: 'smooth', + top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, + }); + } + + return; + } + const scrollElement = props.scrollElement; + + if (scrollElement) { + scrollElement.scroll({ + behavior: 'smooth', + top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, + }); + } }); - store.set(visualisationTypeKey, visualisationType); - } + }, + [props.scrollElement] + ); - if ( - prevProps.logsVolumeData?.data !== this.props.logsVolumeData?.data || - prevState.hiddenLogLevels !== this.state.hiddenLogLevels - ) { - this.registerLogLevelsWithContentOutline(); - } - } - - onLogRowHover = (row?: LogRowModel) => { - if (!row) { - this.props.eventBus.publish(new DataHoverClearEvent()); - } else { - this.props.eventBus.publish( - new DataHoverEvent({ - point: { - time: row.timeEpochMs, - }, - }) - ); - } - }; - - onLogsContainerRef = (node: HTMLDivElement) => { - this.setState({ logsContainer: node }); - }; - - onChangeLogsSortOrder = () => { - this.setState({ isFlipping: true }); + const onChangeLogsSortOrder = () => { + setIsFlipping(true); // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs - this.flipOrderTimer = window.setTimeout(() => { - this.setState((prevState) => { - const newSortOrder = - prevState.logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; - store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); - return { logsSortOrder: newSortOrder }; - }); + flipOrderTimer.current = window.setTimeout(() => { + const newSortOrder = + logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; + store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); + setLogsSortOrder(newSortOrder); }, 0); - this.cancelFlippingTimer = window.setTimeout(() => this.setState({ isFlipping: false }), 1000); + cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000); }; - onEscapeNewlines = () => { - this.setState((prevState) => ({ - forceEscape: !prevState.forceEscape, - })); - }; + const onEscapeNewlines = useCallback(() => { + setForceEscape(!forceEscape); + }, [forceEscape]); - onChangeVisualisation = (visualisation: LogsVisualisationType) => { - this.setState(() => ({ - visualisationType: visualisation, - })); - const payload = { - ...this.props.panelState?.logs, - visualisationType: visualisation, - }; - this.updatePanelState(payload); + const onChangeVisualisation = useCallback( + (visualisation: LogsVisualisationType) => { + setVisualisationType(visualisation); + const payload = { + ...panelState?.logs, + visualisationType: visualisation, + }; + updatePanelState(payload); - reportInteraction('grafana_explore_logs_visualisation_changed', { - newVisualizationType: visualisation, - datasourceType: this.props.datasourceType ?? 'unknown', - defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs', - }); - }; + reportInteraction('grafana_explore_logs_visualisation_changed', { + newVisualizationType: visualisation, + datasourceType: props.datasourceType ?? 'unknown', + defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs', + }); + }, + [panelState?.logs, props.datasourceType, updatePanelState] + ); - onChangeDedup = (dedupStrategy: LogsDedupStrategy) => { - reportInteraction('grafana_explore_logs_deduplication_clicked', { - deduplicationType: dedupStrategy, - datasourceType: this.props.datasourceType, - }); - this.setState({ dedupStrategy }); - }; + const onChangeDedup = useCallback( + (dedupStrategy: LogsDedupStrategy) => { + reportInteraction('grafana_explore_logs_deduplication_clicked', { + deduplicationType: dedupStrategy, + datasourceType: props.datasourceType, + }); + setDedupStrategy(dedupStrategy); + }, + [props.datasourceType] + ); - onChangeLabels = (event: React.ChangeEvent) => { + const onChangeLabels = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const showLabels = target.checked; - this.setState({ - showLabels, - }); + + setShowLabels(showLabels); store.set(SETTINGS_KEYS.showLabels, showLabels); } - }; + }, []); - onChangeTime = (event: React.ChangeEvent) => { + const onChangeShowTime = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const showTime = target.checked; - this.setState({ - showTime, - }); + + setShowTime(showTime); store.set(SETTINGS_KEYS.showTime, showTime); } - }; + }, []); - onChangeWrapLogMessage = (event: React.ChangeEvent) => { + const onChangeWrapLogMessage = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const wrapLogMessage = target.checked; - this.setState({ - wrapLogMessage, - }); + + setWrapLogMessage(wrapLogMessage); store.set(SETTINGS_KEYS.wrapLogMessage, wrapLogMessage); } - }; + }, []); - onChangePrettifyLogMessage = (event: React.ChangeEvent) => { + const onChangePrettifyLogMessage = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const prettifyLogMessage = target.checked; - this.setState({ - prettifyLogMessage, - }); + + setPrettifyLogMessage(prettifyLogMessage); store.set(SETTINGS_KEYS.prettifyLogMessage, prettifyLogMessage); } - }; + }, []); - onToggleLogLevel = (hiddenRawLevels: string[]) => { + const onToggleLogLevel = useCallback((hiddenRawLevels: string[]) => { const hiddenLogLevels = hiddenRawLevels.map((level) => getLogLevelFromKey(level)); - this.setState({ hiddenLogLevels }); - }; + setHiddenLogLevels(hiddenLogLevels); + }, []); - onToggleLogsVolumeCollapse = (collapsed: boolean) => { - this.props.onSetLogsVolumeEnabled(!collapsed); - reportInteraction('grafana_explore_logs_histogram_toggle_clicked', { - datasourceType: this.props.datasourceType, - type: !collapsed ? 'open' : 'close', - }); - }; - - onClickScan = (event: React.SyntheticEvent) => { - event.preventDefault(); - if (this.props.onStartScanning) { - this.props.onStartScanning(); - reportInteraction('grafana_explore_logs_scanning_button_clicked', { - type: 'start', - datasourceType: this.props.datasourceType, + const onToggleLogsVolumeCollapse = useCallback( + (collapsed: boolean) => { + props.onSetLogsVolumeEnabled(!collapsed); + reportInteraction('grafana_explore_logs_histogram_toggle_clicked', { + datasourceType: props.datasourceType, + type: !collapsed ? 'open' : 'close', }); - } - }; + }, + [props] + ); - onClickStopScan = (event: React.SyntheticEvent) => { - event.preventDefault(); - if (this.props.onStopScanning) { - this.props.onStopScanning(); - } - }; + const onClickScan = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + if (props.onStartScanning) { + props.onStartScanning(); + reportInteraction('grafana_explore_logs_scanning_button_clicked', { + type: 'start', + datasourceType: props.datasourceType, + }); + } + }, + [props] + ); - showField = (key: string) => { - const index = this.state.displayedFields.indexOf(key); + const onClickStopScan = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + if (props.onStopScanning) { + props.onStopScanning(); + } + }, + [props] + ); - if (index === -1) { - this.setState((state) => { - return { - displayedFields: state.displayedFields.concat(key), - }; - }); - } - }; + const showField = useCallback( + (key: string) => { + const index = displayedFields.indexOf(key); - hideField = (key: string) => { - const index = this.state.displayedFields.indexOf(key); - if (index > -1) { - this.setState((state) => { - return { - displayedFields: state.displayedFields.filter((k) => key !== k), - }; - }); - } - }; + if (index === -1) { + setDisplayedFields(displayedFields.concat(key)); + } + }, + [displayedFields] + ); - clearDetectedFields = () => { - this.setState((state) => { - return { - displayedFields: [], - }; + const hideField = useCallback( + (key: string) => { + const index = displayedFields.indexOf(key); + if (index > -1) { + setDisplayedFields(displayedFields.filter((k) => key !== k)); + } + }, + [displayedFields] + ); + + const clearDetectedFields = useCallback(() => { + setDisplayedFields([]); + }, []); + + const onCloseCallbackRef = useRef<() => void>(() => {}); + + let onCloseContext = useCallback(() => { + setContextOpen(false); + setContextRow(undefined); + reportInteraction('grafana_explore_logs_log_context_closed', { + datasourceType: contextRow?.datasourceType, + logRowUid: contextRow?.uid, }); - }; + onCloseCallbackRef?.current(); + }, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]); - onCloseContext = () => { - this.setState({ - contextOpen: false, - contextRow: undefined, - }); - }; - - onOpenContext = (row: LogRowModel, onClose: () => void) => { + const onOpenContext = (row: LogRowModel, onClose: () => void) => { // we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open - this.setState({ - contextOpen: true, - contextRow: row, - }); + setContextOpen(true); + setContextRow(row); reportInteraction('grafana_explore_logs_log_context_opened', { datasourceType: row.datasourceType, logRowUid: row.uid, }); - this.onCloseContext = () => { - this.setState({ - contextOpen: false, - contextRow: undefined, - }); - reportInteraction('grafana_explore_logs_log_context_closed', { - datasourceType: row.datasourceType, - logRowUid: row.uid, - }); - onClose(); - }; + onCloseCallbackRef.current = onClose; }; - getPreviousLog(row: LogRowModel, allLogs: LogRowModel[]): LogRowModel | null { + const getPreviousLog = useCallback((row: LogRowModel, allLogs: LogRowModel[]) => { for (let i = allLogs.indexOf(row) - 1; i >= 0; i--) { if (allLogs[i].timeEpochMs > row.timeEpochMs) { return allLogs[i]; @@ -535,39 +570,42 @@ class UnthemedLogs extends PureComponent { } return null; - } + }, []); - getPermalinkRange(row: LogRowModel) { - const range = { - from: new Date(this.props.absoluteRange.from).toISOString(), - to: new Date(this.props.absoluteRange.to).toISOString(), - }; - if (!config.featureToggles.logsInfiniteScrolling) { - return range; - } - - // With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust - // Look for the previous sibling log, and use its timestamp - const allLogs = this.props.logRows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId); - const prevLog = this.getPreviousLog(row, allLogs); - - if (row.timeEpochMs > this.props.absoluteRange.to && !prevLog) { - // Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval - // This only happens when you scroll into the future and you want to share the first log of the list - return { - from: new Date(this.props.absoluteRange.from).toISOString(), - // Slide 1ms otherwise it's very likely to be omitted in the results - to: new Date(row.timeEpochMs + 1).toISOString(), + const getPermalinkRange = useCallback( + (row: LogRowModel) => { + const range = { + from: new Date(absoluteRange.from).toISOString(), + to: new Date(absoluteRange.to).toISOString(), }; - } + if (!config.featureToggles.logsInfiniteScrolling) { + return range; + } - return { - from: new Date(this.props.absoluteRange.from).toISOString(), - to: new Date(prevLog ? prevLog.timeEpochMs : this.props.absoluteRange.to).toISOString(), - }; - } + // With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust + // Look for the previous sibling log, and use its timestamp + const allLogs = logRows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId); + const prevLog = getPreviousLog(row, allLogs); - onPermalinkClick = async (row: LogRowModel) => { + if (row.timeEpochMs > absoluteRange.to && !prevLog) { + // Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval + // This only happens when you scroll into the future and you want to share the first log of the list + return { + from: new Date(absoluteRange.from).toISOString(), + // Slide 1ms otherwise it's very likely to be omitted in the results + to: new Date(row.timeEpochMs + 1).toISOString(), + }; + } + + return { + from: new Date(absoluteRange.from).toISOString(), + to: new Date(prevLog ? prevLog.timeEpochMs : absoluteRange.to).toISOString(), + }; + }, + [absoluteRange.from, absoluteRange.to, getPreviousLog, logRows] + ); + + const onPermalinkClick = async (row: LogRowModel) => { // this is an extra check, to be sure that we are not // creating permalinks for logs without an id-field. // normally it should never happen, because we do not @@ -577,12 +615,12 @@ class UnthemedLogs extends PureComponent { } // get explore state, add log-row-id and make timerange absolute - const urlState = getUrlStateFromPaneState(getState().explore.panes[this.props.exploreId]!); + const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); urlState.panelsState = { - ...this.props.panelState, - logs: { id: row.uid, visualisationType: this.state.visualisationType ?? getDefaultVisualisationType() }, + ...panelState, + logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType() }, }; - urlState.range = this.getPermalinkRange(row); + urlState.range = getPermalinkRange(row); // append changed urlState to baseUrl const serializedState = serializeStateToUrlParam(urlState); @@ -597,92 +635,40 @@ class UnthemedLogs extends PureComponent { }); }; - scrollIntoView = (element: HTMLElement) => { + const scrollToTopLogs = useCallback(() => { if (config.featureToggles.logsInfiniteScrolling) { - if (this.state.logsContainer) { - this.topLogsRef.current?.scrollIntoView(); - this.state.logsContainer.scroll({ - behavior: 'smooth', - top: this.state.logsContainer.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, - }); - } - - return; - } - const { scrollElement } = this.props; - - if (scrollElement) { - scrollElement.scroll({ - behavior: 'smooth', - top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, - }); - } - }; - - checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => { - return !!logRows.some((r) => r.hasUnescapedContent); - }); - - dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => { - const dedupedRows = dedupLogRows(logRows, dedupStrategy); - const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0); - return { dedupedRows, dedupCount }; - }); - - filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { - return filterLogLevels(logRows, new Set(hiddenLogLevels)); - }); - - createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: number; to: number } | undefined => { - if (!logRows || logRows.length === 0) { - return undefined; - } - const firstTimeStamp = logRows[0].timeEpochMs; - const lastTimeStamp = logRows[logRows.length - 1].timeEpochMs; - - if (lastTimeStamp < firstTimeStamp) { - return { from: lastTimeStamp, to: firstTimeStamp }; - } - - return { from: firstTimeStamp, to: lastTimeStamp }; - }); - - scrollToTopLogs = () => { - if (config.featureToggles.logsInfiniteScrolling) { - if (this.state.logsContainer) { - this.state.logsContainer.scroll({ + if (logsContainerRef.current) { + logsContainerRef.current.scroll({ behavior: 'auto', top: 0, }); } } - this.topLogsRef.current?.scrollIntoView(); - }; + topLogsRef.current?.scrollIntoView(); + }, [logsContainerRef, topLogsRef]); - onPinToContentOutlineClick = (row: LogRowModel) => { - if (this.getPinnedLogsCount() === PINNED_LOGS_LIMIT) { - this.setState({ - pinLineButtonTooltipTitle: ( - - ❗️ - - Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. - - - ), - }); + const onPinToContentOutlineClick = (row: LogRowModel) => { + if (getPinnedLogsCount() === PINNED_LOGS_LIMIT) { + setPinLineButtonTooltipTitle( + + ❗️ + + Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. + + + ); return; } // find the Logs parent item - const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root'); + const logsParent = outlineItems?.find((item) => item.panelId === 'Logs' && item.level === 'root'); //update the parent's expanded state - if (logsParent) { - this.context?.updateItem(logsParent.id, { expanded: true }); + if (logsParent && updateItem) { + updateItem(logsParent.id, { expanded: true }); } - this.context?.register({ + register?.({ icon: 'gf-logs', title: 'Pinned log', panelId: 'Logs', @@ -690,362 +676,306 @@ class UnthemedLogs extends PureComponent { ref: null, color: LogLevelColor[row.logLevel], childOnTop: true, - onClick: () => this.onOpenContext(row, () => {}), + onClick: () => onOpenContext(row, () => {}), onRemove: (id: string) => { - this.context?.unregister(id); - if (this.getPinnedLogsCount() < PINNED_LOGS_LIMIT) { - this.setState({ - pinLineButtonTooltipTitle: 'Pin to content outline', - }); + unregister?.(id); + if (getPinnedLogsCount() < PINNED_LOGS_LIMIT) { + setPinLineButtonTooltipTitle('Pin to content outline'); } }, }); - this.props.onPinLineCallback?.(); + props.onPinLineCallback?.(); }; - getPinnedLogsCount = () => { - const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root'); + const getPinnedLogsCount = () => { + const logsParent = outlineItems?.find((item) => item.panelId === 'Logs' && item.level === 'root'); return logsParent?.children?.filter((child) => child.title === 'Pinned log').length ?? 0; }; - render() { - const { - width, - splitOpen, - logRows, - logsMeta, - logsVolumeEnabled, - logsVolumeData, - loadLogsVolumeData, - loading = false, - onClickFilterLabel, - onClickFilterOutLabel, - timeZone, - scanning, - scanRange, - showContextToggle, - absoluteRange, - onChangeTime, - getFieldLinks, - theme, - logsQueries, - clearCache, - addResultsToCache, - exploreId, - getRowContext, - getLogRowContextUi, - getRowContextQuery, - loadMoreLogs, - } = this.props; + const hasUnescapedContent = checkUnescapedContent(logRows); + const filteredLogs = filterRows(logRows, hiddenLogLevels); + const { dedupedRows, dedupCount } = dedupRows(filteredLogs, dedupStrategy); + const navigationRange = createNavigationRange(logRows); - const { - showLabels, - showTime, - wrapLogMessage, - prettifyLogMessage, - dedupStrategy, - hiddenLogLevels, - logsSortOrder, - isFlipping, - displayedFields, - forceEscape, - contextOpen, - contextRow, - } = this.state; - - const tableHeight = getLogsTableHeight(); - const styles = getStyles(theme, wrapLogMessage, tableHeight); - const hasData = logRows && logRows.length > 0; - const hasUnescapedContent = this.checkUnescapedContent(logRows); - - const filteredLogs = this.filterRows(logRows, hiddenLogLevels); - const { dedupedRows, dedupCount } = this.dedupRows(filteredLogs, dedupStrategy); - const navigationRange = this.createNavigationRange(logRows); - - const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; - - return ( - <> - {getRowContext && contextRow && ( - getRowContext(row, contextRow, options)} - getRowContextQuery={getRowContextQuery} - getLogRowContextUi={getLogRowContextUi} - logsSortOrder={logsSortOrder} + return ( + <> + {getRowContext && contextRow && ( + getRowContext(row, contextRow, options)} + getRowContextQuery={getRowContextQuery} + getLogRowContextUi={getLogRowContextUi} + logsSortOrder={logsSortOrder} + timeZone={timeZone} + /> + )} + + {logsVolumeEnabled && ( + onToggleLogsVolumeCollapse(true)} /> )} - - {logsVolumeEnabled && ( - this.onToggleLogsVolumeCollapse(true)} - /> - )} - - - - - ) - ) : null, - ]} - title={'Logs'} - actions={ - <> - {config.featureToggles.logsExploreTableVisualisation && ( -
      - -
      - )} - - } - loadingState={loading ? LoadingState.Loading : LoadingState.Done} - > -
      - {this.state.visualisationType !== 'table' && ( -
      - - - - - - - - - - - - - - - ({ - label: capitalize(dedupType), - value: dedupType, - description: LogsDedupDescription[dedupType], - }))} - value={dedupStrategy} - onChange={this.onChangeDedup} - className={styles.radioButtons} - /> - - - -
      - - - -
      -
      - )} -
      - -
      -
      - {this.state.visualisationType === 'table' && hasData && ( -
      - {/* Width should be full width minus logs navigation and padding */} - + + + + ) + ) : null, + ]} + title={'Logs'} + actions={ + <> + {config.featureToggles.logsExploreTableVisualisation && ( +
      +
      )} - {this.state.visualisationType === 'logs' && hasData && ( -
      - - + } + loadingState={loading ? LoadingState.Loading : LoadingState.Done} + > +
      + {visualisationType !== 'table' && ( +
      + + + - + + + + + + + + + + + + ({ + label: capitalize(dedupType), + value: dedupType, + description: LogsDedupDescription[dedupType], + }))} + value={dedupStrategy} + onChange={onChangeDedup} + className={styles.radioButtons} + /> + + + +
      + + +
      - )} - {!loading && !hasData && !scanning && ( -
      -
      - No logs found. - -
      +
      + )} +
      + +
      +
      + {visualisationType === 'table' && hasData && ( +
      + {/* Width should be full width minus logs navigation and padding */} + +
      + )} + {visualisationType === 'logs' && hasData && ( +
      + + + +
      + )} + {!loading && !hasData && !scanning && ( +
      +
      + No logs found. +
      - )} - {scanning && ( -
      -
      - {scanText} - -
      +
      + )} + {scanning && ( +
      +
      + {scanText} +
      - )} - -
      - - - ); - } -} +
      + )} + +
      + + + ); +}; export const Logs = withTheme2(UnthemedLogs); @@ -1113,3 +1043,31 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n }), }; }; + +const checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => { + return logRows.some((r) => r.hasUnescapedContent); +}); + +const dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => { + const dedupedRows = dedupLogRows(logRows, dedupStrategy); + const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0); + return { dedupedRows, dedupCount }; +}); + +const filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { + return filterLogLevels(logRows, new Set(hiddenLogLevels)); +}); + +const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: number; to: number } | undefined => { + if (!logRows || logRows.length === 0) { + return undefined; + } + const firstTimeStamp = logRows[0].timeEpochMs; + const lastTimeStamp = logRows[logRows.length - 1].timeEpochMs; + + if (lastTimeStamp < firstTimeStamp) { + return { from: lastTimeStamp, to: firstTimeStamp }; + } + + return { from: firstTimeStamp, to: lastTimeStamp }; +}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx index a62a3366b6c..a5d495ee346 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx @@ -41,6 +41,7 @@ export function QueryTemplatesList() { const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; return { index: index.toString(), + uid: queryTemplate.uid, datasourceRef, datasourceType, createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx index 6dd1c181d69..4ffd14edad6 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx @@ -1,16 +1,67 @@ import React from 'react'; +import { reportInteraction, getAppEvents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { IconButton } from '@grafana/ui'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { t } from 'app/core/internationalization'; +import { useDeleteQueryTemplateMutation } from 'app/features/query-library'; +import { dispatch } from 'app/store/store'; +import { ShowConfirmModalEvent } from 'app/types/events'; import ExploreRunQueryButton from '../../ExploreRunQueryButton'; +import { useQueryLibraryListStyles } from './styles'; + interface ActionsCellProps { + queryUid?: string; query?: DataQuery; rootDatasourceUid?: string; } -function ActionsCell({ query, rootDatasourceUid }: ActionsCellProps) { - return ; +function ActionsCell({ query, rootDatasourceUid, queryUid }: ActionsCellProps) { + const [deleteQueryTemplate] = useDeleteQueryTemplateMutation(); + const styles = useQueryLibraryListStyles(); + + const onDeleteQuery = (queryUid: string) => { + const performDelete = (queryUid: string) => { + deleteQueryTemplate({ uid: queryUid }); + dispatch(notifyApp(createSuccessNotification(t('explore.query-library.query-deleted', 'Query deleted')))); + reportInteraction('grafana_explore_query_library_deleted'); + }; + + getAppEvents().publish( + new ShowConfirmModalEvent({ + title: t('explore.query-library.delete-query-title', 'Delete query'), + text: t( + 'explore.query-library.delete-query-text', + "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?" + ), + yesText: t('query-library.delete-query-button', 'Delete query'), + icon: 'trash-alt', + onConfirm: () => performDelete(queryUid), + }) + ); + }; + + return ( +
      + { + if (queryUid) { + onDeleteQuery(queryUid); + } + }} + /> + +
      + ); } export default ActionsCell; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx index 283aef4dc69..5654cb98577 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx @@ -26,7 +26,7 @@ const columns: Array> = [ id: 'actions', header: '', cell: ({ row: { original } }) => ( - + ), }, ]; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx index 1c7897e6e5d..715cd5d235b 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx @@ -34,4 +34,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ WebkitLineClamp: 1, overflow: 'hidden', }), + cell: css({ + display: 'flex', + alignItems: 'center', + '&:last-child': { + justifyContent: 'end', + }, + }), + actionButton: css({ + padding: theme.spacing(1), + }), }); diff --git a/public/app/features/explore/RichHistory/RichHistory.test.tsx b/public/app/features/explore/RichHistory/RichHistory.test.tsx index ff53cbd57f4..b938b63fc2e 100644 --- a/public/app/features/explore/RichHistory/RichHistory.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.test.tsx @@ -67,7 +67,7 @@ const setup = (propOverrides?: Partial) => { describe('RichHistory', () => { it('should render tabs as defined', () => { setup(); - const tabs = screen.getAllByLabelText(/Tab*/); + const tabs = screen.getAllByRole('tab'); expect(tabs).toHaveLength(3); expect(tabs[0]).toHaveTextContent('Query history'); expect(tabs[1]).toHaveTextContent('Starred'); @@ -76,7 +76,7 @@ describe('RichHistory', () => { it('should render defined default', () => { setup(); - const tabs = screen.getAllByLabelText(/Tab*/); + const tabs = screen.getAllByRole('tab'); expect(tabs[0].className).toMatch(/-*activeTabStyle/); expect(tabs[1].className).not.toMatch(/-*activeTabStyle/); }); diff --git a/public/app/features/explore/spec/helper/assert.ts b/public/app/features/explore/spec/helper/assert.ts index 9ed88e92283..664109527db 100644 --- a/public/app/features/explore/spec/helper/assert.ts +++ b/public/app/features/explore/spec/helper/assert.ts @@ -65,7 +65,7 @@ export const assertQueryHistoryComment = async (expectedQueryComments: string[]) }; export const assertQueryHistoryTabIsSelected = (tabName: 'Query history' | 'Starred' | 'Settings') => { - expect(withinQueryHistory().getByRole('tab', { name: `Tab ${tabName}`, selected: true })).toBeInTheDocument(); + expect(withinQueryHistory().getByRole('tab', { name: tabName, selected: true })).toBeInTheDocument(); }; export const assertDataSourceFilterVisibility = (visible: boolean) => { diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts index e3deed335aa..4f8ed1fbdf6 100644 --- a/public/app/features/explore/spec/helper/interactions.ts +++ b/public/app/features/explore/spec/helper/interactions.ts @@ -38,14 +38,14 @@ export const openQueryLibrary = async () => { await userEvent.click(button); await waitFor(async () => { screen.getByRole('tab', { - name: /tab query library/i, + name: /query library/i, }); }); }; export const switchToQueryHistory = async () => { const tab = screen.getByRole('tab', { - name: /tab query history/i, + name: /query history/i, }); await userEvent.click(tab); }; @@ -71,7 +71,7 @@ export const closeQueryHistory = async () => { }; export const switchToQueryHistoryTab = async (name: 'Settings' | 'Query History') => { - await userEvent.click(withinQueryHistory().getByRole('tab', { name: `Tab ${name}` })); + await userEvent.click(withinQueryHistory().getByRole('tab', { name })); }; export const selectStarredTabFirst = async () => { diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 2f453f17c25..306fc6ed3f1 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -270,7 +270,7 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): }; } - if (initializeExplore.pending.match(action)) { + if (initializeExplore?.pending.match(action)) { const initialPanes = Object.entries(state.panes); const before = initialPanes.slice(0, action.meta.arg.position); const after = initialPanes.slice(before.length); diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index fe161e3111e..a06edb4bdf1 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -17,7 +17,7 @@ import { reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { withTheme2, Themeable2, Icon, Tooltip, PopoverContent } from '@grafana/ui'; -import { checkLogsError, escapeUnescapedString } from '../utils'; +import { checkLogsError, escapeUnescapedString, checkLogsSampled } from '../utils'; import { LogDetails } from './LogDetails'; import { LogLabels } from './LogLabels'; @@ -222,6 +222,7 @@ class UnThemedLogRow extends PureComponent { const { showDetails, showingContext, permalinked } = this.state; const levelStyles = getLogLevelStyles(theme, row.logLevel); const { errorMessage, hasError } = checkLogsError(row); + const { sampleMessage, isSampled } = checkLogsSampled(row); const logRowBackground = cx(styles.logsRow, { [styles.errorLogRow]: hasError, [styles.highlightBackground]: showingContext || permalinked, @@ -255,13 +256,22 @@ class UnThemedLogRow extends PureComponent { )} {hasError && ( )} + {isSampled && ( + + + + )} { if (document.getSelection()?.toString()) { return; } + this.closePopoverMenu(); }; closePopoverMenu = () => { diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index e2142cdf592..e3609dae455 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -117,9 +117,16 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { width: 4em; cursor: default; `, - logIconError: css` - color: ${theme.colors.warning.main}; - `, + logIconError: css({ + color: theme.colors.warning.main, + position: 'relative', + top: '-2px', + }), + logIconInfo: css({ + color: theme.colors.info.main, + position: 'relative', + top: '-2px', + }), logsRowToggleDetails: css` label: logs-row-toggle-details__level; font-size: 9px; diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 150caee3740..4d8f88c5641 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -154,6 +154,22 @@ export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorM }; }; +export const checkLogsSampled = (logRow: LogRowModel): { isSampled: boolean; sampleMessage?: string } => { + if (logRow.labels.__adaptive_logs_sampled__) { + let msg = + logRow.labels.__adaptive_logs_sampled__ === 'true' + ? 'Logs like this one have been dropped by Adaptive Logs' + : `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`; + return { + isSampled: true, + sampleMessage: msg, + }; + } + return { + isSampled: false, + }; +}; + export const escapeUnescapedString = (string: string) => string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); diff --git a/public/app/features/playlist/api.ts b/public/app/features/playlist/api.ts index 25033582d50..dd778f8903d 100644 --- a/public/app/features/playlist/api.ts +++ b/public/app/features/playlist/api.ts @@ -71,7 +71,9 @@ class K8sAPI implements PlaylistAPI { async createPlaylist(playlist: Playlist): Promise { const body = this.playlistAsK8sResource(playlist); - await withErrorHandling(() => this.server.create(body)); + await withErrorHandling(async () => { + await this.server.create(body); + }); } async updatePlaylist(playlist: Playlist): Promise { diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index 9ae2cac8c11..bcec74edd0e 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -271,7 +271,7 @@ describe('Plugin details page', () => { ); // Check if version information is available - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.VERSIONS}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.VERSIONS })).toBeInTheDocument(); // Check the column headers expect(getByRole('columnheader', { name: /version/i })).toBeInTheDocument(); @@ -430,7 +430,7 @@ describe('Plugin details page', () => { }); // Wait for the install controls to be loaded - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); // Open the confirmation modal await userEvent.click(getByRole('button', { name: /uninstall/i })); @@ -682,7 +682,7 @@ describe('Plugin details page', () => { isPublished: false, }); - expect(await queryByRole('tab', { name: `Tab ${PluginTabLabels.VERSIONS}` })).not.toBeInTheDocument(); + expect(await queryByRole('tab', { name: PluginTabLabels.VERSIONS })).not.toBeInTheDocument(); }); it('should not display update for plugins not published to gcom', async () => { @@ -694,7 +694,7 @@ describe('Plugin details page', () => { isPublished: false, }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument(); }); @@ -708,7 +708,7 @@ describe('Plugin details page', () => { isPublished: false, }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument(); }); @@ -722,7 +722,7 @@ describe('Plugin details page', () => { isPublished: false, }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument(); }); @@ -812,7 +812,7 @@ describe('Plugin details page', () => { it("should not display an install button for a plugin that isn't installed", async () => { const { queryByRole, findByRole } = renderPluginDetails({ id, isInstalled: false }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument(); }); @@ -820,7 +820,7 @@ describe('Plugin details page', () => { it('should not display an uninstall button for an already installed plugin', async () => { const { queryByRole, findByRole } = renderPluginDetails({ id, isInstalled: true }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument(); }); @@ -828,7 +828,7 @@ describe('Plugin details page', () => { it('should not display update or uninstall buttons for a plugin with update', async () => { const { queryByRole, findByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument(); expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument(); @@ -837,7 +837,7 @@ describe('Plugin details page', () => { it('should not display an install button for enterprise plugins if license is valid', async () => { const { findByRole, queryByRole } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true }); - expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument(); + expect(await findByRole('tab', { name: PluginTabLabels.OVERVIEW })).toBeInTheDocument(); expect(await queryByRole('button', { name: /^install/i })).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts index dc0b26dc2c0..c3c278cb5bf 100644 --- a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts @@ -162,7 +162,7 @@ async function doImportPluginModuleInSandbox(meta: SandboxPluginMeta): Promise `Tab ${tab}`; +const _createTabName = (tab: ExtensionPointComponentTabs) => tab; const _createTabContent = (tabId: ExtensionPointComponentId) => `this is settings for component ${tabId}`; const generalTabName = 'General'; diff --git a/public/app/features/profile/UserSessions.tsx b/public/app/features/profile/UserSessions.tsx index ec9dbb034e7..9d93c5a9e08 100644 --- a/public/app/features/profile/UserSessions.tsx +++ b/public/app/features/profile/UserSessions.tsx @@ -4,7 +4,8 @@ import React, { PureComponent } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { Button, Icon, LoadingPlaceholder } from '@grafana/ui'; -import { i18nDate, Trans } from 'app/core/internationalization'; +import { Trans } from 'app/core/internationalization'; +import { formatDate } from 'app/core/internationalization/dates'; import { UserSession } from 'app/types'; interface Props { @@ -50,7 +51,7 @@ class UserSessions extends PureComponent { {sessions.map((session: UserSession, index) => ( {session.isActive ? Now : {session.seenAt}} - {i18nDate(session.createdAt, { dateStyle: 'long' })} + {formatDate(session.createdAt, { dateStyle: 'long' })} {session.clientIp} {session.browser} on {session.os} {session.osVersion} diff --git a/public/app/features/query-library/api/factory.ts b/public/app/features/query-library/api/factory.ts index bff1973010f..09cc17f76f6 100644 --- a/public/app/features/query-library/api/factory.ts +++ b/public/app/features/query-library/api/factory.ts @@ -1,6 +1,6 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { AddQueryTemplateCommand, QueryTemplate } from '../types'; +import { AddQueryTemplateCommand, DeleteQueryTemplateCommand, QueryTemplate } from '../types'; import { convertAddQueryTemplateCommandToDataQuerySpec, convertDataQueryResponseToQueryTemplates } from './mappers'; import { baseQuery } from './query'; @@ -21,6 +21,13 @@ export const queryLibraryApi = createApi({ }), invalidatesTags: ['QueryTemplatesList'], }), + deleteQueryTemplate: builder.mutation({ + query: ({ uid }) => ({ + url: `${uid}`, + method: 'DELETE', + }), + invalidatesTags: ['QueryTemplatesList'], + }), }), reducerPath: 'queryLibrary', }); diff --git a/public/app/features/query-library/api/query.ts b/public/app/features/query-library/api/query.ts index 80464e1625b..b3baced6237 100644 --- a/public/app/features/query-library/api/query.ts +++ b/public/app/features/query-library/api/query.ts @@ -24,15 +24,20 @@ export enum QueryTemplateKinds { */ export const BASE_URL = `/apis/${API_VERSION}/namespaces/default/querytemplates/`; +// URL is optional for these requests +interface QueryLibraryBackendRequest extends Pick { + url?: string; +} + /** * TODO: similar code is duplicated in many places. To be unified in #86960 */ -export const baseQuery: BaseQueryFn, DataQuerySpecResponse, Error> = async ( +export const baseQuery: BaseQueryFn = async ( requestOptions ) => { try { const responseObservable = getBackendSrv().fetch({ - url: BASE_URL, + url: `${BASE_URL}${requestOptions.url ?? ''}`, showErrorAlert: true, method: requestOptions.method || 'GET', data: requestOptions.data, diff --git a/public/app/features/query-library/index.ts b/public/app/features/query-library/index.ts index 3c2e74e1f81..513b7ca5bb8 100644 --- a/public/app/features/query-library/index.ts +++ b/public/app/features/query-library/index.ts @@ -12,7 +12,8 @@ import { config } from '@grafana/runtime'; import { queryLibraryApi } from './api/factory'; import { mockData } from './api/mocks'; -export const { useAllQueryTemplatesQuery, useAddQueryTemplateMutation } = queryLibraryApi; +export const { useAllQueryTemplatesQuery, useAddQueryTemplateMutation, useDeleteQueryTemplateMutation } = + queryLibraryApi; export function isQueryLibraryEnabled() { return config.featureToggles.queryLibrary; diff --git a/public/app/features/query-library/types.ts b/public/app/features/query-library/types.ts index 2e496259320..e7fbc637d88 100644 --- a/public/app/features/query-library/types.ts +++ b/public/app/features/query-library/types.ts @@ -14,3 +14,7 @@ export type AddQueryTemplateCommand = { title: string; targets: DataQuery[]; }; + +export type DeleteQueryTemplateCommand = { + uid: string; +}; diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 61d3d0beb8f..22aea99cc16 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import { intervalToDuration } from 'date-fns'; import React from 'react'; import Skeleton from 'react-loading-skeleton'; @@ -11,9 +12,10 @@ import { getFieldDisplayName, } from '@grafana/data'; import { config, getDataSourceSrv } from '@grafana/runtime'; -import { Checkbox, Icon, IconName, TagList, Text } from '@grafana/ui'; +import { Checkbox, Icon, IconName, TagList, Text, Tooltip } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { t } from 'app/core/internationalization'; +import { formatDate, formatDuration } from 'app/core/internationalization/dates'; import { PluginIconName } from 'app/features/plugins/admin/types'; import { ShowModalReactEvent } from 'app/types/events'; @@ -25,6 +27,7 @@ import { ExplainScorePopup } from './ExplainScorePopup'; import { TableColumn } from './SearchResultsTable'; const TYPE_COLUMN_WIDTH = 175; +const DURATION_COLUMN_WIDTH = 200; const DATASOURCE_COLUMN_WIDTH = 200; export const generateColumns = ( @@ -112,15 +115,20 @@ export const generateColumns = ( Cell: (p) => { let classNames = cx(styles.nameCellStyle); let name = access.name.values[p.row.index]; + const isDeleted = access.isDeleted?.values[p.row.index]; + if (!name?.length) { const loading = p.row.index >= response.view.dataFrame.length; name = loading ? 'Loading...' : 'Missing title'; // normal for panels classNames += ' ' + styles.missingTitleText; } + return (
      {!response.isItemLoaded(p.row.index) ? ( + ) : isDeleted ? ( + {name} ) : ( {name} @@ -136,9 +144,18 @@ export const generateColumns = ( }); availableWidth -= width; - width = TYPE_COLUMN_WIDTH; - columns.push(makeTypeColumn(response, access.kind, access.panel_type, width, styles)); - availableWidth -= width; + const showDeletedRemaining = + response.view.fields.permanentlyDeleteDate && hasValue(response.view.fields.permanentlyDeleteDate); + + if (showDeletedRemaining && access.permanentlyDeleteDate) { + width = DURATION_COLUMN_WIDTH; + columns.push(makeDeletedRemainingColumn(response, access.permanentlyDeleteDate, width, styles)); + availableWidth -= width; + } else { + width = TYPE_COLUMN_WIDTH; + columns.push(makeTypeColumn(response, access.kind, access.panel_type, width, styles)); + availableWidth -= width; + } // Show datasources if we have any if (access.ds_uid && onDatasourceChange) { @@ -328,6 +345,46 @@ function makeDataSourceColumn( }; } +function makeDeletedRemainingColumn( + response: QueryResponse, + deletedField: Field, + width: number, + styles: Record +): TableColumn { + return { + id: 'column-delete-age', + field: deletedField, + width, + Header: t('search.results-table.deleted-remaining-header', 'Time remaining'), + Cell: (p) => { + const i = p.row.index; + const deletedDate = deletedField.values[i]; + + if (!deletedDate || !response.isItemLoaded(p.row.index)) { + return ( +
      + +
      + ); + } + + const duration = calcCoarseDuration(new Date(), deletedDate); + const isDeletingSoon = !Object.values(duration).some((v) => v > 0); + const formatted = isDeletingSoon + ? t('search.results-table.deleted-less-than-1-min', '< 1 min') + : formatDuration(duration, { style: 'long' }); + + return ( +
      + + {formatted} + +
      + ); + }, + }; +} + function makeTypeColumn( response: QueryResponse, kindField: Field, @@ -442,3 +499,22 @@ function getDisplayValue({ } return formattedValueToString(getDisplay(value)); } + +/** + * Calculates the rough duration between two dates, keeping only the most significant unit + */ +function calcCoarseDuration(start: Date, end: Date) { + let { years = 0, months = 0, days = 0, hours = 0, minutes = 0 } = intervalToDuration({ start, end }); + + if (years > 0) { + return { years }; + } else if (months > 0) { + return { months }; + } else if (days > 0) { + return { days }; + } else if (hours > 0) { + return { hours }; + } + + return { minutes }; +} diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index 15f4bff23b8..d3999434813 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -158,6 +158,8 @@ export class SQLSearcher implements GrafanaSearcher { const tags: string[][] = []; const location: string[] = []; const sortBy: number[] = []; + const isDeleted: boolean[] = []; + const permanentlyDeleteDate: Array = []; let sortMetaName: string | undefined; for (let hit of rsp) { @@ -168,6 +170,8 @@ export class SQLSearcher implements GrafanaSearcher { url.push(hit.url); tags.push(hit.tags); sortBy.push(hit.sortMeta!); + isDeleted.push(hit.isDeleted ?? false); + permanentlyDeleteDate.push(hit.permanentlyDeleteDate ? new Date(hit.permanentlyDeleteDate) : undefined); let v = hit.folderUid; if (!v && k === 'dashboard') { @@ -204,6 +208,8 @@ export class SQLSearcher implements GrafanaSearcher { { name: 'url', type: FieldType.string, config: {}, values: url }, { name: 'tags', type: FieldType.other, config: {}, values: tags }, { name: 'location', type: FieldType.string, config: {}, values: location }, + { name: 'isDeleted', type: FieldType.boolean, config: {}, values: isDeleted }, + { name: 'permanentlyDeleteDate', type: FieldType.time, config: {}, values: permanentlyDeleteDate }, ], length: name.length, meta: { diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index 1dc3748b10d..83ceeb6758a 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -39,6 +39,8 @@ export interface DashboardQueryResult { tags: string[]; location: string; // url that can be split ds_uid: string[]; + isDeleted?: boolean; + permanentlyDeleteDate?: Date; // debugging fields score: number; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index 00f54007d8d..975e6135f12 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -30,6 +30,8 @@ export interface DashboardSearchHit extends WithAccessControlMetadata { url: string; sortMeta?: number; sortMetaName?: string; + isDeleted?: boolean; + permanentlyDeleteDate?: string; } /** diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx index a9ed32ee9b6..e441c5d3dc0 100644 --- a/public/app/features/trails/DataTrailCard.tsx +++ b/public/app/features/trails/DataTrailCard.tsx @@ -1,36 +1,57 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useMemo } from 'react'; import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; import { Card, IconButton, Stack, Tag, useStyles2 } from '@grafana/ui'; import { DataTrail } from './DataTrail'; +import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore'; import { VAR_FILTERS } from './shared'; import { getDataSource, getDataSourceName, getMetricName } from './utils'; -export interface Props { - trail: DataTrail; - onSelect: (trail: DataTrail) => void; +export type Props = { + trail?: DataTrail; + bookmark?: DataTrailBookmark; + onSelect: () => void; onDelete?: () => void; -} +}; -export function DataTrailCard({ trail, onSelect, onDelete }: Props) { +export function DataTrailCard(props: Props) { + const { onSelect, onDelete, bookmark } = props; const styles = useStyles2(getStyles); - const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!; - if (!(filtersVariable instanceof AdHocFiltersVariable)) { + const values = useMemo(() => { + let trail = props.trail || (bookmark && getTrailStore().getTrailForBookmark(bookmark)); + + if (!trail) { + return null; + } + + const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!; + if (!(filtersVariable instanceof AdHocFiltersVariable)) { + return null; + } + + const createdAt = bookmark?.createdAt || trail.state.createdAt; + + return { + dsValue: getDataSource(trail), + filters: filtersVariable.state.filters, + metric: trail.state.metric, + createdAt, + }; + }, [props.trail, bookmark]); + + if (!values) { return null; } - const filters = filtersVariable.state.filters; - const dsValue = getDataSource(trail); - - const onClick = () => onSelect(trail); + const { dsValue, filters, metric, createdAt } = values; return ( - - {getMetricName(trail.state.metric)} + + {getMetricName(metric)}
      {filters.map((f) => ( @@ -43,9 +64,9 @@ export function DataTrailCard({ trail, onSelect, onDelete }: Props) {
      Datasource: {getDataSourceName(dsValue)}
      - {trail.state.createdAt && ( + {createdAt && ( - Created: {dateTimeFormat(trail.state.createdAt, { format: 'LL' })} + Created: {dateTimeFormat(createdAt, { format: 'LL' })} )}
      diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx index e65a65ff035..4e7a81144e0 100644 --- a/public/app/features/trails/DataTrailsHome.tsx +++ b/public/app/features/trails/DataTrailsHome.tsx @@ -10,7 +10,7 @@ import { Text } from '@grafana/ui/src/components/Text/Text'; import { DataTrail } from './DataTrail'; import { DataTrailCard } from './DataTrailCard'; import { DataTrailsApp } from './DataTrailsApp'; -import { getTrailStore } from './TrailStore/TrailStore'; +import { getBookmarkKey, getTrailStore } from './TrailStore/TrailStore'; import { reportExploreMetrics } from './interactions'; import { getDatasourceForNewTrail, getUrlForTrail, newMetricsTrail } from './utils'; @@ -29,9 +29,17 @@ export class DataTrailsHome extends SceneObjectBase { app.goToUrlForTrail(trail); }; - public onSelectTrail = (trail: DataTrail, isBookmark: boolean) => { + public onSelectRecentTrail = (trail: DataTrail) => { const app = getAppFor(this); - reportExploreMetrics('exploration_started', { cause: isBookmark ? 'bookmark_clicked' : 'recent_clicked' }); + reportExploreMetrics('exploration_started', { cause: 'recent_clicked' }); + getTrailStore().setRecentTrail(trail); + app.goToUrlForTrail(trail); + }; + + public onSelectBookmark = (bookmarkIndex: number) => { + const app = getAppFor(this); + reportExploreMetrics('exploration_started', { cause: 'bookmark_clicked' }); + const trail = getTrailStore().getTrailForBookmarkIndex(bookmarkIndex); getTrailStore().setRecentTrail(trail); app.goToUrlForTrail(trail); }; @@ -52,9 +60,6 @@ export class DataTrailsHome extends SceneObjectBase { return ; } - const onSelectRecent = (trail: DataTrail) => model.onSelectTrail(trail, false); - const onSelectBookmark = (trail: DataTrail) => model.onSelectTrail(trail, true); - return (
      @@ -73,7 +78,7 @@ export class DataTrailsHome extends SceneObjectBase { model.onSelectRecentTrail(resolvedTrail)} /> ); })} @@ -83,13 +88,12 @@ export class DataTrailsHome extends SceneObjectBase {
      Bookmarks
      - {getTrailStore().bookmarks.map((trail, index) => { - const resolvedTrail = trail.resolve(); + {getTrailStore().bookmarks.map((bookmark, index) => { return ( model.onSelectBookmark(index)} onDelete={() => onDelete(index)} /> ); diff --git a/public/app/features/trails/TrailStore/TrailStore.test.ts b/public/app/features/trails/TrailStore/TrailStore.test.ts index 871dd2fc55c..d742f81cf9c 100644 --- a/public/app/features/trails/TrailStore/TrailStore.test.ts +++ b/public/app/features/trails/TrailStore/TrailStore.test.ts @@ -4,7 +4,7 @@ import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import { MockDataSourceSrv, mockDataSource } from '../../alerting/unified/mocks'; import { DataTrail } from '../DataTrail'; -import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY, VAR_FILTERS } from '../shared'; +import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY, VAR_FILTERS } from '../shared'; import { SerializedTrail, getTrailStore } from './TrailStore'; @@ -482,11 +482,95 @@ describe('TrailStore', () => { }); }); - describe('Initialize store with one bookmark trail', () => { + describe('Initialize store with one bookmark trail but no recent trails', () => { beforeEach(() => { localStorage.clear(); localStorage.setItem( - BOOKMARKED_TRAILS_KEY, + TRAIL_BOOKMARKS_KEY, + JSON.stringify([ + { + urlValues: { + metric: 'bookmarked_metric', + from: 'now-1h', + to: 'now', + 'var-ds': 'prom-mock', + 'var-filters': [], + refresh: '', + }, + type: 'time', + }, + ]) + ); + getTrailStore().load(); + }); + + const store = getTrailStore(); + + it('should have no recent trails', () => { + expect(store.recent.length).toBe(0); + }); + + it('should accurately load bookmarked trails xx', () => { + expect(store.bookmarks.length).toBe(1); + const trail = store.getTrailForBookmarkIndex(0); + expect(trail.state.metric).toBe('bookmarked_metric'); + }); + + it('should save a new recent trail based on the bookmark', () => { + expect(store.recent.length).toBe(0); + const trail = store.getTrailForBookmarkIndex(0); + // Trail and history must be activated first + trail.activate(); + trail.state.history.activate(); + store.setRecentTrail(trail); + expect(store.recent.length).toBe(1); + }); + + it('should be able to obtain index of bookmark', () => { + const trail = store.getTrailForBookmarkIndex(0); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(0); + }); + + it('index should be undefined for removed bookmarks', () => { + const trail = store.getTrailForBookmarkIndex(0); + store.removeBookmark(0); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(undefined); + }); + + it('index should be undefined for a trail that has changed since it was bookmarked', () => { + const trail = store.getTrailForBookmarkIndex(0); + trail.setState({ metric: 'something_completely_different' }); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(undefined); + }); + + it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => { + const trail = store.getTrailForBookmarkIndex(0); + const bookmarkedMetric = trail.state.metric; + trail.setState({ metric: 'something_completely_different' }); + trail.setState({ metric: bookmarkedMetric }); + const index = store.getBookmarkIndex(trail); + expect(index).toBe(0); + }); + + it('should remove a bookmark', () => { + expect(store.bookmarks.length).toBe(1); + store.removeBookmark(0); + expect(store.bookmarks.length).toBe(0); + + jest.advanceTimersByTime(2000); + + expect(localStorage.getItem(TRAIL_BOOKMARKS_KEY)).toBe('[]'); + }); + }); + + describe('Initialize store with one legacy bookmark trail', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem( + TRAIL_BOOKMARKS_KEY, JSON.stringify([ { history: [ @@ -526,66 +610,18 @@ describe('TrailStore', () => { expect(store.recent.length).toBe(0); }); - it('should accurately load bookmarked trails', () => { + it('should accurately load legacy bookmark', () => { expect(store.bookmarks.length).toBe(1); - const trail = store.bookmarks[0].resolve(); - expect(trail.state.history.state.steps.length).toBe(2); - expect(trail.state.history.state.steps[0].type).toBe('start'); - expect(trail.state.history.state.steps[1].type).toBe('time'); - }); - - it('should save a new recent trail based on the bookmark', () => { - expect(store.recent.length).toBe(0); - const trail = store.bookmarks[0].resolve(); - store.setRecentTrail(trail); - expect(store.recent.length).toBe(1); - }); - - it('should be able to obtain index of bookmark', () => { - const trail = store.bookmarks[0].resolve(); - const index = store.getBookmarkIndex(trail); - expect(index).toBe(0); - }); - - it('index should be undefined for removed bookmarks', () => { - const trail = store.bookmarks[0].resolve(); - store.removeBookmark(0); - const index = store.getBookmarkIndex(trail); - expect(index).toBe(undefined); - }); - - it('index should be undefined for a trail that has changed since it was bookmarked', () => { - const trail = store.bookmarks[0].resolve(); - trail.setState({ metric: 'something_completely_different' }); - const index = store.getBookmarkIndex(trail); - expect(index).toBe(undefined); - }); - - it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => { - const trail = store.bookmarks[0].resolve(); - const bookmarkedMetric = trail.state.metric; - trail.setState({ metric: 'something_completely_different' }); - trail.setState({ metric: bookmarkedMetric }); - const index = store.getBookmarkIndex(trail); - expect(index).toBe(0); - }); - - it('should remove a bookmark', () => { - expect(store.bookmarks.length).toBe(1); - store.removeBookmark(0); - expect(store.bookmarks.length).toBe(0); - - jest.advanceTimersByTime(2000); - - expect(localStorage.getItem(BOOKMARKED_TRAILS_KEY)).toBe('[]'); + const trail = store.getTrailForBookmarkIndex(0); + expect(trail.state.metric).toBe('access_permissions_duration_count'); }); }); - describe('Initialize store with one bookmark trail not on final step', () => { + describe('Initialize store with one legacy bookmark trail not bookmarked on final step', () => { beforeEach(() => { localStorage.clear(); localStorage.setItem( - BOOKMARKED_TRAILS_KEY, + TRAIL_BOOKMARKS_KEY, JSON.stringify([ { history: [ @@ -635,9 +671,87 @@ describe('TrailStore', () => { expect(store.recent.length).toBe(0); }); - it('should accurately load bookmarked trails', () => { + it('should accurately load legacy bookmark', () => { expect(store.bookmarks.length).toBe(1); - const trail = store.bookmarks[0].resolve(); + const trail = store.getTrailForBookmarkIndex(0); + expect(trail.state.metric).toBe('bookmarked_metric'); + }); + }); + + describe('Initialize store with one bookmark matching recent trail not on final step', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem( + RECENT_TRAILS_KEY, + JSON.stringify([ + { + history: [ + { + urlValues: { + from: 'now-1h', + to: 'now', + 'var-ds': 'prom-mock', + 'var-filters': [], + refresh: '', + }, + type: 'start', + }, + { + urlValues: { + metric: 'bookmarked_metric', + from: 'now-1h', + to: 'now', + 'var-ds': 'prom-mock', + 'var-filters': [], + refresh: '', + }, + type: 'time', + }, + { + urlValues: { + metric: 'some_other_metric', + from: 'now-1h', + to: 'now', + 'var-ds': 'prom-mock', + 'var-filters': [], + refresh: '', + }, + type: 'metric', + }, + ], + currentStep: 1, + }, + ]) + ); + localStorage.setItem( + TRAIL_BOOKMARKS_KEY, + JSON.stringify([ + { + urlValues: { + metric: 'bookmarked_metric', + from: 'now-1h', + to: 'now', + 'var-ds': 'prom-mock', + 'var-filters': [], + refresh: '', + }, + type: 'time', + }, + ]) + ); + getTrailStore().load(); + }); + + const store = getTrailStore(); + + it('should have 1 recent trail', () => { + expect(store.recent.length).toBe(1); + }); + + it('should accurately load bookmarked trail from matching recent', () => { + expect(store.bookmarks.length).toBe(1); + expect(store.recent.length).toBe(1); + const trail = store.getTrailForBookmarkIndex(0); expect(trail.state.history.state.steps.length).toBe(3); expect(trail.state.history.state.steps[0].type).toBe('start'); expect(trail.state.history.state.steps[1].type).toBe('time'); @@ -645,34 +759,34 @@ describe('TrailStore', () => { }); it('should save a new recent trail based on the bookmark', () => { - expect(store.recent.length).toBe(0); - const trail = store.bookmarks[0].resolve(); + expect(store.recent.length).toBe(1); + const trail = store.getTrailForBookmarkIndex(0); store.setRecentTrail(trail); expect(store.recent.length).toBe(1); }); it('should be able to obtain index of bookmark', () => { - const trail = store.bookmarks[0].resolve(); + const trail = store.getTrailForBookmarkIndex(0); const index = store.getBookmarkIndex(trail); expect(index).toBe(0); }); it('index should be undefined for removed bookmarks', () => { - const trail = store.bookmarks[0].resolve(); + const trail = store.getTrailForBookmarkIndex(0); store.removeBookmark(0); const index = store.getBookmarkIndex(trail); expect(index).toBe(undefined); }); it('index should be undefined for a trail that has changed since it was bookmarked', () => { - const trail = store.bookmarks[0].resolve(); + const trail = store.getTrailForBookmarkIndex(0); trail.setState({ metric: 'something_completely_different' }); const index = store.getBookmarkIndex(trail); expect(index).toBe(undefined); }); it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => { - const trail = store.bookmarks[0].resolve(); + const trail = store.getTrailForBookmarkIndex(0); trail.setState({ metric: 'something_completely_different' }); expect(store.getBookmarkIndex(trail)).toBe(undefined); trail.setState({ metric: 'bookmarked_metric' }); @@ -684,7 +798,7 @@ describe('TrailStore', () => { store.removeBookmark(0); expect(store.bookmarks.length).toBe(0); jest.advanceTimersByTime(2000); - expect(localStorage.getItem(BOOKMARKED_TRAILS_KEY)).toBe('[]'); + expect(localStorage.getItem(TRAIL_BOOKMARKS_KEY)).toBe('[]'); }); }); }); diff --git a/public/app/features/trails/TrailStore/TrailStore.ts b/public/app/features/trails/TrailStore/TrailStore.ts index 69e2ff119b6..e11c9c8abdb 100644 --- a/public/app/features/trails/TrailStore/TrailStore.ts +++ b/public/app/features/trails/TrailStore/TrailStore.ts @@ -7,7 +7,8 @@ import { dispatch } from 'app/store/store'; import { notifyApp } from '../../../core/reducers/appNotification'; import { DataTrail } from '../DataTrail'; import { TrailStepType } from '../DataTrailsHistory'; -import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared'; +import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY } from '../shared'; +import { newMetricsTrail } from '../utils'; import { createBookmarkSavedNotification } from './utils'; @@ -20,13 +21,18 @@ export interface SerializedTrail { description: string; parentIndex: number; }>; - currentStep: number; + currentStep?: number; // Assume last step in history if not specified createdAt?: number; } +export interface DataTrailBookmark { + urlValues: SceneObjectUrlValues; + createdAt: number; +} + export class TrailStore { private _recent: Array> = []; - private _bookmarks: Array> = []; + private _bookmarks: DataTrailBookmark[] = []; private _save: () => void; constructor() { @@ -38,8 +44,7 @@ export class TrailStore { .map((trail) => this._serializeTrail(trail.resolve())); localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent)); - const serializedBookmarks = this._bookmarks.map((trail) => this._serializeTrail(trail.resolve())); - localStorage.setItem(BOOKMARKED_TRAILS_KEY, JSON.stringify(serializedBookmarks)); + localStorage.setItem(TRAIL_BOOKMARKS_KEY, JSON.stringify(this._bookmarks)); }; this._save = debounce(doSave, 1000); @@ -51,9 +56,9 @@ export class TrailStore { }); } - private _loadFromStorage(key: string) { + private _loadRecentTrailsFromStorage() { const list: Array> = []; - const storageItem = localStorage.getItem(key); + const storageItem = localStorage.getItem(RECENT_TRAILS_KEY); if (storageItem) { const serializedTrails: SerializedTrail[] = JSON.parse(storageItem); @@ -65,6 +70,25 @@ export class TrailStore { return list; } + private _loadBookmarksFromStorage() { + const storageItem = localStorage.getItem(TRAIL_BOOKMARKS_KEY); + + const list: Array = storageItem ? JSON.parse(storageItem) : []; + + return list.map((item) => { + if (isSerializedTrail(item)) { + // Take the legacy SerializedTrail implementation of bookmark storage, and extract a DataTrailBookmark + const step = item.currentStep != null ? item.currentStep : item.history.length - 1; + const bookmark: DataTrailBookmark = { + urlValues: item.history[step].urlValues, + createdAt: item.createdAt || Date.now(), + }; + return bookmark; + } + return item; + }); + } + private _deserializeTrail(t: SerializedTrail): DataTrail { // reconstruct the trail based on the serialized history const trail = new DataTrail({ createdAt: t.createdAt }); @@ -107,6 +131,31 @@ export class TrailStore { }; } + public getTrailForBookmarkIndex(index: number) { + const bookmark = this._bookmarks[index]; + if (!bookmark) { + // Create a blank trail + return newMetricsTrail(); + } + return this.getTrailForBookmark(bookmark); + } + + public getTrailForBookmark(bookmark: DataTrailBookmark) { + const key = getBookmarkKey(bookmark); + // Match for recent trails that have the exact same state as the current step + for (const recent of this._recent) { + const trail = recent.resolve(); + if (getBookmarkKey(trail) === key) { + return trail; + } + } + // Just create a new trail with that state + + const trail = new DataTrail({}); + this._loadFromUrl(trail, bookmark.urlValues); + return trail; + } + private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) { const urlState = urlUtil.renderUrl('', urlValues); sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState)); @@ -118,8 +167,8 @@ export class TrailStore { } load() { - this._recent = this._loadFromStorage(RECENT_TRAILS_KEY); - this._bookmarks = this._loadFromStorage(BOOKMARKED_TRAILS_KEY); + this._recent = this._loadRecentTrailsFromStorage(); + this._bookmarks = this._loadBookmarksFromStorage(); this._refreshBookmarkIndexMap(); } @@ -152,8 +201,14 @@ export class TrailStore { } addBookmark(trail: DataTrail) { - const bookmark = new DataTrail(sceneUtils.cloneSceneObjectState(trail.state)); - this._bookmarks.unshift(bookmark.getRef()); + const urlState = getUrlSyncManager().getUrlState(trail); + + const bookmarkState: DataTrailBookmark = { + urlValues: urlState, + createdAt: Date.now(), + }; + + this._bookmarks.unshift(bookmarkState); this._refreshBookmarkIndexMap(); this._save(); dispatch(notifyApp(createBookmarkSavedNotification())); @@ -178,9 +233,7 @@ export class TrailStore { private _refreshBookmarkIndexMap() { this._bookmarkIndexMap.clear(); this._bookmarks.forEach((bookmarked, index) => { - const trail = bookmarked.resolve(); - - const key = getBookmarkKey(trail); + const key = getBookmarkKey(bookmarked); // If there are duplicate bookmarks, the latest index will be kept this._bookmarkIndexMap.set(key, index); }); @@ -190,24 +243,36 @@ export class TrailStore { function getUrlStateForComparison(trail: DataTrail) { const urlState = getUrlSyncManager().getUrlState(trail); // Make a few corrections + correctUrlStateForComparison(urlState); + return urlState; +} + +function correctUrlStateForComparison(urlState: SceneObjectUrlValues) { // Omit some URL parameters that are not useful for state comparison, // as they can change in the URL without creating new steps delete urlState.actionView; delete urlState.layout; delete urlState.metricSearch; + delete urlState.refresh; // Populate defaults - if (urlState['var-groupby'] === '') { + if (urlState['var-groupby'] === '' || urlState['var-groupby'] === undefined) { urlState['var-groupby'] = '$__all'; } + if (typeof urlState['var-filters'] !== 'string') { + urlState['var-filters'] = urlState['var-filters']?.filter((filter) => filter !== ''); + } + return urlState; } -function getBookmarkKey(trail: DataTrail) { - const key = JSON.stringify(getUrlStateForComparison(trail)); - return key; +export function getBookmarkKey(trail: DataTrail | DataTrailBookmark) { + if (trail instanceof DataTrail) { + return JSON.stringify(getUrlStateForComparison(trail)); + } + return JSON.stringify(correctUrlStateForComparison({ ...trail.urlValues })); } let store: TrailStore | undefined; @@ -218,3 +283,7 @@ export function getTrailStore(): TrailStore { return store; } + +function isSerializedTrail(serialized: unknown): serialized is SerializedTrail { + return serialized != null && typeof serialized === 'object' && 'history' in serialized; +} diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index 7c8ed5697b3..4b12725cb9a 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -31,7 +31,8 @@ export const trailDS = { uid: VAR_DATASOURCE_EXPR }; // Local storage keys export const RECENT_TRAILS_KEY = 'grafana.trails.recent'; -export const BOOKMARKED_TRAILS_KEY = 'grafana.trails.bookmarks'; + +export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks'; export type MakeOptional = Pick, K> & Omit; diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index 1de1d638361..538b2c9a97a 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -15,6 +15,7 @@ import { durationToMilliseconds, parseDuration, TransformationApplicabilityLevels, + TimeRange, } from '@grafana/data'; import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { config } from '@grafana/runtime'; @@ -292,7 +293,14 @@ export function prepBucketFrames(frames: DataFrame[]): DataFrame[] { })); } -export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame { +interface HeatmapCalculationOptionsWithTimeRange extends HeatmapCalculationOptions { + timeRange?: TimeRange; +} + +export function calculateHeatmapFromData( + frames: DataFrame[], + options: HeatmapCalculationOptionsWithTimeRange +): DataFrame { // Find fields in the heatmap const { xField, yField, xs, ys } = findHeatmapFields(frames); @@ -329,6 +337,9 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa ySize: yBucketsCfg.value ? +yBucketsCfg.value : undefined, yLog: scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as 2 | 10 | undefined) : undefined, + + xMin: options.timeRange?.from.valueOf(), + xMax: options.timeRange?.to.valueOf(), }); const frame = { @@ -460,20 +471,23 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) { let ySorted = opts?.ySorted ?? false; // find x and y limits to pre-compute buckets struct - let minX = xSorted ? xs[0] : Infinity; + let minX = opts?.xMin ?? (xSorted ? xs[0] : Infinity); let minY = ySorted ? ys[0] : Infinity; - let maxX = xSorted ? xs[len - 1] : -Infinity; + let maxX = opts?.xMax ?? (xSorted ? xs[len - 1] : -Infinity); let maxY = ySorted ? ys[len - 1] : -Infinity; let yExp = opts?.yLog; + let withPredefX = opts?.xMin != null && opts?.xMax != null; + let withPredefY = opts?.yMin != null && opts?.yMax != null; + for (let i = 0; i < len; i++) { - if (!xSorted) { + if (!xSorted && !withPredefX) { minX = Math.min(minX, xs[i]); maxX = Math.max(maxX, xs[i]); } - if (!ySorted) { + if (!ySorted && !withPredefY) { if (!yExp || ys[i] > 0) { minY = Math.min(minY, ys[i]); maxY = Math.max(maxY, ys[i]); @@ -500,7 +514,6 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) { } if (xMode === HeatmapCalculationMode.Count) { - // TODO: optionally use view range min/max instead of data range for bucket sizing let approx = (maxX - minX) / Math.max(xBinIncr - 1, 1); // nice-ify let xIncrs = opts?.xTime ? niceTimeIncrs : niceLinearIncrs; @@ -509,7 +522,6 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) { } if (yMode === HeatmapCalculationMode.Count) { - // TODO: optionally use view range min/max instead of data range for bucket sizing let approx = (maxY - minY) / Math.max(yBinIncr - 1, 1); // nice-ify let yIncrs = opts?.yTime ? niceTimeIncrs : niceLinearIncrs; diff --git a/public/app/features/transformers/docs/content.ts b/public/app/features/transformers/docs/content.ts index c5b76007973..582c92d4723 100644 --- a/public/app/features/transformers/docs/content.ts +++ b/public/app/features/transformers/docs/content.ts @@ -196,6 +196,9 @@ This transformation has the following options: - **Numeric** - attempts to make the values numbers - **String** - will make the values strings - **Time** - attempts to parse the values as time + - The input will be parsed according to the [Moment.js parsing format](https://momentjs.com/docs/#/parsing/) + - It will parse the numeric input as a Unix epoch timestamp in milliseconds. + You must multiply your input by 1000 if it's in seconds. - Will show an option to specify a DateFormat as input by a string like yyyy-mm-dd or DD MM YYYY hh:mm:ss - **Boolean** - will make the values booleans - **Enum** - will make the values enums diff --git a/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx b/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx index 7bd477ba0f0..f3f3155059a 100644 --- a/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx @@ -3,7 +3,8 @@ import { stripIndent, stripIndents } from 'common-tags'; import Prism from 'prismjs'; import React, { useState } from 'react'; -import { Collapse } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Collapse, useStyles2 } from '@grafana/ui'; import { flattenTokens } from '@grafana/ui/src/slate-plugins/slate-prism'; import tokenizer from '../../language/cloudwatch-logs/syntax'; @@ -350,14 +351,6 @@ function renderHighlightedMarkup(code: string, keyPrefix: string) { return
      {spans}
      ; } -const exampleCategory = css` - margin-top: 5px; -`; - -const link = css` - text-decoration: underline; -`; - type Props = { onClickExample: (query: CloudWatchQuery) => void; query: CloudWatchQuery; @@ -366,6 +359,7 @@ type Props = { const LogsCheatSheet = (props: Props) => { const [isCommandsOpen, setIsCommandsOpen] = useState(false); const [isQueriesOpen, setIsQueriesOpen] = useState(false); + const styles = useStyles2(getStyles); return (
      @@ -385,7 +379,7 @@ const LogsCheatSheet = (props: Props) => {

      {item.description}