diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml index 2b8484d7cf6..1fd10ae5540 100644 --- a/.github/actions/change-detection/action.yml +++ b/.github/actions/change-detection/action.yml @@ -14,6 +14,9 @@ outputs: frontend: description: Whether the frontend or self has changed in any way value: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }} + frontend-packages: + description: Whether any frontend packages have changed + value: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }} e2e: description: Whether the e2e tests or self have changed in any way value: ${{ steps.changed-files.outputs.e2e_any_changed == 'true' || @@ -97,6 +100,12 @@ runs: - '.yarn/**' - 'apps/dashboard/pkg/migration/**' - '${{ inputs.self }}' + frontend_packages: + - '.github/actions/checkout/**' + - '.github/actions/change-detection/**' + - 'packages/**' + - './scripts/validate-npm-packages.sh' + - '${{ inputs.self }}' e2e: - 'e2e/**' - 'e2e-playwright/**' @@ -153,6 +162,8 @@ runs: echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" echo "Frontend: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}" + echo "Frontend packages: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.frontend_packages_all_changed_files }}" echo "E2E: ${{ steps.changed-files.outputs.e2e_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.e2e_all_changed_files }}" echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 5762389f83b..92ffd43593c 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -4,8 +4,8 @@ description: Sets up a node.js environment with presets for the Grafana reposito runs: using: "composite" steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'yarn' - cache-dependency-path: 'yarn.lock' \ No newline at end of file + cache-dependency-path: 'yarn.lock' diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index 02833d61149..539d57b1742 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -17,6 +17,7 @@ jobs: outputs: changed: ${{ steps.detect-changes.outputs.frontend }} prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }} + changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }} steps: - uses: actions/checkout@v5 with: @@ -42,11 +43,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - run: yarn run prettier:check - run: yarn run lint @@ -63,11 +61,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -89,11 +84,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - run: yarn run typecheck lint-frontend-typecheck-enterprise: @@ -109,11 +101,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -133,11 +122,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - name: Generate API clients run: | @@ -164,11 +150,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -187,3 +170,26 @@ jobs: echo "${uncommited_error_message}" exit 1 fi + lint-frontend-packed-packages: + needs: detect-changes + permissions: + contents: read + id-token: write + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.changed-frontend-packages == 'true' + name: Verify packed frontend packages + runs-on: ubuntu-latest + steps: + - name: Checkout build commit + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup Node + uses: ./.github/actions/setup-node + - name: Install dependencies + run: yarn install --immutable + - name: Build and pack packages + run: | + yarn run packages:build + yarn run packages:pack + - name: Validate packages + run: ./scripts/validate-npm-packages.sh diff --git a/Makefile b/Makefile index a5353e95567..e4a261acb4b 100644 --- a/Makefile +++ b/Makefile @@ -135,7 +135,7 @@ i18n-extract-enterprise: @echo "Skipping i18n extract for Enterprise: not enabled" else i18n-extract-enterprise: - @echo "Extracting i18n strings for Enterprise" + @echo "Extracting i18n strings for Enterprise" cd public/locales/enterprise && yarn run i18next-cli extract --sync-primary endif @@ -227,6 +227,10 @@ fix-cue: gen-jsonnet: go generate ./devenv/jsonnet +.PHONY: gen-themes +gen-themes: + go generate ./pkg/services/preference + .PHONY: update-workspace update-workspace: gen-go @echo "updating workspace" @@ -244,6 +248,7 @@ build-go-fast: ## Build all Go binaries without updating workspace. .PHONY: build-backend build-backend: ## Build Grafana backend. @echo "build backend" + $(MAKE) gen-themes $(GO) run build.go $(GO_BUILD_FLAGS) build-backend .PHONY: build-air diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index a79829d45c2..9bce171ed16 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f + github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index d45d418dfb8..73b2a5c991b 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json index a27c33c3239..ac88b83370f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json @@ -852,6 +852,194 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -914,6 +1102,24 @@ "name": "panel-6" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json index 0c787609714..fad72787d19 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json @@ -879,6 +879,200 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -973,6 +1167,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json index 20f70a0a647..b38cf688949 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json index 9956ad6962f..0b7f512e6f1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json index 09e35c64258..aba5db6146d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json @@ -879,6 +879,200 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -941,6 +1135,24 @@ "name": "panel-6" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json index 4494023eb13..99bcc3e9581 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json index bc8d90d796a..e2d54185ea5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json index bb70e99ec48..d3ca201e380 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json @@ -852,6 +852,194 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -946,6 +1134,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 46a2a533d41..5dc7ecf21fd 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -1195,16 +1195,36 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer // getPanelDatasource determines the panel-level datasource for V1. // Returns: // - Mixed datasource reference if queries use different datasources +// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels) +// - Dashboard datasource reference if a single query uses Dashboard datasource // - First query's datasource if all queries use the same datasource // - nil if no queries exist // Compares based on V2 input without runtime resolution: // - If query has explicit datasource.uid → use that UID and type // - Else → use query.Kind as type (empty UID) func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { + const sharedDashboardQuery = "-- Dashboard --" + if len(queries) == 0 { return nil } + // Count how many queries use Dashboard datasource + // Multiple dashboard queries need mixed mode because they fetch from different panels + // which may have different underlying datasources + dashboardDsQueryCount := 0 + for _, query := range queries { + if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery { + dashboardDsQueryCount++ + } + } + if dashboardDsQueryCount > 1 { + return map[string]interface{}{ + "type": "mixed", + "uid": "-- Mixed --", + } + } + var firstUID, firstType string var hasFirst bool @@ -1239,6 +1259,16 @@ func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[stri } } + // Handle case when a single query uses Dashboard datasource. + // This is needed for the frontend to properly activate and fetch data from source panels. + // See DashboardDatasourceBehaviour.tsx for more details. + if firstUID == sharedDashboardQuery { + return map[string]interface{}{ + "type": "datasource", + "uid": sharedDashboardQuery, + } + } + // Not mixed - return the first query's datasource so the panel has a datasource set. // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource // to resolve the datasource, and if undefined, it falls back to the default datasource diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index e4866731967..7e9e5f47876 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -97,7 +97,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect + github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 29738a48cca..c991111f90e 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -215,8 +215,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go index f9f8dcc8382..2738af49db1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go @@ -32,7 +32,7 @@ type ConnectionSecure struct { // Token is the reference of the token used to act as the Connection. // This value is stored securely and cannot be read back - Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"` + Token common.InlineSecureValue `json:"token,omitzero,omitempty"` } func (v ConnectionSecure) IsZero() bool { diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 11788142e94..4db11489c98 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -320,7 +320,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.Reference Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), }, }, - "webhook": { + "token": { SchemaProps: spec.SchemaProps{ Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", Default: map[string]interface{}{}, diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index 3a54dcf2a5e..72567e04b90 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -22,7 +22,6 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents -API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs diff --git a/apps/provisioning/pkg/connection/connection.go b/apps/provisioning/pkg/connection/connection.go new file mode 100644 index 00000000000..d2043b5af4a --- /dev/null +++ b/apps/provisioning/pkg/connection/connection.go @@ -0,0 +1,16 @@ +package connection + +import ( + "context" +) + +//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter +type Connection interface { + // Validate ensures the resource _looks_ correct. + // It should be called before trying to upsert a resource into the Kubernetes API server. + // This is not an indication that the connection information works, just that they are reasonably configured. + Validate(ctx context.Context) error + + // Mutate performs in place mutation of the underneath resource. + Mutate(context.Context) error +} diff --git a/apps/provisioning/pkg/connection/connection_mock.go b/apps/provisioning/pkg/connection/connection_mock.go new file mode 100644 index 00000000000..3867059d432 --- /dev/null +++ b/apps/provisioning/pkg/connection/connection_mock.go @@ -0,0 +1,128 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockConnection is an autogenerated mock type for the Connection type +type MockConnection struct { + mock.Mock +} + +type MockConnection_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConnection) EXPECT() *MockConnection_Expecter { + return &MockConnection_Expecter{mock: &_m.Mock} +} + +// Mutate provides a mock function with given fields: _a0 +func (_m *MockConnection) Mutate(_a0 context.Context) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Mutate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' +type MockConnection_Mutate_Call struct { + *mock.Call +} + +// Mutate is a helper method to define mock.On call +// - _a0 context.Context +func (_e *MockConnection_Expecter) Mutate(_a0 interface{}) *MockConnection_Mutate_Call { + return &MockConnection_Mutate_Call{Call: _e.mock.On("Mutate", _a0)} +} + +func (_c *MockConnection_Mutate_Call) Run(run func(_a0 context.Context)) *MockConnection_Mutate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Mutate_Call) Return(_a0 error) *MockConnection_Mutate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Mutate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Mutate_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: ctx +func (_m *MockConnection) Validate(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockConnection_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockConnection_Expecter) Validate(ctx interface{}) *MockConnection_Validate_Call { + return &MockConnection_Validate_Call{Call: _e.mock.On("Validate", ctx)} +} + +func (_c *MockConnection_Validate_Call) Run(run func(ctx context.Context)) *MockConnection_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Validate_Call) Return(_a0 error) *MockConnection_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Validate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConnection(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConnection { + mock := &MockConnection{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/extra_mock.go b/apps/provisioning/pkg/connection/extra_mock.go new file mode 100644 index 00000000000..cc2a1f3d5e2 --- /dev/null +++ b/apps/provisioning/pkg/connection/extra_mock.go @@ -0,0 +1,141 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockExtra is an autogenerated mock type for the Extra type +type MockExtra struct { + mock.Mock +} + +type MockExtra_Expecter struct { + mock *mock.Mock +} + +func (_m *MockExtra) EXPECT() *MockExtra_Expecter { + return &MockExtra_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockExtra) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockExtra_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockExtra_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockExtra_Expecter) Build(ctx interface{}, r interface{}) *MockExtra_Build_Call { + return &MockExtra_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockExtra_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockExtra_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockExtra_Build_Call) Return(_a0 Connection, _a1 error) *MockExtra_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockExtra_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockExtra_Build_Call { + _c.Call.Return(run) + return _c +} + +// Type provides a mock function with no fields +func (_m *MockExtra) Type() v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Type") + } + + var r0 v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(v0alpha1.ConnectionType) + } + + return r0 +} + +// MockExtra_Type_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Type' +type MockExtra_Type_Call struct { + *mock.Call +} + +// Type is a helper method to define mock.On call +func (_e *MockExtra_Expecter) Type() *MockExtra_Type_Call { + return &MockExtra_Type_Call{Call: _e.mock.On("Type")} +} + +func (_c *MockExtra_Type_Call) Run(run func()) *MockExtra_Type_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockExtra_Type_Call) Return(_a0 v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockExtra_Type_Call) RunAndReturn(run func() v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(run) + return _c +} + +// NewMockExtra creates a new instance of MockExtra. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockExtra(t interface { + mock.TestingT + Cleanup(func()) +}) *MockExtra { + mock := &MockExtra{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory.go b/apps/provisioning/pkg/connection/factory.go new file mode 100644 index 00000000000..4a0e46d84d2 --- /dev/null +++ b/apps/provisioning/pkg/connection/factory.go @@ -0,0 +1,75 @@ +package connection + +import ( + "context" + "fmt" + "sort" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +//go:generate mockery --name=Extra --structname=MockExtra --inpackage --filename=extra_mock.go --with-expecter +type Extra interface { + Type() provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +//go:generate mockery --name=Factory --structname=MockFactory --inpackage --filename=factory_mock.go --with-expecter +type Factory interface { + Types() []provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +type factory struct { + extras map[provisioning.ConnectionType]Extra + enabled map[provisioning.ConnectionType]struct{} +} + +func ProvideFactory(enabled map[provisioning.ConnectionType]struct{}, extras []Extra) (Factory, error) { + f := &factory{ + enabled: enabled, + extras: make(map[provisioning.ConnectionType]Extra, len(extras)), + } + + for _, e := range extras { + if _, exists := f.extras[e.Type()]; exists { + return nil, fmt.Errorf("connection type %q is already registered", e.Type()) + } + f.extras[e.Type()] = e + } + + return f, nil +} + +func (f *factory) Types() []provisioning.ConnectionType { + var types []provisioning.ConnectionType + for t := range f.enabled { + if _, exists := f.extras[t]; exists { + types = append(types, t) + } + } + + sort.Slice(types, func(i, j int) bool { + return string(types[i]) < string(types[j]) + }) + + return types +} + +func (f *factory) Build(ctx context.Context, c *provisioning.Connection) (Connection, error) { + for _, e := range f.extras { + if e.Type() == c.Spec.Type { + if _, enabled := f.enabled[e.Type()]; !enabled { + return nil, fmt.Errorf("connection type %q is not enabled", e.Type()) + } + + return e.Build(ctx, c) + } + } + + return nil, fmt.Errorf("connection type %q is not supported", c.Spec.Type) +} + +var ( + _ Factory = (*factory)(nil) +) diff --git a/apps/provisioning/pkg/connection/factory_mock.go b/apps/provisioning/pkg/connection/factory_mock.go new file mode 100644 index 00000000000..8fd7023920f --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_mock.go @@ -0,0 +1,143 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockFactory is an autogenerated mock type for the Factory type +type MockFactory struct { + mock.Mock +} + +type MockFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFactory) EXPECT() *MockFactory_Expecter { + return &MockFactory_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockFactory) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockFactory_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockFactory_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockFactory_Expecter) Build(ctx interface{}, r interface{}) *MockFactory_Build_Call { + return &MockFactory_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockFactory_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockFactory_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockFactory_Build_Call) Return(_a0 Connection, _a1 error) *MockFactory_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockFactory_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockFactory_Build_Call { + _c.Call.Return(run) + return _c +} + +// Types provides a mock function with no fields +func (_m *MockFactory) Types() []v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Types") + } + + var r0 []v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() []v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v0alpha1.ConnectionType) + } + } + + return r0 +} + +// MockFactory_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types' +type MockFactory_Types_Call struct { + *mock.Call +} + +// Types is a helper method to define mock.On call +func (_e *MockFactory_Expecter) Types() *MockFactory_Types_Call { + return &MockFactory_Types_Call{Call: _e.mock.On("Types")} +} + +func (_c *MockFactory_Types_Call) Run(run func()) *MockFactory_Types_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockFactory_Types_Call) Return(_a0 []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockFactory_Types_Call) RunAndReturn(run func() []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(run) + return _c +} + +// NewMockFactory creates a new instance of MockFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFactory { + mock := &MockFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory_test.go b/apps/provisioning/pkg/connection/factory_test.go new file mode 100644 index 00000000000..4ce6bc96e0e --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_test.go @@ -0,0 +1,309 @@ +package connection + +import ( + "context" + "errors" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestProvideFactory(t *testing.T) { + t.Run("should create factory with valid extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with empty extras", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with nil enabled map", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + factory, err := ProvideFactory(nil, []Extra{extra1}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should return error when duplicate repository types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.Error(t, err) + assert.Nil(t, factory) + assert.Contains(t, err.Error(), "connection type \"github\" is already registered") + }) +} + +func TestFactory_Types(t *testing.T) { + t.Run("should return only enabled types that have extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.Contains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return sorted list of types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + // github should come before gitlab alphabetically + assert.Equal(t, provisioning.GithubConnectionType, types[0]) + assert.Equal(t, provisioning.GitlabConnectionType, types[1]) + }) + + t.Run("should return empty list when no types are enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) + + t.Run("should not return types that are enabled but have no extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should not return types that have extras but are not enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return empty list when no extras are provided", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) +} + +func TestFactory_Build(t *testing.T) { + t.Run("should successfully build connection when type is enabled and has extra", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) + + t.Run("should return error when type is not enabled", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled") + }) + + t.Run("should return error when type is not supported", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported") + }) + + t.Run("should pass through errors from extra.Build()", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + expectedErr := errors.New("build error") + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(nil, expectedErr) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, expectedErr, err) + }) + + t.Run("should build with multiple extras registered", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/client.go b/apps/provisioning/pkg/connection/github/client.go new file mode 100644 index 00000000000..7ddb9a4665e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v70/github" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// API errors that we need to convey after parsing real GH errors (or faking them). +var ( + //lint:ignore ST1005 this is not punctuation + ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable") +) + +//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter +type Client interface { + // Apps and installations + GetApp(ctx context.Context) (App, error) + GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) +} + +// App represents a Github App. +type App struct { + // ID represents the GH app ID. + ID int64 + // Slug represents the GH app slug. + Slug string + // Owner represents the GH account/org owning the app + Owner string +} + +// AppInstallation represents a Github App Installation. +type AppInstallation struct { + // ID represents the GH installation ID. + ID int64 + // Whether the installation is enabled or not. + Enabled bool +} + +type githubClient struct { + gh *github.Client +} + +func NewClient(client *github.Client) Client { + return &githubClient{client} +} + +// GetApp gets the app by using the given token. +func (r *githubClient) GetApp(ctx context.Context) (App, error) { + app, _, err := r.gh.Apps.Get(ctx, "") + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return App{}, ErrServiceUnavailable + } + return App{}, err + } + + // TODO(ferruvich): do we need any other info? + return App{ + ID: app.GetID(), + Slug: app.GetSlug(), + Owner: app.GetOwner().GetLogin(), + }, nil +} + +// GetAppInstallation gets the installation of the app related to the given token. +func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + id, err := strconv.Atoi(installationID) + if err != nil { + return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID) + } + + installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id)) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return AppInstallation{}, ErrServiceUnavailable + } + return AppInstallation{}, err + } + + // TODO(ferruvich): do we need any other info? + return AppInstallation{ + ID: installation.GetID(), + Enabled: installation.GetSuspendedAt().IsZero(), + }, nil +} diff --git a/apps/provisioning/pkg/connection/github/client_mock.go b/apps/provisioning/pkg/connection/github/client_mock.go new file mode 100644 index 00000000000..c9f009f5021 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_mock.go @@ -0,0 +1,149 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// GetApp provides a mock function with given fields: ctx +func (_m *MockClient) GetApp(ctx context.Context) (App, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetApp") + } + + var r0 App + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (App, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) App); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(App) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApp' +type MockClient_GetApp_Call struct { + *mock.Call +} + +// GetApp is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetApp(ctx interface{}) *MockClient_GetApp_Call { + return &MockClient_GetApp_Call{Call: _e.mock.On("GetApp", ctx)} +} + +func (_c *MockClient_GetApp_Call) Run(run func(ctx context.Context)) *MockClient_GetApp_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetApp_Call) Return(_a0 App, _a1 error) *MockClient_GetApp_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetApp_Call) RunAndReturn(run func(context.Context) (App, error)) *MockClient_GetApp_Call { + _c.Call.Return(run) + return _c +} + +// GetAppInstallation provides a mock function with given fields: ctx, installationID +func (_m *MockClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + ret := _m.Called(ctx, installationID) + + if len(ret) == 0 { + panic("no return value specified for GetAppInstallation") + } + + var r0 AppInstallation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (AppInstallation, error)); ok { + return rf(ctx, installationID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) AppInstallation); ok { + r0 = rf(ctx, installationID) + } else { + r0 = ret.Get(0).(AppInstallation) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, installationID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetAppInstallation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAppInstallation' +type MockClient_GetAppInstallation_Call struct { + *mock.Call +} + +// GetAppInstallation is a helper method to define mock.On call +// - ctx context.Context +// - installationID string +func (_e *MockClient_Expecter) GetAppInstallation(ctx interface{}, installationID interface{}) *MockClient_GetAppInstallation_Call { + return &MockClient_GetAppInstallation_Call{Call: _e.mock.On("GetAppInstallation", ctx, installationID)} +} + +func (_c *MockClient_GetAppInstallation_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_GetAppInstallation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) Return(_a0 AppInstallation, _a1 error) *MockClient_GetAppInstallation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Context, string) (AppInstallation, error)) *MockClient_GetAppInstallation_Call { + _c.Call.Return(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/github/client_test.go b/apps/provisioning/pkg/connection/github/client_test.go new file mode 100644 index 00000000000..bae6d6ac1e9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_test.go @@ -0,0 +1,297 @@ +package github_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v70/github" + conngh "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + mockhub "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGithubClient_GetApp(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + token string + wantApp conngh.App + wantErr error + }{ + { + name: "get app successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + app := &github.App{ + ID: github.Ptr(int64(12345)), + Slug: github.Ptr("my-test-app"), + Owner: &github.User{ + Login: github.Ptr("grafana"), + }, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(app)) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{ + ID: 12345, + Slug: "my-test-app", + Owner: "grafana", + }, + wantErr: nil, + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: conngh.ErrServiceUnavailable, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + }, + }, + { + name: "unauthorized error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + })) + }), + ), + ), + token: "invalid-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + app, err := client.GetApp(context.Background()) + + // Check the error + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantApp, app) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantApp, app) + } + }) + } +} + +func TestGithubClient_GetAppInstallation(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + appToken string + installationID string + wantInstallation conngh.AppInstallation + wantErr bool + errContains string + }{ + { + name: "get disabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: github.Ptr(github.Timestamp{Time: time.Now()}), + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: false, + }, + wantErr: false, + }, + { + name: "get enabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: nil, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: true, + }, + wantErr: false, + }, + { + name: "invalid installation ID", + mockHandler: mockhub.NewMockedHTTPClient(), + appToken: "test-app-token", + installationID: "not-a-number", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + errContains: "invalid installation ID", + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "installation not found", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + }, + Message: "Not Found", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "99999", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + installation, err := client.GetAppInstallation(context.Background(), tt.installationID) + + // Check the error + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + + // Check the result + assert.Equal(t, tt.wantInstallation, installation) + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/connection.go b/apps/provisioning/pkg/connection/github/connection.go new file mode 100644 index 00000000000..6a2da98ac8d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection.go @@ -0,0 +1,192 @@ +package github + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +//go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter +type GithubFactory interface { + New(ctx context.Context, ghToken common.RawSecureValue) Client +} + +type Connection struct { + obj *provisioning.Connection + ghFactory GithubFactory +} + +func NewConnection( + obj *provisioning.Connection, + factory GithubFactory, +) Connection { + return Connection{ + obj: obj, + ghFactory: factory, + } +} + +const ( + //TODO(ferruvich): these probably need to be setup in API configuration. + githubInstallationURL = "https://github.com/settings/installations" + jwtExpirationMinutes = 10 // GitHub Apps JWT tokens expire in 10 minutes maximum +) + +// Mutate performs in place mutation of the underneath resource. +func (c *Connection) Mutate(_ context.Context) error { + // Do nothing in case spec.Github is nil. + // If this field is required, we should fail at validation time. + if c.obj.Spec.GitHub == nil { + return nil + } + + c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID) + + // Generate JWT token if private key is being provided. + // Same as for the spec.Github, if such a field is required, Validation will take care of that. + if !c.obj.Secure.PrivateKey.Create.IsZero() { + token, err := generateToken(c.obj.Spec.GitHub.AppID, c.obj.Secure.PrivateKey.Create) + if err != nil { + return fmt.Errorf("failed to generate JWT token: %w", err) + } + + // Store the generated token + c.obj.Secure.Token = common.InlineSecureValue{Create: token} + } + + return nil +} + +// Token generates and returns the Connection token. +func generateToken(appID string, privateKey common.RawSecureValue) (common.RawSecureValue, error) { + // Decode base64-encoded private key + privateKeyPEM, err := base64.StdEncoding.DecodeString(string(privateKey)) + if err != nil { + return "", fmt.Errorf("failed to decode base64 private key: %w", err) + } + + // Parse the private key + key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %w", err) + } + + // Create the JWT token + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtExpirationMinutes) * time.Minute)), + Issuer: appID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("failed to sign JWT token: %w", err) + } + + return common.RawSecureValue(signedToken), nil +} + +// Validate ensures the resource _looks_ correct. +func (c *Connection) Validate(ctx context.Context) error { + list := field.ErrorList{} + + if c.obj.Spec.Type != provisioning.GithubConnectionType { + list = append(list, field.Invalid(field.NewPath("spec", "type"), c.obj.Spec.Type, "invalid connection type")) + + // Doesn't make much sense to continue validating a connection which is not a Github one. + return toError(c.obj.GetName(), list) + } + + if c.obj.Spec.GitHub == nil { + list = append( + list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), + ) + + // Doesn't make much sense to continue validating a connection with no information. + return toError(c.obj.GetName(), list) + } + + if c.obj.Secure.PrivateKey.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) + } + if c.obj.Secure.Token.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection")) + } + if !c.obj.Secure.ClientSecret.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) + } + + // Validate GitHub configuration fields + if c.obj.Spec.GitHub.AppID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "appID"), "appID must be specified for GitHub connection")) + } + if c.obj.Spec.GitHub.InstallationID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "installationID"), "installationID must be specified for GitHub connection")) + } + + // In case we have any error above, we don't go forward with the validation, and return the errors. + if len(list) > 0 { + return toError(c.obj.GetName(), list) + } + + // Validating app content via GH API + if err := c.validateAppAndInstallation(ctx); err != nil { + list = append(list, err) + } + + return toError(c.obj.GetName(), list) +} + +// validateAppAndInstallation validates the appID and installationID against the given github token. +func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error { + ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create) + + app, err := ghClient.GetApp(ctx) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "token"), "[REDACTED]", "invalid token") + } + + if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID { + return field.Invalid(field.NewPath("spec", "appID"), c.obj.Spec.GitHub.AppID, "appID mismatch") + } + + _, err = ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "installationID"), c.obj.Spec.GitHub.InstallationID, "invalid installation ID") + } + + return nil +} + +// toError converts a field.ErrorList to an error, returning nil if the list is empty +func toError(name string, list field.ErrorList) error { + if len(list) == 0 { + return nil + } + return apierrors.NewInvalid( + provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), + name, + list, + ) +} + +var ( + _ connection.Connection = (*Connection)(nil) +) diff --git a/apps/provisioning/pkg/connection/github/connection_test.go b/apps/provisioning/pkg/connection/github/connection_test.go new file mode 100644 index 00000000000..6a916db730e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection_test.go @@ -0,0 +1,434 @@ +package github + +import ( + "context" + "encoding/base64" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoInVbLY9io2Q/wHvUIXlEHg2Qyvd8eRzBAVEJ92DS6fx9H10 +06V0VRm78S0MXyo6i+n8ZAbZ0/R+GWpP2Ephxm0Gs2zo+iO2mpB19xQFI4o6ZTOw +b2WyjSaa2Vr4oyDkqti6AvfjW4VUAu932e08GkgwmmQSHXj7FX2CMWjgUwTTcuaX +65SHNKLNYLUP0HTumLzoZeqDTdoMMpKNdgH9Avr4/8vkVJ0mD6rqvxnw3JHsseNO +WdQTxf2aApBNHIIKxWZ2i/ZmjLNey7kltgjEquGiBdJvip3fHhH5XHdkrXcjRtnw +OJDnDmi5lQwv5yUBOSkbvbXRv/L/m0YLoD/fbwIDAQABAoIBAFfl//hM8/cnuesV ++R1Con/ZAgTXQOdPqPXbmEyniVrkMqMmCdBUOBTcST4s5yg36+RtkeaGpb/ajyyF +PAB2AYDucwvMpudGpJWOYTiOOp4R8hU1LvZfXVrRd1lo6NgQi4NLtNUpOtACeVQ+ +H4Yv0YemXQ47mnuOoRNMK/u3q5NoIdSahWptXBgUno8KklNpUrH3IYWaUxfBzDN3 +2xsVRTn2SfTSyoDmTDdTgptJONmoK1/sV7UsgWksdFc6XyYhsFAZgOGEJrBABRvF +546dyQ0cWxuPyVXpM7CN3tqC5ssvLjElg3LicK1V6gnjpdRnnvX88d1Eh3Uc/9IM +OZInT2ECgYEA6W8sQXTWinyEwl8SDKKMbB2ApIghAcFgdRxprZE4WFxjsYNCNL70 +dnSB7MRuzmxf5W77cV0N7JhH66N8HvY6Xq9olrpQ5dNttR4w8Pyv3wavDe8x7seL +5L2Xtbu7ihDr8Dk27MjiBSin3IxhBP5CJS910+pR6LrAWtEuU+FzFfECgYEAsA6y +qxHhCMXlTnauXhsnmPd1g61q7chW8kLQFYtHMLlQlgjHTW7irDZ9cPbPYDNjwRLO +7KLorcpv2NKe7rqq2ZyCm6hf1b9WnlQjo3dLpNWMu6fhy/smK8MgbRqcWpX+oTKF +79mK6hbY7o6eBzsQHBl7Z+LBNuwYmp9qOodPa18CgYEArv6ipKdcNhFGzRfMRiCN +OHederp6VACNuP2F05IsNUF9kxOdTEFirnKE++P+VU01TqA2azOhPp6iO+ohIGzi +MR06QNSH1OL9OWvasK4dggpWrRGF00VQgDgJRTnpS4WH+lxJ6pRlrAxgWpv6F24s +VAgSQr1Ejj2B+hMasdMvHWECgYBJ4uE4yhgXBnZlp4kmFV9Y4wF+cZkekaVrpn6N +jBYkbKFVVfnOlWqru3KJpgsB5I9IyAvvY68iwIKQDFSG+/AXw4dMrC0MF3DSoZ0T +TU2Br92QI7SvVod+djV1lGVp3ukt3XY4YqPZ+hywgUnw3uiz4j3YK2HLGup4ec6r +IX5DIQKBgHRLzvT3zqtlR1Oh0vv098clLwt+pGzXOxzJpxioOa5UqK13xIpFXbcg +iWUVh5YXCcuqaICUv4RLIEac5xQitk9Is/9IhP0NJ/81rHniosvdSpCeFXzxTImS +B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb +-----END RSA PRIVATE KEY-----` + +func TestConnection_Mutate(t *testing.T) { + t.Run("should add URL to Github connection", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + }) + + t.Run("should generate JWT token when private key is provided", func(t *testing.T) { + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(privateKeyBase64), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated") + }) + + t.Run("should do nothing when GitHub config is nil", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "clientID", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + }) + + t.Run("should fail when private key is not base64", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("invalid-key"), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to decode base64 private key") + }) + + t.Run("should fail when private key is invalid", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to parse private key") + }) +} + +func TestConnection_Validate(t *testing.T) { + tests := []struct { + name string + connection *provisioning.Connection + setupMock func(*MockGithubFactory) + wantErr bool + errMsgContains []string + }{ + { + name: "invalid type returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: "invalid", + }, + }, + wantErr: true, + errMsgContains: []string{"spec.type"}, + }, + { + name: "github type without github config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github"}, + }, + { + name: "github type without private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.privateKey"}, + }, + { + name: "github type without token returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.token"}, + }, + { + name: "github type with client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + ClientSecret: common.InlineSecureValue{ + Create: common.NewSecretValue("test-client-secret"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.clientSecret"}, + }, + { + name: "github type without appID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.appID"}, + }, + { + name: "github type without installationID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + Token: common.InlineSecureValue{ + Name: "test-token", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.installationID"}, + }, + { + name: "github type with valid config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: false, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil) + }, + }, + { + name: "problem getting app returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.token", "[REDACTED]"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError) + }, + }, + { + name: "mismatched app ID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.appID"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil) + }, + }, + { + name: "problem when getting installation returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.installationID", "456"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockFactory := NewMockGithubFactory(t) + if tt.setupMock != nil { + tt.setupMock(mockFactory) + } + + conn := NewConnection(tt.connection, mockFactory) + err := conn.Validate(context.Background()) + if tt.wantErr { + assert.Error(t, err) + for _, msg := range tt.errMsgContains { + assert.Contains(t, err.Error(), msg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/extra.go b/apps/provisioning/pkg/connection/github/extra.go new file mode 100644 index 00000000000..2c207637c61 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" +) + +type extra struct { + factory GithubFactory +} + +func (e *extra) Type() provisioning.ConnectionType { + return provisioning.GithubConnectionType +} + +func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) { + logger := logging.FromContext(ctx) + if connection == nil || connection.Spec.GitHub == nil { + logger.Error("connection is nil or github info is nil") + + return nil, fmt.Errorf("invalid github connection") + } + + c := NewConnection(connection, e.factory) + return &c, nil +} + +func Extra(factory GithubFactory) connection.Extra { + return &extra{ + factory: factory, + } +} diff --git a/apps/provisioning/pkg/connection/github/extra_test.go b/apps/provisioning/pkg/connection/github/extra_test.go new file mode 100644 index 00000000000..c5bcc8279d9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra_test.go @@ -0,0 +1,126 @@ +package github_test + +import ( + "context" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExtra_Type(t *testing.T) { + t.Run("should return GithubConnectionType", func(t *testing.T) { + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result := e.Type() + assert.Equal(t, provisioning.GithubConnectionType, result) + }) +} + +func TestExtra_Build(t *testing.T) { + t.Run("should successfully build connection", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should handle different connection configurations", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "another-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "789", + InstallationID: "101112", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "existing-private-key", + }, + Token: common.InlineSecureValue{ + Name: "existing-token", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should build connection with background context", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should always pass empty token to factory.New", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("some-token"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/factory.go b/apps/provisioning/pkg/connection/github/factory.go new file mode 100644 index 00000000000..2399f3c9f69 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory.go @@ -0,0 +1,39 @@ +package github + +import ( + "context" + "net/http" + + "github.com/google/go-github/v70/github" + "golang.org/x/oauth2" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// Factory creates new GitHub clients. +// It exists only for the ability to test the code easily. +type Factory struct { + // Client allows overriding the client to use in the GH client returned. It exists primarily for testing. + // FIXME: we should replace in this way. We should add some options pattern for the factory. + Client *http.Client +} + +func ProvideFactory() GithubFactory { + return &Factory{} +} + +func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client { + if r.Client != nil { + return NewClient(github.NewClient(r.Client)) + } + + if !ghToken.IsZero() { + tokenSrc := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: string(ghToken)}, + ) + tokenClient := oauth2.NewClient(ctx, tokenSrc) + return NewClient(github.NewClient(tokenClient)) + } + + return NewClient(github.NewClient(&http.Client{})) +} diff --git a/apps/provisioning/pkg/connection/github/factory_mock.go b/apps/provisioning/pkg/connection/github/factory_mock.go new file mode 100644 index 00000000000..a9e1424b62d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory_mock.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockGithubFactory is an autogenerated mock type for the GithubFactory type +type MockGithubFactory struct { + mock.Mock +} + +type MockGithubFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGithubFactory) EXPECT() *MockGithubFactory_Expecter { + return &MockGithubFactory_Expecter{mock: &_m.Mock} +} + +// New provides a mock function with given fields: ctx, ghToken +func (_m *MockGithubFactory) New(ctx context.Context, ghToken v0alpha1.RawSecureValue) Client { + ret := _m.Called(ctx, ghToken) + + if len(ret) == 0 { + panic("no return value specified for New") + } + + var r0 Client + if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.RawSecureValue) Client); ok { + r0 = rf(ctx, ghToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Client) + } + } + + return r0 +} + +// MockGithubFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New' +type MockGithubFactory_New_Call struct { + *mock.Call +} + +// New is a helper method to define mock.On call +// - ctx context.Context +// - ghToken v0alpha1.RawSecureValue +func (_e *MockGithubFactory_Expecter) New(ctx interface{}, ghToken interface{}) *MockGithubFactory_New_Call { + return &MockGithubFactory_New_Call{Call: _e.mock.On("New", ctx, ghToken)} +} + +func (_c *MockGithubFactory_New_Call) Run(run func(ctx context.Context, ghToken v0alpha1.RawSecureValue)) *MockGithubFactory_New_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(v0alpha1.RawSecureValue)) + }) + return _c +} + +func (_c *MockGithubFactory_New_Call) Return(_a0 Client) *MockGithubFactory_New_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubFactory_New_Call) RunAndReturn(run func(context.Context, v0alpha1.RawSecureValue) Client) *MockGithubFactory_New_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGithubFactory creates a new instance of MockGithubFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGithubFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGithubFactory { + mock := &MockGithubFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/mutator.go b/apps/provisioning/pkg/connection/mutator.go deleted file mode 100644 index 30291669905..00000000000 --- a/apps/provisioning/pkg/connection/mutator.go +++ /dev/null @@ -1,28 +0,0 @@ -package connection - -import ( - "fmt" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" -) - -const ( - githubInstallationURL = "https://github.com/settings/installations" -) - -func MutateConnection(connection *provisioning.Connection) error { - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - // Do nothing in case spec.Github is nil. - // If this field is required, we should fail at validation time. - if connection.Spec.GitHub == nil { - return nil - } - - connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID) - return nil - default: - // TODO: we need to setup the URL for bitbucket and gitlab. - return nil - } -} diff --git a/apps/provisioning/pkg/connection/mutator_test.go b/apps/provisioning/pkg/connection/mutator_test.go deleted file mode 100644 index a25aabd10a1..00000000000 --- a/apps/provisioning/pkg/connection/mutator_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutateConnection(t *testing.T) { - t.Run("should add URL to Github connection", func(t *testing.T) { - c := &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - } - - require.NoError(t, connection.MutateConnection(c)) - assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) - }) -} diff --git a/apps/provisioning/pkg/connection/validator.go b/apps/provisioning/pkg/connection/validator.go deleted file mode 100644 index c2537e3af2f..00000000000 --- a/apps/provisioning/pkg/connection/validator.go +++ /dev/null @@ -1,104 +0,0 @@ -package connection - -import ( - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -func ValidateConnection(connection *provisioning.Connection) error { - list := field.ErrorList{} - - if connection.Spec.Type == "" { - list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified")) - } - - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - list = append(list, validateGithubConnection(connection)...) - case provisioning.BitbucketConnectionType: - list = append(list, validateBitbucketConnection(connection)...) - case provisioning.GitlabConnectionType: - list = append(list, validateGitlabConnection(connection)...) - default: - list = append( - list, field.NotSupported( - field.NewPath("spec", "type"), - connection.Spec.Type, - []provisioning.ConnectionType{ - provisioning.GithubConnectionType, - provisioning.BitbucketConnectionType, - provisioning.GitlabConnectionType, - }), - ) - } - - return toError(connection.GetName(), list) -} - -func validateGithubConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.GitHub == nil { - list = append( - list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), - ) - } - - if connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) - } - if !connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) - } - - return list -} - -func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Bitbucket == nil { - list = append( - list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection")) - } - - return list -} - -func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Gitlab == nil { - list = append( - list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection")) - } - - return list -} - -// toError converts a field.ErrorList to an error, returning nil if the list is empty -func toError(name string, list field.ErrorList) error { - if len(list) == 0 { - return nil - } - return apierrors.NewInvalid( - provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), - name, - list, - ) -} diff --git a/apps/provisioning/pkg/connection/validator_test.go b/apps/provisioning/pkg/connection/validator_test.go deleted file mode 100644 index 23d4b01b800..00000000000 --- a/apps/provisioning/pkg/connection/validator_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestValidateConnection(t *testing.T) { - tests := []struct { - name string - connection *provisioning.Connection - wantErr bool - errMsg string - }{ - { - name: "empty type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{}, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "invalid type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: "invalid", - }, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "github type without github config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.github", - }, - { - name: "github type without private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "github type with client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "github type with github config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - }, - wantErr: false, - }, - { - name: "bitbucket type without bitbucket config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.bitbucket", - }, - { - name: "bitbucket type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "bitbucket type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "bitbucket type with bitbucket config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - { - name: "gitlab type without gitlab config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.gitlab", - }, - { - name: "gitlab type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "gitlab type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "gitlab type with gitlab config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := connection.ValidateConnection(tt.connection) - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go index 8ac26b192c9..f5be635560d 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go @@ -13,7 +13,7 @@ import ( type ConnectionSecureApplyConfiguration struct { PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"` ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"` - Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"` + Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"` } // ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with diff --git a/conf/defaults.ini b/conf/defaults.ini index 8e0a113a0ec..080d4e62fe0 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -336,7 +336,7 @@ rudderstack_data_plane_url = rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -rudderstack_v3_sdk_url = +rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config rudderstack_config_url = @@ -2079,8 +2079,14 @@ enable = # To enable features by default, set `Expression: "true"` in: # https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. +# # feature1 = true # feature2 = false +# feature3 = "foobar" +# feature4 = 1.5 +# feature5 = { "foo": "bar" } [feature_toggles.openfeature] # This is EXPERIMENTAL. Please, do not use this section diff --git a/conf/sample.ini b/conf/sample.ini index 5a579d0e74e..b4bc6027abf 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -323,7 +323,7 @@ ;rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -;rudderstack_v3_sdk_url = +;rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = @@ -1913,7 +1913,7 @@ default_datasource_uid = # client_queue_max_size is the maximum size in bytes of the client queue # for Live connections. Defaults to 4MB. -;client_queue_max_size = +;client_queue_max_size = #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] @@ -1996,9 +1996,14 @@ default_datasource_uid = ;enable = feature1,feature2 +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. + ;feature1 = true ;feature2 = false - +;feature3 = "foobar" +;feature4 = 1.5 +;feature5 = { "foo": "bar" } [date_formats] # For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/ diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md index 15cf895a5e8..465880919ca 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md @@ -66,17 +66,18 @@ Please refer to plugin documentation to see what RBAC permissions the plugin has The following list contains app plugins that have fine-grained RBAC support. -| App plugin | App plugin ID | App plugin permission documentation | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | -| [Adaptive Metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | -| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) | -| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | -| [Kubernetes Monitoring](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/) | `grafana-k8s-app` | [Kubernetes Monitoring role-based access control](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/configuration/control-access/#precision-access-with-rbac-custom-plugin-roles) | -| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | -| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) | -| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a | -| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) | +| App plugin | App plugin ID | App plugin permission documentation | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | +| [Adaptive Metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | +| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) | +| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | +| [Kubernetes Monitoring](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/) | `grafana-k8s-app` | [Kubernetes Monitoring role-based access control](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/configuration/control-access/#precision-access-with-rbac-custom-plugin-roles) | +| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | +| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) | +| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a | +| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) | +| [Synthetic Monitoring](https://grafana.com/docs/grafana-cloud/testing/synthetic-monitoring/) | `grafana-synthetic-monitoring-app` | [Configure RBAC for Synthetic Monitoring](https://grafana.com/docs/grafana-cloud/testing/synthetic-monitoring/user-and-team-management/) | ### Revoke fine-grained access from app plugins diff --git a/docs/sources/datasources/mssql/_index.md b/docs/sources/datasources/mssql/_index.md index a5e00da6dcb..b7ea159e972 100644 --- a/docs/sources/datasources/mssql/_index.md +++ b/docs/sources/datasources/mssql/_index.md @@ -99,12 +99,27 @@ refs: destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching - pattern: /docs/grafana-cloud/ destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + mssql-troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + postgres: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/postgres/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/postgres/ + mysql: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mysql/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mysql/ --- # Microsoft SQL Server (MSSQL) data source Grafana ships with built-in support for Microsoft SQL Server (MSSQL). -You can query and visualize data from any Microsoft SQL Server 2005 or newer, including the Microsoft Azure SQL Database. +You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database. Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time. @@ -113,10 +128,33 @@ The following documentation helps you get started working with the Microsoft SQL - [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source) - [Microsoft SQL Server query editor](ref:mssql-query-editor) - [Microsoft SQL Server template variables](ref:mssql-template-variables) +- [Troubleshoot Microsoft SQL Server data source issues](ref:mssql-troubleshoot) -## Get the most out of the data source +## Supported versions -After installing and configuring the Microsoft SQL Server data source, you can: +This data source supports the following Microsoft SQL Server versions: + +- Microsoft SQL Server 2005 and newer +- Microsoft Azure SQL Database +- Azure SQL Managed Instance + +Grafana recommends using the latest available service pack for your SQL Server version for optimal compatibility. + +## Key capabilities + +The Microsoft SQL Server data source supports: + +- **Time series queries:** Visualize metrics over time using the built-in time grouping macros. +- **Table queries:** Display query results in table format for any valid SQL query. +- **Template variables:** Create dynamic dashboards with variable-driven queries. +- **Annotations:** Overlay events from SQL Server on your dashboard graphs. +- **Alerting:** Create alerts based on SQL Server query results. +- **Stored procedures:** Execute stored procedures and visualize results. +- **Macros:** Simplify queries with built-in macros for time filtering and grouping. + +## Additional resources + +After configuring the Microsoft SQL Server data source, you can: - Create a wide variety of [visualizations](ref:visualizations) - Configure and use [templates and variables](ref:variables) @@ -124,3 +162,8 @@ After installing and configuring the Microsoft SQL Server data source, you can: - Add [annotations](ref:annotate-visualizations) - Set up [alerting](ref:alerting) - Optimize performance with [query caching](ref:query-caching) + +## Related data sources + +- [PostgreSQL](ref:postgres) - For PostgreSQL databases. +- [MySQL](ref:mysql) - For MySQL and MariaDB databases. diff --git a/docs/sources/datasources/mssql/configure/index.md b/docs/sources/datasources/mssql/configure/index.md index 7ce6398f1cc..f41deeb51dd 100644 --- a/docs/sources/datasources/mssql/configure/index.md +++ b/docs/sources/datasources/mssql/configure/index.md @@ -89,6 +89,26 @@ refs: destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + mssql-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/query-editor/ + mssql-template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/template-variables/ + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/ + mssql-troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ --- # Configure the Microsoft SQL Server data source @@ -97,13 +117,28 @@ This document provides instructions for configuring the Microsoft SQL Server dat ## Before you begin -- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin. +Before configuring the Microsoft SQL Server data source, ensure you have the following: -- You must have the `Organization administrator` role to configure the MSSQL data source. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. +- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. -- Familiarize yourself with your MSSQL security configuration and gather any necessary security certificates and client keys. +- **A running SQL Server instance:** Microsoft SQL Server 2005 or newer, Azure SQL Database, or Azure SQL Managed Instance. -- Verify that data from MSSQL is being written to your Grafana instance. +- **Network access:** Grafana must be able to reach your SQL Server. The default port is `1433`. + +- **Authentication credentials:** Depending on your authentication method, you need one of: + - SQL Server login credentials (username and password). + - Windows/Kerberos credentials and configuration (not supported in Grafana Cloud). + - Azure Entra ID app registration or managed identity. + +- **Security certificates:** If using encrypted connections, gather any necessary TLS/SSL certificates. + +{{< admonition type="note" >}} +Grafana ships with a built-in Microsoft SQL Server data source plugin. No additional installation is required. +{{< /admonition >}} + +{{< admonition type="tip" >}} +**Grafana Cloud users:** If your SQL Server is in a private network, you can configure [Private data source connect](ref:private-data-source-connect) to establish connectivity. +{{< /admonition >}} ## Add the MSSQL data source @@ -382,3 +417,48 @@ datasources: secureJsonData: password: 'Password!' ``` + +### Configure with Terraform + +You can configure the Microsoft SQL Server data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation. + +#### Terraform example + +The following example creates a basic Microsoft SQL Server data source: + +```hcl +resource "grafana_data_source" "mssql" { + name = "MSSQL" + type = "mssql" + url = "localhost:1433" + user = "grafana" + + json_data_encoded = jsonencode({ + database = "grafana" + maxOpenConns = 100 + maxIdleConns = 100 + maxIdleConnsAuto = true + connMaxLifetime = 14400 + connectionTimeout = 0 + encrypt = "false" + }) + + secure_json_data_encoded = jsonencode({ + password = "Password!" + }) +} +``` + +For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source). + +## Next steps + +After configuring your Microsoft SQL Server data source, you can: + +- [Write queries](ref:mssql-query-editor) using the query editor to explore and visualize your data +- [Create template variables](ref:mssql-template-variables) to build dynamic, reusable dashboards +- [Add annotations](ref:annotate-visualizations) to overlay SQL Server events on your graphs +- [Set up alerting](ref:alerting) to create alert rules based on your SQL Server data +- [Troubleshoot issues](ref:mssql-troubleshoot) if you encounter problems with your data source diff --git a/docs/sources/datasources/mssql/troubleshooting/index.md b/docs/sources/datasources/mssql/troubleshooting/index.md new file mode 100644 index 00000000000..a62f3eb59e1 --- /dev/null +++ b/docs/sources/datasources/mssql/troubleshooting/index.md @@ -0,0 +1,333 @@ +--- +description: Troubleshoot common problems with the Microsoft SQL Server data source in Grafana +keywords: + - grafana + - MSSQL + - Microsoft + - SQL + - troubleshooting + - errors +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Microsoft SQL Server data source issues +weight: 400 +refs: + configure-mssql-data-source: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/configure/ + mssql-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/query-editor/ + private-data-source-connect: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ +--- + +# Troubleshoot Microsoft SQL Server data source issues + +This document provides solutions to common issues you may encounter when configuring or using the Microsoft SQL Server (MSSQL) data source in Grafana. + +## Connection errors + +These errors occur when Grafana cannot establish or maintain a connection to the Microsoft SQL Server. + +### Unable to connect to the server + +**Error message:** "Unable to open tcp connection" or "dial tcp: connection refused" + +**Cause:** Grafana cannot establish a network connection to the SQL Server. + +**Solution:** + +1. Verify that the SQL Server is running and accessible. +1. Check that the host and port are correct in the data source configuration. The default SQL Server port is `1433`. +1. Ensure there are no firewall rules blocking the connection between Grafana and SQL Server. +1. Verify that SQL Server is configured to allow remote connections. +1. For Grafana Cloud, ensure you have configured [Private data source connect](ref:private-data-source-connect) if your SQL Server instance is not publicly accessible. + +### Connection timeout + +**Error message:** "Connection timed out" or "I/O timeout" + +**Cause:** The connection to SQL Server timed out before receiving a response. + +**Solution:** + +1. Check the network latency between Grafana and SQL Server. +1. Verify that SQL Server is not overloaded or experiencing performance issues. +1. Increase the **Connection timeout** setting in the data source configuration under **Additional settings**. +1. Check if any network devices (load balancers, proxies) are timing out the connection. + +### Encryption-related connection failures + +**Error message:** "TLS handshake failed" or "certificate verify failed" + +**Cause:** There is a mismatch between the encryption settings in Grafana and what the SQL Server supports or requires. + +**Solution:** + +1. For older versions of SQL Server (2008, 2008R2), set the **Encrypt** option to **Disable** or **False** in the data source configuration. +1. Verify that the SQL Server has a valid SSL certificate if encryption is enabled. +1. Check that the certificate is trusted by the Grafana server. +1. Ensure you're using the latest available service pack for your SQL Server version for optimal compatibility. + +### Named instance connection issues + +**Error message:** "Cannot connect to named instance" or connection fails when using instance name + +**Cause:** Grafana cannot resolve the SQL Server named instance. + +**Solution:** + +1. Use the format `hostname\instancename` or `hostname\instancename,port` in the **Host** field. +1. Verify that the SQL Server Browser service is running on the SQL Server machine. +1. If the Browser service is unavailable, specify the port number directly: `hostname,port`. +1. Check that UDP port 1434 is open if using the SQL Server Browser service. + +## Authentication errors + +These errors occur when there are issues with authentication credentials or permissions. + +### Login failed for user + +**Error message:** "Login failed for user 'username'" or "Authentication failed" + +**Cause:** The authentication credentials are invalid or the user doesn't have permission to access the database. + +**Solution:** + +1. Verify that the username and password are correct. +1. Check that the user exists in SQL Server and is enabled. +1. Ensure the user has access to the specified database. +1. For Windows Authentication, verify that the credentials are in the correct format (`DOMAIN\User`). +1. Check that the SQL Server authentication mode allows the type of login you're using (SQL Server Authentication, Windows Authentication, or Mixed Mode). + +### Access denied to database + +**Error message:** "Cannot open database 'dbname' requested by the login" + +**Cause:** The authenticated user doesn't have permission to access the specified database. + +**Solution:** + +1. Verify that the database name is correct in the data source configuration. +1. Ensure the user is mapped to the database with appropriate permissions. +1. Grant at least `SELECT` permission on the required tables: + + ```sql + USE [your_database] + GRANT SELECT ON dbo.YourTable TO [your_user] + ``` + +1. Check that the user doesn't have any conflicting permissions from the public role. + +### Windows Authentication (Kerberos) issues + +**Error message:** "Kerberos authentication failed" or "Cannot initialize Kerberos" + +**Cause:** Kerberos configuration is incorrect or incomplete. + +**Solution:** + +1. Verify that the Kerberos configuration file (`krb5.conf`) path is correct in the data source settings. +1. For keytab authentication, ensure the keytab file exists and is readable by Grafana. +1. Check that the realm and KDC settings are correct in the Kerberos configuration. +1. Verify DNS is correctly resolving the KDC servers. +1. Ensure the service principal name (SPN) is registered for the SQL Server instance. + +{{< admonition type="note" >}} +Kerberos authentication is not supported in Grafana Cloud. +{{< /admonition >}} + +### Azure Entra ID authentication errors + +**Error message:** "AADSTS error codes" or "Azure AD authentication failed" + +**Cause:** Azure Entra ID (formerly Azure AD) authentication is misconfigured. + +**Solution:** + +1. For **App Registration** authentication: + - Verify the tenant ID, client ID, and client secret are correct. + - Ensure the app registration has been added as a user in the Azure SQL database. + - Check that the client secret hasn't expired. + +1. For **Managed Identity** authentication: + - Verify `managed_identity_enabled = true` is set in the Grafana server configuration. + - Ensure the managed identity has been added to the Azure SQL database. + - Confirm the Azure resource hosting Grafana has managed identity enabled. + +1. For **Current User** authentication: + - Ensure `user_identity_enabled = true` is set in the Grafana server configuration. + - Verify the app registration is configured to issue both Access Tokens and ID Tokens. + - Check that the required API permissions are configured (`user_impersonation` for Azure SQL). + +For detailed Azure authentication configuration, refer to [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source). + +## Query errors + +These errors occur when there are issues with query syntax or configuration. + +### Time column not found or invalid + +**Error message:** "Could not find time column" or time series visualization shows no data + +**Cause:** The query doesn't return a properly formatted `time` column for time series visualization. + +**Solution:** + +1. Ensure your query includes a column named `time` when using the **Time series** format. +1. Use the `$__time()` macro to rename your date column: `$__time(your_date_column)`. +1. Verify the time column is of a valid SQL date/time type (`datetime`, `datetime2`, `date`) or contains Unix epoch values. +1. Ensure the result set is sorted by the time column using `ORDER BY`. + +### Macro expansion errors + +**Error message:** "Error parsing query" or macros appear unexpanded in the query + +**Cause:** Grafana macros are being used incorrectly. + +**Solution:** + +1. Verify macro syntax: use `$__timeFilter(column)` not `$_timeFilter(column)`. +1. Macros don't work inside stored procedures—use explicit date parameters instead. +1. Check that the column name passed to macros exists in your table. +1. View the expanded query by clicking **Generated SQL** after running the query to debug macro expansion. + +### Timezone and time shift issues + +**Cause:** Time series data appears shifted or doesn't align with expected times. + +**Solution:** + +1. Store timestamps in UTC in your database to avoid timezone issues. +1. Time macros (`$__time`, `$__timeFilter`, etc.) always expand to UTC values. +1. If your timestamps are stored in local time, convert them to UTC in your query: + + ```sql + SELECT + your_datetime_column AT TIME ZONE 'Your Local Timezone' AT TIME ZONE 'UTC' AS time, + value + FROM your_table + ``` + +1. Don't pass timezone parameters to time macros—they're not supported. + +### Query returns too many rows + +**Error message:** "Result set too large" or browser becomes unresponsive + +**Cause:** The query returns more data than can be efficiently processed. + +**Solution:** + +1. Add time filters using `$__timeFilter(column)` to limit data to the dashboard time range. +1. Use aggregations (`AVG`, `SUM`, `COUNT`) with `GROUP BY` instead of returning raw rows. +1. Add a `TOP` clause to limit results: `SELECT TOP 1000 ...`. +1. Use the `$__timeGroup()` macro to aggregate data into time intervals. + +### Stored procedure returns no data + +**Cause:** Stored procedure output isn't being captured correctly. + +**Solution:** + +1. Ensure the stored procedure uses `SELECT` statements, not just variable assignments. +1. Remove `SET NOCOUNT ON` if present, or ensure it's followed by a `SELECT` statement. +1. Verify the stored procedure parameters are being passed correctly. +1. Test the stored procedure directly in SQL Server Management Studio with the same parameters. + +For more information on using stored procedures, refer to the [query editor documentation](ref:mssql-query-editor). + +## Performance issues + +These issues relate to slow queries or high resource usage. + +### Slow query execution + +**Cause:** Queries take a long time to execute. + +**Solution:** + +1. Reduce the dashboard time range to limit data volume. +1. Add indexes to columns used in `WHERE` clauses and time filters. +1. Use aggregations instead of returning individual rows. +1. Increase the **Min time interval** setting to reduce the number of data points. +1. Review the query execution plan in SQL Server Management Studio to identify bottlenecks. + +### Connection pool exhaustion + +**Error message:** "Too many connections" or "Connection pool exhausted" + +**Cause:** Too many concurrent connections to the database. + +**Solution:** + +1. Increase the **Max open** connection limit in the data source configuration. +1. Enable **Auto max idle** to automatically manage idle connections. +1. Reduce the number of panels querying the same data source simultaneously. +1. Check for long-running queries that might be holding connections. + +## Other common issues + +The following issues don't produce specific error messages but are commonly encountered. + +### System databases appear in queries + +**Cause:** Queries accidentally access system databases. + +**Solution:** + +1. The query editor automatically excludes `tempdb`, `model`, `msdb`, and `master` from the database dropdown. +1. Always specify the database in your data source configuration to restrict access. +1. Ensure the database user only has permissions on the intended database. + +### Template variable queries fail + +**Cause:** Variable queries return unexpected results or errors. + +**Solution:** + +1. Verify the variable query syntax is valid SQL that returns a single column. +1. Check that the data source connection is working. +1. Ensure the user has permission to access the tables referenced in the variable query. +1. Test the query in the query editor before using it as a variable query. + +### Data appears incorrect or misaligned + +**Cause:** Data formatting or type conversion issues. + +**Solution:** + +1. Use explicit column aliases to ensure consistent naming: `SELECT value AS metric`. +1. Verify numeric columns are actually numeric types, not strings. +1. Check for `NULL` values that might affect aggregations. +1. Use the `FILL` option in `$__timeGroup()` macro to handle missing data points. + +## Get additional help + +If you continue to experience issues after following this troubleshooting guide: + +1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Review the [Grafana GitHub issues](https://github.com/grafana/grafana/issues) for known bugs. +1. Enable debug logging in Grafana to capture detailed error information. +1. Check SQL Server logs for additional error details. +1. Contact Grafana Support if you're an Enterprise or Cloud customer. + +When reporting issues, include: + +- Grafana version +- SQL Server version +- Error messages (redact sensitive information) +- Steps to reproduce +- Relevant query examples (redact sensitive data) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 67c361b2bdc..d9125991c12 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2836,9 +2836,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../ Keys of features to enable, separated by space. -#### `FEATURE_TOGGLE_NAME = false` +#### `FEATURE_NAME = ` -Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`. +Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details. + +For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
diff --git a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts index 038fe099eeb..2a0f6932141 100644 --- a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { setScopes } from '../utils/scope-helpers'; +import { setScopes, setupScopeRoutes } from '../utils/scope-helpers'; +import { testScopes } from '../utils/scopes'; import { getAdHocFilterOptionValues, @@ -13,6 +14,7 @@ import { } from './cuj-selectors'; import { prepareAPIMocks } from './utils'; +const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); const DASHBOARD_UNDER_TEST = 'cuj-dashboard-1'; test.use({ @@ -34,6 +36,11 @@ test.describe( const adHocFilterPills = getAdHocFilterPills(page); const scopesSelectorInput = getScopesSelectorInput(page); + // Set up routes before any navigation (only for mocked mode) + if (!USE_LIVE_DATA) { + await setupScopeRoutes(page, testScopes()); + } + await test.step('1.Apply filtering to a whole dashboard', async () => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); diff --git a/e2e-playwright/dashboard-cujs/cuj-selectors.ts b/e2e-playwright/dashboard-cujs/cuj-selectors.ts index f2366fd2ef1..548c183ef3f 100644 --- a/e2e-playwright/dashboard-cujs/cuj-selectors.ts +++ b/e2e-playwright/dashboard-cujs/cuj-selectors.ts @@ -66,6 +66,17 @@ export function getScopesDashboards(page: Page) { return page.locator('[data-testid^="scopes-dashboards-"][role="treeitem"]'); } +/** + * Clicks the first available dashboard in the scopes dashboard list. + */ +export async function clickFirstScopesDashboard(page: Page) { + const dashboards = getScopesDashboards(page); + // Wait for at least one dashboard to be visible + await expect(dashboards.first()).toBeVisible({ timeout: 10000 }); + // Click - Playwright will automatically wait for the element to be actionable + await dashboards.first().click(); +} + export function getScopesDashboardsSearchInput(page: Page) { return page.getByTestId('scopes-dashboards-search'); } diff --git a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts index 008e1e5538c..c941c40f6ef 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts @@ -1,8 +1,10 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { setScopes } from '../utils/scope-helpers'; +import { setScopes, setupScopeRoutes } from '../utils/scope-helpers'; +import { testScopes } from '../utils/scopes'; import { + clickFirstScopesDashboard, getAdHocFilterPills, getGroupByInput, getGroupByValues, @@ -21,6 +23,7 @@ test.use({ }, }); +const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); const DASHBOARD_UNDER_TEST = 'cuj-dashboard-1'; const DASHBOARD_UNDER_TEST_2 = 'cuj-dashboard-2'; const NAVIGATE_TO = 'cuj-dashboard-3'; @@ -38,6 +41,11 @@ test.describe( const adhocFilterPills = getAdHocFilterPills(page); const groupByValues = getGroupByValues(page); + // Set up routes before any navigation (only for mocked mode) + if (!USE_LIVE_DATA) { + await setupScopeRoutes(page, testScopes()); + } + await test.step('1.Search dashboard', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); @@ -74,7 +82,7 @@ test.describe( await expect(markdownContent).toContainText(`now-12h`); - await scopesDashboards.first().click(); + await clickFirstScopesDashboard(page); await page.waitForURL('**/d/**'); await expect(markdownContent).toBeVisible(); @@ -117,10 +125,10 @@ test.describe( await groupByVariable.press('Enter'); await groupByVariable.press('Escape'); - await expect(scopesDashboards.first()).toBeVisible(); - const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page); - await scopesDashboards.first().click(); + + await clickFirstScopesDashboard(page); + await page.waitForURL('**/d/**'); await waitForExpectedRequests(); await page.waitForLoadState('networkidle'); @@ -158,8 +166,7 @@ test.describe( const oldFilters = `GroupByVar: ${selectedValues}\n\nAdHocVar: ${processedPills}`; await expect(markdownContent).toContainText(oldFilters); - await expect(scopesDashboards.first()).toBeVisible(); - await scopesDashboards.first().click(); + await clickFirstScopesDashboard(page); await page.waitForURL('**/d/**'); const newPillCount = await adhocFilterPills.count(); diff --git a/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts index 53dd02a0314..e9c1370bbc6 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-view.spec.ts @@ -165,9 +165,8 @@ test.describe( await refreshBtn.click(); - await page.waitForLoadState('networkidle'); - - expect(await panelContent.textContent()).not.toBe(panelContents); + // Wait for the panel content to change (not just for network to complete) + await expect(panelContent).not.toHaveText(panelContents!, { timeout: 10000 }); }); await test.step('6.Turn off refresh', async () => { diff --git a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts index 54ec3ca7a8b..dd1cd50c35f 100644 --- a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts @@ -9,6 +9,7 @@ import { openScopesSelector, searchScopes, selectScope, + setupScopeRoutes, } from '../utils/scope-helpers'; import { testScopes } from '../utils/scopes'; @@ -36,32 +37,37 @@ test.describe( const scopesSelector = getScopesSelectorInput(page); const recentScopesSelector = getRecentScopesSelector(page); const scopeTreeCheckboxes = getScopeTreeCheckboxes(page); + const scopes = testScopes(); + + // Set up routes once before any navigation (only for mocked mode) + if (!USE_LIVE_DATA) { + await setupScopeRoutes(page, scopes); + } await test.step('1.View and select any scope', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 0); - const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); - const selectedScopes = [secondLevelScopes[0]]; //used only in mocked scopes version + const selectedScopes = [secondLevelScopes[0]]; scopeName = await getScopeLeafName(page, 0); let scopeTitle = await getScopeLeafTitle(page, 0); await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[0]); - await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version + await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitle); }); @@ -70,28 +76,27 @@ test.describe( await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 0); - const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); const scopeTitles: string[] = []; - const selectedScopes = [secondLevelScopes[0], secondLevelScopes[1]]; //used only in mocked scopes version + const selectedScopes = [secondLevelScopes[0], secondLevelScopes[1]]; for (let i = 0; i < selectedScopes.length; i++) { scopeName = await getScopeLeafName(page, i); scopeTitles.push(await getScopeLeafTitle(page, i)); - await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[i]); //used only in mocked scopes version + await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[i]); } - await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version + await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitles.join(' + ')); }); @@ -102,8 +107,7 @@ test.describe( expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); await recentScopesSelector.click(); @@ -121,26 +125,25 @@ test.describe( expect.soft(scopesSelector).toHaveAttribute('data-value', ''); - const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 1); - const firstLevelScopes = scopes[2].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[2].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); - const selectedScopes = [secondLevelScopes[0]]; //used only in mocked scopes version + const selectedScopes = [secondLevelScopes[0]]; scopeName = await getScopeLeafName(page, 0); let scopeTitle = await getScopeLeafTitle(page, 0); await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[0]); - await applyScopes(page, USE_LIVE_DATA ? undefined : []); //used only in mocked scopes version + await applyScopes(page, USE_LIVE_DATA ? undefined : []); expect.soft(scopesSelector).toHaveAttribute('data-value', new RegExp(`^${scopeTitle}`)); }); @@ -148,17 +151,16 @@ test.describe( await test.step('5.View pre-completed production entity values as I type', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - const scopes = testScopes(); - await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version + await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); let scopeName = await getScopeTreeName(page, 0); - const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version + const firstLevelScopes = scopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes); scopeName = await getScopeTreeName(page, 1); - const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version + const secondLevelScopes = firstLevelScopes[0].children!; await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes); const scopeSearchOne = await getScopeLeafTitle(page, 0); diff --git a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts index 952e8a3da63..b140e7a9838 100644 --- a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { applyScopes, openScopesSelector, selectScope } from '../utils/scope-helpers'; +import { applyScopes, openScopesSelector, selectScope, setupScopeRoutes } from '../utils/scope-helpers'; import { testScopesWithRedirect } from '../utils/scopes'; test.use({ @@ -16,8 +16,13 @@ test.describe('Scope Redirect Functionality', () => { test('should redirect to custom URL when scope has redirectUrl', async ({ page, gotoDashboardPage }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and open scopes selector', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + // Set up routes BEFORE navigation to ensure all requests are mocked + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Open scopes selector', async () => { await openScopesSelector(page, scopes); }); @@ -40,8 +45,12 @@ test.describe('Scope Redirect Functionality', () => { test('should prioritize redirectUrl over scope navigation fallback', async ({ page, gotoDashboardPage }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and open scopes selector', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Open scopes selector', async () => { await openScopesSelector(page, scopes); }); @@ -68,8 +77,12 @@ test.describe('Scope Redirect Functionality', () => { }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and select scope', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Select and apply scope', async () => { await openScopesSelector(page, scopes); await selectScope(page, 'sn-redirect-fallback', scopes[1]); await applyScopes(page, [scopes[1]]); @@ -112,8 +125,12 @@ test.describe('Scope Redirect Functionality', () => { }) => { const scopes = testScopesWithRedirect(); - await test.step('Navigate to dashboard and select scope', async () => { + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + + await test.step('Select and apply scope', async () => { await openScopesSelector(page, scopes); await selectScope(page, 'sn-redirect-fallback', scopes[1]); await applyScopes(page, [scopes[1]]); @@ -151,9 +168,13 @@ test.describe('Scope Redirect Functionality', () => { test('should not redirect to redirectPath when on active scope navigation', async ({ page, gotoDashboardPage }) => { const scopes = testScopesWithRedirect(); + await test.step('Set up routes and navigate to dashboard', async () => { + await setupScopeRoutes(page, scopes); + await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); + }); + await test.step('Set up scope navigation to dashboard-1', async () => { // First, apply a scope that creates scope navigation to dashboard-1 (without redirectPath) - await gotoDashboardPage({ uid: 'cuj-dashboard-1' }); await openScopesSelector(page, scopes); await selectScope(page, 'sn-redirect-setup', scopes[2]); await applyScopes(page, [scopes[2]]); diff --git a/e2e-playwright/utils/scope-helpers.ts b/e2e-playwright/utils/scope-helpers.ts index fc88a79d8fa..df11644a396 100644 --- a/e2e-playwright/utils/scope-helpers.ts +++ b/e2e-playwright/utils/scope-helpers.ts @@ -6,7 +6,150 @@ import { Resource } from '../../public/app/features/apiserver/types'; import { testScopes } from './scopes'; -const USE_LIVE_DATA = Boolean(process.env.API_CALLS_CONFIG_PATH); +const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH); + +/** + * Sets up all scope-related API routes before navigation. + * This ensures that ALL scope API requests (including those made during initial page load) + * are intercepted by the mocks, preventing RTK Query from caching real API responses. + * + * Call this BEFORE navigating to a page (e.g., before gotoDashboardPage). + */ +export async function setupScopeRoutes(page: Page, scopes: TestScope[]): Promise { + // Route for scope node children (tree structure) + await page.route(`**/apis/scope.grafana.app/v0alpha1/namespaces/*/find/scope_node_children*`, async (route) => { + const url = new URL(route.request().url()); + const parentParam = url.searchParams.get('parent'); + const queryParam = url.searchParams.get('query'); + + // Find the appropriate scopes based on parent + let scopesToReturn = scopes; + if (parentParam) { + // Find nested scopes based on parent name + const findChildren = (items: TestScope[]): TestScope[] => { + for (const item of items) { + if (item.name === parentParam && item.children) { + return item.children; + } + if (item.children) { + const found = findChildren(item.children); + if (found.length > 0) { + return found; + } + } + } + return []; + }; + scopesToReturn = findChildren(scopes); + if (scopesToReturn.length === 0) { + scopesToReturn = scopes; // Fallback to root scopes + } + } + + // Filter by search query if provided + if (queryParam) { + const query = queryParam.toLowerCase(); + const filterByQuery = (items: TestScope[]): TestScope[] => { + const results: TestScope[] = []; + for (const item of items) { + // Exact match on name or title containing the query + if (item.name.toLowerCase() === query || item.title.toLowerCase() === query) { + results.push(item); + } else if (item.name.toLowerCase().includes(query) || item.title.toLowerCase().includes(query)) { + results.push(item); + } + // Also search in children + if (item.children) { + results.push(...filterByQuery(item.children)); + } + } + return results; + }; + scopesToReturn = filterByQuery(scopesToReturn); + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + apiVersion: 'scope.grafana.app/v0alpha1', + kind: 'FindScopeNodeChildrenResults', + metadata: {}, + items: scopesToReturn.map((scope) => ({ + kind: 'ScopeNode', + apiVersion: 'scope.grafana.app/v0alpha1', + metadata: { + name: scope.name, + namespace: 'default', + }, + spec: { + title: scope.title, + description: scope.title, + disableMultiSelect: scope.disableMultiSelect ?? false, + nodeType: scope.children ? 'container' : 'leaf', + ...(parentParam && { parentName: parentParam }), + ...((scope.addLinks || scope.children) && { + linkType: 'scope', + linkId: `scope-${scope.name}`, + }), + ...(scope.redirectPath && { redirectPath: scope.redirectPath }), + }, + })), + }), + }); + }); + + // Route for individual scope fetching + await page.route(`**/apis/scope.grafana.app/v0alpha1/namespaces/*/scopes/*`, async (route) => { + const url = route.request().url(); + const scopeName = url.split('/scopes/')[1]?.split('?')[0]; + + // Find the scope in the test data + const findScope = (items: TestScope[]): TestScope | undefined => { + for (const item of items) { + if (`scope-${item.name}` === scopeName) { + return item; + } + if (item.children) { + const found = findScope(item.children); + if (found) { + return found; + } + } + } + return undefined; + }; + + const scope = findScope(scopes); + + if (scope) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + kind: 'Scope', + apiVersion: 'scope.grafana.app/v0alpha1', + metadata: { + name: `scope-${scope.name}`, + namespace: 'default', + }, + spec: { + title: scope.title, + description: '', + filters: scope.filters, + category: scope.category, + type: scope.type, + }, + }), + }); + } else { + await route.fulfill({ status: 404 }); + } + }); + + // Note: Dashboard bindings and navigations routes are set up dynamically in applyScopes() + // with scope-specific URL patterns to avoid cache issues. They are not set up here. +} export type TestScope = { name: string; @@ -24,6 +167,9 @@ export type TestScope = { type ScopeDashboardBinding = Resource; +/** + * Sets up a route for scope node children requests and waits for the response. + */ export async function scopeNodeChildrenRequest( page: Page, scopes: TestScope[], @@ -68,10 +214,13 @@ export async function scopeNodeChildrenRequest( return page.waitForResponse((response) => response.url().includes(`/find/scope_node_children`)); } +/** + * Opens the scopes selector dropdown and waits for the tree to load. + */ export async function openScopesSelector(page: Page, scopes?: TestScope[]) { const click = async () => await page.getByTestId('scopes-selector-input').click(); - if (!scopes) { + if (!scopes || USE_LIVE_DATA) { await click(); return; } @@ -82,10 +231,13 @@ export async function openScopesSelector(page: Page, scopes?: TestScope[]) { await responsePromise; } +/** + * Expands a scope tree node and waits for children to load. + */ export async function expandScopesSelection(page: Page, parentScope: string, scopes?: TestScope[]) { const click = async () => await page.getByTestId(`scopes-tree-${parentScope}-expand`).click(); - if (!scopes) { + if (!scopes || USE_LIVE_DATA) { await click(); return; } @@ -96,6 +248,9 @@ export async function expandScopesSelection(page: Page, parentScope: string, sco await responsePromise; } +/** + * Sets up a route for individual scope requests and waits for the response. + */ export async function scopeSelectRequest(page: Page, selectedScope: TestScope): Promise { await page.route( `**/apis/scope.grafana.app/v0alpha1/namespaces/*/scopes/scope-${selectedScope.name}`, @@ -125,6 +280,9 @@ export async function scopeSelectRequest(page: Page, selectedScope: TestScope): return page.waitForResponse((response) => response.url().includes(`/scopes/scope-${selectedScope.name}`)); } +/** + * Selects a scope in the tree. + */ export async function selectScope(page: Page, scopeName: string, selectedScope?: TestScope) { const click = async () => { const element = page.locator( @@ -134,7 +292,7 @@ export async function selectScope(page: Page, scopeName: string, selectedScope?: await element.click({ force: true }); }; - if (!selectedScope) { + if (!selectedScope || USE_LIVE_DATA) { await click(); return; } @@ -145,14 +303,22 @@ export async function selectScope(page: Page, scopeName: string, selectedScope?: await responsePromise; } +/** + * Applies the selected scopes and waits for the selector to close and page to settle. + * Sets up routes dynamically with scope-specific URL patterns to avoid cache issues. + */ export async function applyScopes(page: Page, scopes?: TestScope[]) { const click = async () => { await page.getByTestId('scopes-selector-apply').scrollIntoViewIfNeeded(); await page.getByTestId('scopes-selector-apply').click({ force: true }); }; - if (!scopes) { + if (!scopes || USE_LIVE_DATA) { await click(); + // Wait for the apply button to disappear (selector closed) + await page.waitForSelector('[data-testid="scopes-selector-apply"]', { state: 'hidden', timeout: 5000 }); + // Wait for any resulting API calls (dashboard bindings, etc.) to complete + await page.waitForLoadState('networkidle'); return; } @@ -166,7 +332,7 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) { const groups: string[] = ['Most relevant', 'Dashboards', 'Something else', '']; - // Mock scope_dashboard_bindings endpoint + // Mock scope_dashboard_bindings endpoint with scope-specific URL pattern await page.route(dashboardBindingsUrl, async (route) => { await route.fulfill({ status: 200, @@ -220,7 +386,7 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) { }); }); - // Mock scope_navigations endpoint + // Mock scope_navigations endpoint with scope-specific URL pattern await page.route(scopeNavigationsUrl, async (route) => { await route.fulfill({ status: 200, @@ -266,21 +432,23 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) { (response) => response.url().includes(`/find/scope_dashboard_bindings`) || response.url().includes(`/find/scope_navigations`) ); - const scopeRequestPromises: Array> = []; - - for (const scope of scopes) { - scopeRequestPromises.push(scopeSelectRequest(page, scope)); - } await click(); await responsePromise; - await Promise.all(scopeRequestPromises); + // Wait for the apply button to disappear (selector closed) + await page.waitForSelector('[data-testid="scopes-selector-apply"]', { state: 'hidden', timeout: 5000 }); + // Wait for any resulting API calls to complete + await page.waitForLoadState('networkidle'); } -export async function searchScopes(page: Page, value: string, resultScopes: TestScope[]) { +/** + * Searches for scopes in the tree and waits for results. + * Sets up a route dynamically with filtered results to return only matching scopes. + */ +export async function searchScopes(page: Page, value: string, resultScopes?: TestScope[]) { const click = async () => await page.getByTestId('scopes-tree-search').fill(value); - if (!resultScopes) { + if (!resultScopes || USE_LIVE_DATA) { await click(); return; } diff --git a/e2e/dashboards-search-suite/mode0.ini b/e2e/dashboards-search-suite/mode0.ini index 7248a2f81a2..2b38cd62c9b 100644 --- a/e2e/dashboards-search-suite/mode0.ini +++ b/e2e/dashboards-search-suite/mode0.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode1.ini b/e2e/dashboards-search-suite/mode1.ini index 9875afbec80..b2e9da27c3d 100644 --- a/e2e/dashboards-search-suite/mode1.ini +++ b/e2e/dashboards-search-suite/mode1.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini index 18bd29127a0..9517e105306 100644 --- a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini +++ b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = false grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode2.ini b/e2e/dashboards-search-suite/mode2.ini index d255663299e..138e4960cef 100644 --- a/e2e/dashboards-search-suite/mode2.ini +++ b/e2e/dashboards-search-suite/mode2.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode3.ini b/e2e/dashboards-search-suite/mode3.ini index dfc75a310b0..0835dcb3aaa 100644 --- a/e2e/dashboards-search-suite/mode3.ini +++ b/e2e/dashboards-search-suite/mode3.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode4.ini b/e2e/dashboards-search-suite/mode4.ini index a73a0502353..675cc237298 100644 --- a/e2e/dashboards-search-suite/mode4.ini +++ b/e2e/dashboards-search-suite/mode4.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/e2e/dashboards-search-suite/mode5.ini b/e2e/dashboards-search-suite/mode5.ini index a79ab97e792..78b43ee681e 100644 --- a/e2e/dashboards-search-suite/mode5.ini +++ b/e2e/dashboards-search-suite/mode5.ini @@ -3,7 +3,6 @@ [feature_toggles] unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true -unifiedStorageSearchSprinkles = true [unified_storage] enable_search = true diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 250dbd20348..f633ed5b4eb 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1156,11 +1156,6 @@ "count": 2 } }, - "public/app/core/config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "public/app/core/navigation/types.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/go.mod b/go.mod index 102310173d3..fe3e62e3fde 100644 --- a/go.mod +++ b/go.mod @@ -32,14 +32,14 @@ require ( github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources - github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect; @grafana/grafana-operator-experience-squad github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad - github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect; @grafana/grafana-operator-experience-squad github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend @@ -82,14 +82,14 @@ require ( github.com/golang/protobuf v1.5.4 // @grafana/grafana-backend-group github.com/golang/snappy v1.0.0 // @grafana/alerting-backend github.com/google/go-cmp v0.7.0 // @grafana/grafana-backend-group - github.com/google/go-github/v70 v70.0.0 // indirect; @grafana/grafana-git-ui-sync-team + github.com/google/go-github/v70 v70.0.0 // @grafana/grafana-git-ui-sync-team github.com/google/go-querystring v1.1.0 // indirect; @grafana/oss-big-tent github.com/google/uuid v1.6.0 // @grafana/grafana-backend-group github.com/google/wire v0.7.0 // @grafana/grafana-backend-group github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -113,6 +113,7 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling + github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group @@ -260,12 +261,13 @@ require ( github.com/grafana/grafana/pkg/aggregator v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apimachinery v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apiserver v0.0.0 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/plugins v0.0.0 // @grafana/plugins-platform-backend // This needs to be here for other projects that import grafana/grafana // For local development grafana/grafana will always use the local files // Check go.work file for details github.com/grafana/grafana/pkg/promlib v0.0.8 // @grafana/oss-big-tent - github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/semconv v0.0.0 // @grafana/grafana-app-platform-squad ) // Replace the workspace versions @@ -294,6 +296,8 @@ replace ( github.com/grafana/grafana/pkg/aggregator => ./pkg/aggregator github.com/grafana/grafana/pkg/apimachinery => ./pkg/apimachinery github.com/grafana/grafana/pkg/apiserver => ./pkg/apiserver + github.com/grafana/grafana/pkg/plugins => ./pkg/plugins + github.com/grafana/grafana/pkg/semconv => ./pkg/semconv ) require ( @@ -652,11 +656,12 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling - require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect + github.com/Machiel/slugify v1.0.1 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -676,6 +681,8 @@ require ( github.com/google/gnostic v0.7.1 // indirect github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect github.com/grafana/sqlds/v5 v5.0.3 // indirect + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/go-archive v0.1.0 // indirect @@ -697,7 +704,7 @@ require ( replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 // Use our fork of the upstream Alertmanager. -replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 +replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible diff --git a/go.sum b/go.sum index 0a828108d08..67d95c625b6 100644 --- a/go.sum +++ b/go.sum @@ -680,6 +680,7 @@ github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7Og github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= @@ -737,6 +738,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -759,6 +762,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.5 h1:zl/OfRA6nftbBK9qTohYBJ5xvw6C/oNKizR7cZGl3cI= github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -1026,6 +1031,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -1620,8 +1627,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= -github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= +github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= @@ -1664,8 +1671,6 @@ github.com/grafana/grafana/apps/quotas v0.0.0-20251209183543-1013d74f13f2 h1:rDP github.com/grafana/grafana/apps/quotas v0.0.0-20251209183543-1013d74f13f2/go.mod h1:M7bV60iRB61y0ISPG1HX/oNLZtlh0ZF22rUYwNkAKjo= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ= github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 h1:NznuPwItog+rwdVg8hAuGKP29ndRSzJAwhxKldkP8oQ= github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32/go.mod h1:796sq+UcONnSlzA3RtlBZ+b/hrerkZXiEmO8oMjyRwY= github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 h1:/5LKSYgLmAhwA4m6iGUD4w1YkydEWWjazn9qxCFT8W0= @@ -1676,8 +1681,8 @@ github.com/grafana/nanogit v0.3.0 h1:XNEef+4Vi+465ZITJs/g/xgnDRJbWhhJ7iQrAnWZ0oQ github.com/grafana/nanogit v0.3.0/go.mod h1:6s6CCTpyMOHPpcUZaLGI+rgBEKdmxVbhqSGgCK13j7Y= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0= @@ -1753,6 +1758,8 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= @@ -1877,6 +1884,10 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= diff --git a/go.work b/go.work index 208eca0454a..462bbb36a04 100644 --- a/go.work +++ b/go.work @@ -38,6 +38,6 @@ use ( ./pkg/semconv ) -replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 +replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 diff --git a/go.work.sum b/go.work.sum index 0300e5becbc..5f97bbdd6ef 100644 --- a/go.work.sum +++ b/go.work.sum @@ -280,7 +280,6 @@ github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fw github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4= github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= @@ -906,6 +905,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= +github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo= +github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= @@ -996,6 +997,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= @@ -1911,6 +1914,7 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= diff --git a/package.json b/package.json index 72a8f638cb6..a6887b216e4 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,7 @@ "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json", "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", - "themes-schema": "typescript-json-schema ./tsconfig.json NewThemeOptions --include 'packages/grafana-data/src/themes/createTheme.ts' --out public/app/features/theme-playground/schema.generated.json", - "themes-generate": "yarn themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --conditions=@grafana-app/source --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", + "themes-generate": "yarn workspace @grafana/data themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --conditions=@grafana-app/source --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", "themes:usage": "eslint . --ignore-pattern '*.test.ts*' --ignore-pattern '*.spec.ts*' --cache --plugin '@grafana' --rule '{ @grafana/theme-token-usage: \"error\" }'", "typecheck": "tsc --noEmit && yarn run packages:typecheck", "plugins:build-bundled": "echo 'bundled plugins are no longer supported'", @@ -254,7 +253,6 @@ "ts-jest": "29.4.0", "ts-node": "10.9.2", "typescript": "5.9.2", - "typescript-json-schema": "^0.65.1", "webpack": "5.101.0", "webpack-assets-manifest": "^5.1.0", "webpack-cli": "6.0.1", @@ -265,7 +263,7 @@ "webpackbar": "^7.0.0", "yaml": "^2.0.0", "yargs": "^18.0.0", - "zod": "^4.0.0" + "zod": "^4.3.0" }, "dependencies": { "@bsull/augurs": "^0.10.0", diff --git a/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts b/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts index 477e139faac..c6216cc13b5 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/createBaseQuery.ts @@ -34,6 +34,8 @@ export function createBaseQuery({ baseURL }: CreateBaseQueryOptions): BaseQueryF getBackendSrv().fetch({ ...requestOptions, url: baseURL + requestOptions.url, + // Default to GET so backend_srv correctly skips success alerts for queries + method: requestOptions.method ?? 'GET', showErrorAlert: requestOptions.showErrorAlert ?? false, data: requestOptions.body, headers, diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 326b53ccedd..9605b8e9355 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -246,6 +246,8 @@ const injectedRtkApi = api facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, + panelType: queryArg.panelType, + dataSourceType: queryArg.dataSourceType, permission: queryArg.permission, sort: queryArg.sort, limit: queryArg.limit, @@ -674,6 +676,10 @@ export type SearchDashboardsAndFoldersApiArg = { tags?: string[]; /** find dashboards that reference a given libraryPanel */ libraryPanel?: string; + /** find dashboards using panels of a given plugin type */ + panelType?: string; + /** find dashboards using datasources of a given plugin type */ + dataSourceType?: string; /** permission needed for the resource (view, edit, admin) */ permission?: 'view' | 'edit' | 'admin'; /** sortable field */ diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index b6519295c66..40d4299a1a0 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1452,7 +1452,7 @@ export type ConnectionSecure = { /** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */ privateKey?: InlineSecureValue; /** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */ - webhook?: InlineSecureValue; + token?: InlineSecureValue; }; export type BitbucketConnectionConfig = { /** App client ID */ diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index df595973cca..384666ea7f8 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -47,11 +47,12 @@ "LICENSE_APACHE2" ], "scripts": { - "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", + "build": "yarn themes-schema && tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", "clean": "rimraf ./dist ./compiled ./unstable ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json" + "postpack": "mv package.json.bak package.json", + "themes-schema": "tsx ./src/themes/scripts/generateSchema.ts" }, "dependencies": { "@braintree/sanitize-url": "7.0.1", @@ -81,10 +82,12 @@ "tinycolor2": "1.6.0", "tslib": "2.8.1", "uplot": "1.6.32", - "xss": "^1.0.14" + "xss": "^1.0.14", + "zod": "^4.3.0" }, "devDependencies": { "@grafana/scenes": "6.38.0", + "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "16.0.1", "@testing-library/react": "16.3.0", "@types/history": "4.7.11", @@ -101,6 +104,7 @@ "rollup": "^4.22.4", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", + "tsx": "^4.21.0", "typescript": "5.9.2" }, "peerDependencies": { diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts index 87008ddc45f..0c40d731724 100644 --- a/packages/grafana-data/rollup.config.ts +++ b/packages/grafana-data/rollup.config.ts @@ -1,3 +1,4 @@ +import json from '@rollup/plugin-json'; import { createRequire } from 'node:module'; import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts'; @@ -8,13 +9,13 @@ const pkg = rq('./package.json'); export default [ { input: entryPoint, - plugins, + plugins: [...plugins, json()], output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, { input: 'src/unstable.ts', - plugins, + plugins: [...plugins, json()], output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 767da439543..b0aa271be3f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -9,6 +9,7 @@ import { FieldColorModeId } from '../types/fieldColor'; import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides'; import { InterpolateFunction } from '../types/panel'; import { ThresholdsMode } from '../types/thresholds'; +import { MappingType } from '../types/valueMapping'; import { Registry } from '../utils/Registry'; import { locationUtil } from '../utils/location'; import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; @@ -999,6 +1000,45 @@ describe('setDynamicConfigValue', () => { expect(config.custom.property3).toEqual({}); expect(config.displayName).toBeUndefined(); }); + + it('works correctly with multiple value mappings in the same override', () => { + const config: FieldConfig = { + mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }], + }; + + setDynamicConfigValue( + config, + { + id: 'mappings', + value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }], + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [], + field: { type: FieldType.number } as Field, + dataFrameIndex: 0, + } + ); + + setDynamicConfigValue( + config, + { + id: 'mappings', + value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }], + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [], + field: { type: FieldType.number } as Field, + dataFrameIndex: 0, + } + ); + + expect(config.mappings).toHaveLength(3); + expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }); + expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } }); + expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } }); + }); }); describe('getLinksSupplier', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 8b345036c64..bf56f811106 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV return; } - const val = item.process(value.value, context, item.settings); + let val = item.process(value.value, context, item.settings); const remove = val === undefined || val === null; @@ -352,6 +352,15 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV unset(config, item.path); } } else { + // Merge arrays (e.g. mappings) when multiple overrides target the same field + if (Array.isArray(val)) { + const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path); + + if (Array.isArray(existingValue)) { + val = [...existingValue, ...val]; + } + } + if (item.isCustom) { if (!config.custom) { config.custom = {}; diff --git a/packages/grafana-data/src/internal/index.ts b/packages/grafana-data/src/internal/index.ts index e2dab753baa..1b1e3c64a7d 100644 --- a/packages/grafana-data/src/internal/index.ts +++ b/packages/grafana-data/src/internal/index.ts @@ -106,3 +106,4 @@ export { findNumericFieldMinMax } from '../field/fieldOverrides'; export { type PanelOptionsSupplier } from '../panel/PanelPlugin'; export { sanitize, sanitizeUrl } from '../text/sanitize'; export { type NestedValueAccess, type NestedPanelOptions, isNestedPanelOptions } from '../utils/OptionsUIBuilders'; +export { NewThemeOptionsSchema } from '../themes/createTheme'; diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts index 09b94fd3b3e..ee7beab02d6 100644 --- a/packages/grafana-data/src/themes/createColors.ts +++ b/packages/grafana-data/src/themes/createColors.ts @@ -1,83 +1,103 @@ import { merge } from 'lodash'; +import { z } from 'zod'; import { alpha, darken, emphasize, getContrastRatio, lighten } from './colorManipulator'; import { palette } from './palette'; -import { DeepPartial, ThemeRichColor } from './types'; +import { DeepRequired, ThemeRichColor, ThemeRichColorInputSchema } from './types'; +const ThemeColorsModeSchema = z.enum(['light', 'dark']); /** @internal */ -export type ThemeColorsMode = 'light' | 'dark'; +export type ThemeColorsMode = z.infer; +const createThemeColorsBaseSchema = (color: TColor) => + z + .object({ + mode: ThemeColorsModeSchema, + + primary: color, + secondary: color, + info: color, + error: color, + success: color, + warning: color, + + text: z.object({ + primary: z.string().optional(), + secondary: z.string().optional(), + disabled: z.string().optional(), + link: z.string().optional(), + /** Used for auto white or dark text on colored backgrounds */ + maxContrast: z.string().optional(), + }), + + background: z.object({ + /** Dashboard and body background */ + canvas: z.string().optional(), + /** Primary content pane background (panels etc) */ + primary: z.string().optional(), + /** Cards and elements that need to stand out on the primary background */ + secondary: z.string().optional(), + /** + * For popovers and menu backgrounds. This is the same color as primary in most light themes but in dark + * themes it has a brighter shade to help give it contrast against the primary background. + **/ + elevated: z.string().optional(), + }), + + border: z.object({ + weak: z.string().optional(), + medium: z.string().optional(), + strong: z.string().optional(), + }), + + gradients: z.object({ + brandVertical: z.string().optional(), + brandHorizontal: z.string().optional(), + }), + + action: z.object({ + /** Used for selected menu item / select option */ + selected: z.string().optional(), + /** + * @alpha (Do not use from plugins) + * Used for selected items when background only change is not enough (Currently only used for FilterPill) + **/ + selectedBorder: z.string().optional(), + /** Used for hovered menu item / select option */ + hover: z.string().optional(), + /** Used for button/colored background hover opacity */ + hoverOpacity: z.number().optional(), + /** Used focused menu item / select option */ + focus: z.string().optional(), + /** Used for disabled buttons and inputs */ + disabledBackground: z.string().optional(), + /** Disabled text */ + disabledText: z.string().optional(), + /** Disablerd opacity */ + disabledOpacity: z.number().optional(), + }), + + hoverFactor: z.number(), + contrastThreshold: z.number(), + tonalOffset: z.number(), + }) + .partial(); + +// Need to override the zod type to include the generic properly /** @internal */ -export interface ThemeColorsBase { - mode: ThemeColorsMode; - +export type ThemeColorsBase = DeepRequired< + Omit< + z.infer>, + 'primary' | 'secondary' | 'info' | 'error' | 'success' | 'warning' + > +> & { primary: TColor; secondary: TColor; info: TColor; error: TColor; success: TColor; warning: TColor; - - text: { - primary: string; - secondary: string; - disabled: string; - link: string; - /** Used for auto white or dark text on colored backgrounds */ - maxContrast: string; - }; - - background: { - /** Dashboard and body background */ - canvas: string; - /** Primary content pane background (panels etc) */ - primary: string; - /** Cards and elements that need to stand out on the primary background */ - secondary: string; - /** - * For popovers and menu backgrounds. This is the same color as primary in most light themes but in dark - * themes it has a brighter shade to help give it contrast against the primary background. - **/ - elevated: string; - }; - - border: { - weak: string; - medium: string; - strong: string; - }; - - gradients: { - brandVertical: string; - brandHorizontal: string; - }; - - action: { - /** Used for selected menu item / select option */ - selected: string; - /** - * @alpha (Do not use from plugins) - * Used for selected items when background only change is not enough (Currently only used for FilterPill) - **/ - selectedBorder: string; - /** Used for hovered menu item / select option */ - hover: string; - /** Used for button/colored background hover opacity */ - hoverOpacity: number; - /** Used focused menu item / select option */ - focus: string; - /** Used for disabled buttons and inputs */ - disabledBackground: string; - /** Disabled text */ - disabledText: string; - /** Disablerd opacity */ - disabledOpacity: number; - }; - - hoverFactor: number; - contrastThreshold: number; - tonalOffset: number; -} +}; export interface ThemeHoverStrengh {} @@ -89,8 +109,10 @@ export interface ThemeColors extends ThemeColorsBase { emphasize(color: string, amount?: number): string; } +export const ThemeColorsInputSchema = createThemeColorsBaseSchema(ThemeRichColorInputSchema); + /** @internal */ -export type ThemeColorsInput = DeepPartial>; +export type ThemeColorsInput = z.infer; class DarkColors implements ThemeColorsBase> { mode: ThemeColorsMode = 'dark'; diff --git a/packages/grafana-data/src/themes/createShape.ts b/packages/grafana-data/src/themes/createShape.ts index 42291fb78d0..f454eda6861 100644 --- a/packages/grafana-data/src/themes/createShape.ts +++ b/packages/grafana-data/src/themes/createShape.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + /** @beta */ export interface ThemeShape { /** @@ -34,9 +36,12 @@ export interface Radii { } /** @internal */ -export interface ThemeShapeInput { - borderRadius?: number; -} +export const ThemeShapeInputSchema = z.object({ + borderRadius: z.int().nonnegative().optional(), +}); + +/** @internal */ +export type ThemeShapeInput = z.infer; export function createShape(options: ThemeShapeInput): ThemeShape { const baseBorderRadius = options.borderRadius ?? 6; diff --git a/packages/grafana-data/src/themes/createSpacing.ts b/packages/grafana-data/src/themes/createSpacing.ts index 2fa047b3e68..1ba51c61917 100644 --- a/packages/grafana-data/src/themes/createSpacing.ts +++ b/packages/grafana-data/src/themes/createSpacing.ts @@ -1,11 +1,15 @@ // Code based on Material UI // The MIT License (MIT) // Copyright (c) 2014 Call-Em-All +import { z } from 'zod'; /** @internal */ -export type ThemeSpacingOptions = { - gridSize?: number; -}; +export const ThemeSpacingOptionsSchema = z.object({ + gridSize: z.int().positive().optional(), +}); + +/** @internal */ +export type ThemeSpacingOptions = z.infer; /** @internal */ export type ThemeSpacingArgument = number | string; diff --git a/packages/grafana-data/src/themes/createTheme.ts b/packages/grafana-data/src/themes/createTheme.ts index fd4d8080a4e..a4fa773cf52 100644 --- a/packages/grafana-data/src/themes/createTheme.ts +++ b/packages/grafana-data/src/themes/createTheme.ts @@ -1,28 +1,37 @@ +import * as z from 'zod'; + import { createBreakpoints } from './breakpoints'; -import { createColors, ThemeColorsInput } from './createColors'; +import { createColors, ThemeColorsInputSchema } from './createColors'; import { createComponents } from './createComponents'; import { createShadows } from './createShadows'; -import { createShape, ThemeShapeInput } from './createShape'; -import { createSpacing, ThemeSpacingOptions } from './createSpacing'; +import { createShape, ThemeShapeInputSchema } from './createShape'; +import { createSpacing, ThemeSpacingOptionsSchema } from './createSpacing'; import { createTransitions } from './createTransitions'; -import { createTypography, ThemeTypographyInput } from './createTypography'; +import { createTypography, ThemeTypographyInputSchema } from './createTypography'; import { createV1Theme } from './createV1Theme'; -import { createVisualizationColors, ThemeVisualizationColorsInput } from './createVisualizationColors'; +import { createVisualizationColors, ThemeVisualizationColorsInputSchema } from './createVisualizationColors'; import { GrafanaTheme2 } from './types'; import { zIndex } from './zIndex'; -/** @internal */ -export interface NewThemeOptions { - name?: string; - colors?: ThemeColorsInput; - spacing?: ThemeSpacingOptions; - shape?: ThemeShapeInput; - typography?: ThemeTypographyInput; - visualization?: ThemeVisualizationColorsInput; -} +export const NewThemeOptionsSchema = z.object({ + name: z.string(), + id: z.string(), + colors: ThemeColorsInputSchema.optional(), + spacing: ThemeSpacingOptionsSchema.optional(), + shape: ThemeShapeInputSchema.optional(), + typography: ThemeTypographyInputSchema.optional(), + visualization: ThemeVisualizationColorsInputSchema.optional(), +}); /** @internal */ -export function createTheme(options: NewThemeOptions = {}): GrafanaTheme2 { +export type NewThemeOptions = z.infer; + +/** @internal */ +export function createTheme( + options: Omit & { + name?: NewThemeOptions['name']; + } = {} +): GrafanaTheme2 { const { name, colors: colorsInput = {}, diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts index 25c5fa7c91b..3504e52d2fa 100644 --- a/packages/grafana-data/src/themes/createTypography.ts +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -1,6 +1,7 @@ // Code based on Material UI // The MIT License (MIT) // Copyright (c) 2014 Call-Em-All +import { z } from 'zod'; import { ThemeColors } from './createColors'; @@ -40,18 +41,20 @@ export interface ThemeTypographyVariant { letterSpacing?: string; } -export interface ThemeTypographyInput { - fontFamily?: string; - fontFamilyMonospace?: string; - fontSize?: number; - fontWeightLight?: number; - fontWeightRegular?: number; - fontWeightMedium?: number; - fontWeightBold?: number; - // hat's the font-size on the html element. +export const ThemeTypographyInputSchema = z.object({ + fontFamily: z.string().optional(), + fontFamilyMonospace: z.string().optional(), + fontSize: z.number().positive().optional(), + fontWeightLight: z.number().positive().optional(), + fontWeightRegular: z.number().positive().optional(), + fontWeightMedium: z.number().positive().optional(), + fontWeightBold: z.number().positive().optional(), + // what's the font-size on the html element. // 16px is the default font-size used by browsers. - htmlFontSize?: number; -} + htmlFontSize: z.number().positive().optional(), +}); + +export type ThemeTypographyInput = z.infer; const defaultFontFamily = "'Inter', 'Helvetica', 'Arial', sans-serif"; const defaultFontFamilyMonospace = "'Roboto Mono', monospace"; diff --git a/packages/grafana-data/src/themes/createVisualizationColors.ts b/packages/grafana-data/src/themes/createVisualizationColors.ts index fca963c9c07..90acbbc2144 100644 --- a/packages/grafana-data/src/themes/createVisualizationColors.ts +++ b/packages/grafana-data/src/themes/createVisualizationColors.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { FALLBACK_COLOR } from '../types/fieldColor'; import { ThemeColors } from './createColors'; @@ -26,29 +28,44 @@ export interface ThemeVizColor { type ThemeVizColorName = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'; -type ThemeVizColorShadeName = - | `super-light-${T}` - | `light-${T}` - | T - | `semi-dark-${T}` - | `dark-${T}`; +const createShadeSchema = (color: T extends ThemeVizColorName ? T : never) => + z.enum([`super-light-${color}`, `light-${color}`, color, `semi-dark-${color}`, `dark-${color}`]); -type ThemeVizHueGeneric = T extends ThemeVizColorName - ? { - name: T; - shades: Array>; - } - : never; +type ThemeVizColorShadeName = z.infer>>; + +const createHueSchema = (color: T extends ThemeVizColorName ? T : never) => + z.object({ + name: z.literal(color), + shades: z.array( + z.object({ + color: z.string(), + name: createShadeSchema(color), + aliases: z.array(z.string()).optional(), + primary: z.boolean().optional(), + }) + ), + }); + +const ThemeVizHueSchema = z.union([ + createHueSchema('red'), + createHueSchema('orange'), + createHueSchema('yellow'), + createHueSchema('green'), + createHueSchema('blue'), + createHueSchema('purple'), +]); /** * @alpha */ -export type ThemeVizHue = ThemeVizHueGeneric; +export type ThemeVizHue = z.infer; -export type ThemeVisualizationColorsInput = { - hues?: ThemeVizHue[]; - palette?: string[]; -}; +export const ThemeVisualizationColorsInputSchema = z.object({ + hues: z.array(ThemeVizHueSchema).optional(), + palette: z.array(z.string()).optional(), +}); + +export type ThemeVisualizationColorsInput = z.infer; /** * @internal diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts index c4cf352b622..4fca3c5d7be 100644 --- a/packages/grafana-data/src/themes/registry.ts +++ b/packages/grafana-data/src/themes/registry.ts @@ -1,6 +1,6 @@ import { Registry, RegistryItem } from '../utils/Registry'; -import { createTheme } from './createTheme'; +import { createTheme, NewThemeOptionsSchema } from './createTheme'; import * as extraThemes from './themeDefinitions'; import { GrafanaTheme2 } from './types'; @@ -42,9 +42,6 @@ export function getBuiltInThemes(allowedExtras: string[]) { return sortedThemes; } -/** - * There is also a backend list at pkg/services/preference/themes.go - */ const themeRegistry = new Registry(() => { return [ { id: 'system', name: 'System preference', build: getSystemPreferenceTheme }, @@ -53,13 +50,19 @@ const themeRegistry = new Registry(() => { ]; }); -for (const [id, theme] of Object.entries(extraThemes)) { - themeRegistry.register({ - id, - name: theme.name ?? '', - build: () => createTheme(theme), - isExtra: true, - }); +for (const [name, json] of Object.entries(extraThemes)) { + const result = NewThemeOptionsSchema.safeParse(json); + if (!result.success) { + console.error(`Invalid theme definition for theme ${name}: ${result.error.message}`); + } else { + const theme = result.data; + themeRegistry.register({ + id: theme.id, + name: theme.name, + build: () => createTheme(theme), + isExtra: true, + }); + } } function getSystemPreferenceTheme() { diff --git a/packages/grafana-data/src/themes/schema.generated.json b/packages/grafana-data/src/themes/schema.generated.json new file mode 100644 index 00000000000..366ab9c05d6 --- /dev/null +++ b/packages/grafana-data/src/themes/schema.generated.json @@ -0,0 +1,608 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "colors": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["light", "dark"] + }, + "primary": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "secondary": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "error": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "success": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "warning": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "main": { + "type": "string" + }, + "shade": { + "type": "string" + }, + "text": { + "type": "string" + }, + "border": { + "type": "string" + }, + "transparent": { + "type": "string" + }, + "borderTransparent": { + "type": "string" + }, + "contrastText": { + "type": "string" + } + }, + "additionalProperties": false + }, + "text": { + "type": "object", + "properties": { + "primary": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "disabled": { + "type": "string" + }, + "link": { + "type": "string" + }, + "maxContrast": { + "type": "string" + } + }, + "additionalProperties": false + }, + "background": { + "type": "object", + "properties": { + "canvas": { + "type": "string" + }, + "primary": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "elevated": { + "type": "string" + } + }, + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "weak": { + "type": "string" + }, + "medium": { + "type": "string" + }, + "strong": { + "type": "string" + } + }, + "additionalProperties": false + }, + "gradients": { + "type": "object", + "properties": { + "brandVertical": { + "type": "string" + }, + "brandHorizontal": { + "type": "string" + } + }, + "additionalProperties": false + }, + "action": { + "type": "object", + "properties": { + "selected": { + "type": "string" + }, + "selectedBorder": { + "type": "string" + }, + "hover": { + "type": "string" + }, + "hoverOpacity": { + "type": "number" + }, + "focus": { + "type": "string" + }, + "disabledBackground": { + "type": "string" + }, + "disabledText": { + "type": "string" + }, + "disabledOpacity": { + "type": "number" + } + }, + "additionalProperties": false + }, + "hoverFactor": { + "type": "number" + }, + "contrastThreshold": { + "type": "number" + }, + "tonalOffset": { + "type": "number" + } + }, + "additionalProperties": false + }, + "spacing": { + "type": "object", + "properties": { + "gridSize": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "shape": { + "type": "object", + "properties": { + "borderRadius": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "typography": { + "type": "object", + "properties": { + "fontFamily": { + "type": "string" + }, + "fontFamilyMonospace": { + "type": "string" + }, + "fontSize": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightLight": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightRegular": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightMedium": { + "type": "number", + "exclusiveMinimum": 0 + }, + "fontWeightBold": { + "type": "number", + "exclusiveMinimum": 0 + }, + "htmlFontSize": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "visualization": { + "type": "object", + "properties": { + "hues": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "red" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-red", "light-red", "red", "semi-dark-red", "dark-red"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "orange" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-orange", "light-orange", "orange", "semi-dark-orange", "dark-orange"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "yellow" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-yellow", "light-yellow", "yellow", "semi-dark-yellow", "dark-yellow"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "green" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-green", "light-green", "green", "semi-dark-green", "dark-green"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "blue" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-blue", "light-blue", "blue", "semi-dark-blue", "dark-blue"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "purple" + }, + "shades": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string", + "enum": ["super-light-purple", "light-purple", "purple", "semi-dark-purple", "dark-purple"] + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary": { + "type": "boolean" + } + }, + "required": ["color", "name"], + "additionalProperties": false + } + } + }, + "required": ["name", "shades"], + "additionalProperties": false + } + ] + } + }, + "palette": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": ["name", "id"], + "additionalProperties": false +} diff --git a/packages/grafana-data/src/themes/scripts/generateSchema.ts b/packages/grafana-data/src/themes/scripts/generateSchema.ts new file mode 100644 index 00000000000..09369f5e67f --- /dev/null +++ b/packages/grafana-data/src/themes/scripts/generateSchema.ts @@ -0,0 +1,19 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { NewThemeOptionsSchema } from '../createTheme'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +fs.writeFileSync( + path.join(__dirname, '../schema.generated.json'), + JSON.stringify( + NewThemeOptionsSchema.toJSONSchema({ + target: 'draft-07', + }), + undefined, + 2 + ) +); diff --git a/packages/grafana-data/src/themes/themeDefinitions/aubergine.json b/packages/grafana-data/src/themes/themeDefinitions/aubergine.json new file mode 100644 index 00000000000..4baf4f3f439 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/aubergine.json @@ -0,0 +1,50 @@ +{ + "name": "Aubergine", + "id": "aubergine", + "colors": { + "mode": "dark", + "border": { + "weak": "#4F2A3D", + "medium": "#6A3C4B", + "strong": "#8C5A69" + }, + "text": { + "primary": "#E5D0D6", + "secondary": "#D1A8C4", + "disabled": "#B7A0A6", + "link": "#A56BB6", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#8C5A69" + }, + "secondary": { + "main": "#6A3C4B", + "text": "#D1A8C4", + "border": "#8C5A69" + }, + "background": { + "canvas": "#2E1F2D", + "primary": "#3C2136", + "secondary": "#4A2D47", + "elevated": "#4A2D47" + }, + "action": { + "hover": "#6A3C4B", + "selected": "#8C5A69", + "selectedBorder": "#FFB300", + "focus": "#A56BB6", + "hoverOpacity": 0.1, + "disabledText": "#B7A0A6", + "disabledBackground": "#4A2D47", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #6A3C4B 0%, #A56BB6 100%)", + "brandVertical": "linear-gradient(0deg, #6A3C4B 0%, #A56BB6 100%)" + }, + "contrastThreshold": 4, + "hoverFactor": 0.07, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts b/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts deleted file mode 100644 index 967621ebc60..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const aubergineTheme: NewThemeOptions = { - name: 'Aubergine', - colors: { - mode: 'dark', - border: { - weak: '#4F2A3D', - medium: '#6A3C4B', - strong: '#8C5A69', - }, - text: { - primary: '#E5D0D6', - secondary: '#D1A8C4', - disabled: '#B7A0A6', - link: '#A56BB6', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#8C5A69', - }, - secondary: { - main: '#6A3C4B', - text: '#D1A8C4', - border: '#8C5A69', - }, - background: { - canvas: '#2E1F2D', - primary: '#3C2136', - secondary: '#4A2D47', - elevated: '#4A2D47', - }, - action: { - hover: '#6A3C4B', - selected: '#8C5A69', - selectedBorder: '#FFB300', - focus: '#A56BB6', - hoverOpacity: 0.1, - disabledText: '#B7A0A6', - disabledBackground: '#4A2D47', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #6A3C4B 0%, #A56BB6 100%)', - brandVertical: 'linear-gradient(0deg, #6A3C4B 0%, #A56BB6 100%)', - }, - contrastThreshold: 4, - hoverFactor: 0.07, - tonalOffset: 0.15, - }, -}; - -export default aubergineTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/debug.json b/packages/grafana-data/src/themes/themeDefinitions/debug.json new file mode 100644 index 00000000000..a9cce4c5e21 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/debug.json @@ -0,0 +1,60 @@ +{ + "name": "Debug", + "id": "debug", + "colors": { + "mode": "dark", + "background": { + "canvas": "#000033", + "primary": "#000044", + "secondary": "#000055", + "elevated": "#000055" + }, + "text": { + "primary": "#bbbb00", + "secondary": "#888800", + "disabled": "#444400", + "link": "#dddd00", + "maxContrast": "#ffff00" + }, + "border": { + "weak": "#ff000044", + "medium": "#ff000088", + "strong": "#ff0000ff" + }, + "primary": { + "border": "#ff000088", + "text": "#cccc00", + "contrastText": "#ffff00", + "shade": "#9900dd" + }, + "secondary": { + "border": "#ff000088", + "text": "#cccc00", + "contrastText": "#ffff00", + "shade": "#9900dd" + }, + "info": { + "shade": "#9900dd" + }, + "warning": { + "shade": "#9900dd" + }, + "success": { + "shade": "#9900dd" + }, + "error": { + "shade": "#9900dd" + }, + "action": { + "hover": "#9900dd", + "focus": "#6600aa", + "selected": "#440088" + } + }, + "shape": { + "borderRadius": 8 + }, + "spacing": { + "gridSize": 10 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/debug.ts b/packages/grafana-data/src/themes/themeDefinitions/debug.ts deleted file mode 100644 index 22e577faf2c..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/debug.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -/** - * a very ugly theme that is useful for debugging and checking if the theme is applied correctly - * borders are red, - * backgrounds are blue, - * text is yellow, - * and grafana loves you <3 - * (also corners are rounded, action states (hover, focus, selected) are purple) - */ -const debugTheme: NewThemeOptions = { - name: 'Debug', - colors: { - mode: 'dark', - background: { - canvas: '#000033', - primary: '#000044', - secondary: '#000055', - elevated: '#000055', - }, - text: { - primary: '#bbbb00', - secondary: '#888800', - disabled: '#444400', - link: '#dddd00', - maxContrast: '#ffff00', - }, - border: { - weak: '#ff000044', - medium: '#ff000088', - strong: '#ff0000ff', - }, - primary: { - border: '#ff000088', - text: '#cccc00', - contrastText: '#ffff00', - shade: '#9900dd', - }, - secondary: { - border: '#ff000088', - text: '#cccc00', - contrastText: '#ffff00', - shade: '#9900dd', - }, - info: { - shade: '#9900dd', - }, - warning: { - shade: '#9900dd', - }, - success: { - shade: '#9900dd', - }, - error: { - shade: '#9900dd', - }, - action: { - hover: '#9900dd', - focus: '#6600aa', - selected: '#440088', - }, - }, - shape: { - borderRadius: 8, - }, - spacing: { - gridSize: 10, - }, -}; - -export default debugTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.json b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.json new file mode 100644 index 00000000000..1c2304aaff8 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.json @@ -0,0 +1,71 @@ +{ + "name": "Desert bloom", + "id": "desertbloom", + "colors": { + "mode": "light", + "border": { + "weak": "rgba(0, 0, 0, 0.12)", + "medium": "rgba(0, 0, 0, 0.20)", + "strong": "rgba(0, 0, 0, 0.30)" + }, + "text": { + "primary": "#333333", + "secondary": "#555555", + "disabled": "rgba(0, 0, 0, 0.5)", + "link": "#1A82E2", + "maxContrast": "#000000" + }, + "primary": { + "main": "#FF6F61", + "text": "#FE6F61", + "border": "#E55B4D", + "name": "primary", + "shade": "#E55B4D", + "transparent": "#FF6F6126", + "contrastText": "#FFFFFF", + "borderTransparent": "#FF6F6140" + }, + "secondary": { + "main": "#FFFFFF", + "text": "#695f53", + "border": "#d9cec0", + "name": "secondary", + "shade": "#d9cec0", + "transparent": "#FFFFFF26", + "contrastText": "#4c4339", + "borderTransparent": "#FFFFFF40" + }, + "info": { + "main": "#1A82E2" + }, + "success": { + "main": "#4CAF50" + }, + "warning": { + "main": "#FFC107" + }, + "background": { + "canvas": "#FFF8F0", + "primary": "#FFFFFF", + "secondary": "#f9f3e8", + "elevated": "#FFFFFF" + }, + "action": { + "hover": "rgba(168, 156, 134, 0.12)", + "selected": "rgba(168, 156, 134, 0.36)", + "selectedBorder": "#FF6F61", + "focus": "rgba(168, 156, 134, 0.50)", + "hoverOpacity": 0.08, + "disabledText": "rgba(168, 156, 134, 0.5)", + "disabledBackground": "rgba(168, 156, 134, 0.06)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg,rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)", + "brandVertical": "linear-gradient(0deg, rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts deleted file mode 100644 index 8a86b73a0f7..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const desertBloomTheme: NewThemeOptions = { - name: 'Desert bloom', - colors: { - mode: 'light', - border: { - weak: 'rgba(0, 0, 0, 0.12)', - medium: 'rgba(0, 0, 0, 0.20)', - strong: 'rgba(0, 0, 0, 0.30)', - }, - text: { - primary: '#333333', - secondary: '#555555', - disabled: 'rgba(0, 0, 0, 0.5)', - link: '#1A82E2', - maxContrast: '#000000', - }, - primary: { - main: '#FF6F61', - text: '#FE6F61', - border: '#E55B4D', - name: 'primary', - shade: '#E55B4D', - transparent: '#FF6F6126', - contrastText: '#FFFFFF', - borderTransparent: '#FF6F6140', - }, - secondary: { - main: '#FFFFFF', - text: '#695f53', - border: '#d9cec0', - name: 'secondary', - shade: '#d9cec0', - transparent: '#FFFFFF26', - contrastText: '#4c4339', - borderTransparent: '#FFFFFF40', - }, - info: { - main: '#1A82E2', - }, - success: { - main: '#4CAF50', - }, - warning: { - main: '#FFC107', - }, - background: { - canvas: '#FFF8F0', - primary: '#FFFFFF', - secondary: '#f9f3e8', - elevated: '#FFFFFF', - }, - action: { - hover: 'rgba(168, 156, 134, 0.12)', - selected: 'rgba(168, 156, 134, 0.36)', - selectedBorder: '#FF6F61', - focus: 'rgba(168, 156, 134, 0.50)', - hoverOpacity: 0.08, - disabledText: 'rgba(168, 156, 134, 0.5)', - disabledBackground: 'rgba(168, 156, 134, 0.06)', - disabledOpacity: 0.38, - }, - - gradients: { - brandHorizontal: 'linear-gradient(270deg,rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)', - brandVertical: 'linear-gradient(0deg, rgba(255, 111, 97, 1) 0%, rgba(255, 167, 58, 1) 100%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default desertBloomTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json new file mode 100644 index 00000000000..a147afbbe76 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.json @@ -0,0 +1,62 @@ +{ + "name": "Gilded grove", + "id": "gildedgrove", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(200, 200, 180, 0.12)", + "medium": "rgba(200, 200, 180, 0.20)", + "strong": "rgba(200, 200, 180, 0.30)" + }, + "text": { + "primary": "rgb(250, 250, 239)", + "secondary": "rgba(200, 200, 180, 0.85)", + "disabled": "rgba(200, 200, 180, 0.6)", + "link": "#FEAC34", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#FEAC34", + "text": "#FFD783", + "border": "#FFD783", + "name": "primary", + "shade": "rgb(255, 173, 80)", + "transparent": "#FEAC3426", + "contrastText": "#111614", + "borderTransparent": "#FFD78340" + }, + "secondary": { + "main": "rgba(200, 200, 180, 0.10)", + "shade": "rgba(200, 200, 180, 0.14)", + "transparent": "rgba(200, 200, 180, 0.08)", + "text": "rgb(200, 200, 180)", + "contrastText": "rgb(200, 200, 180)", + "border": "rgba(200, 200, 180, 0.08)", + "name": "secondary", + "borderTransparent": "rgba(200, 200, 180, 0.25)" + }, + "background": { + "canvas": "#111614", + "primary": "#1d2220", + "secondary": "#27312E", + "elevated": "#27312E" + }, + "action": { + "hover": "rgba(200, 200, 180, 0.16)", + "selected": "rgba(200, 200, 180, 0.12)", + "selectedBorder": "#FEAC34", + "focus": "rgba(200, 200, 180, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(200, 200, 180, 0.6)", + "disabledBackground": "rgba(200, 200, 180, 0.04)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #FEAC34 0%, #FFD783 100%)", + "brandVertical": "linear-gradient(0.01deg, #FEAC34 0.01%, #FFD783 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts deleted file mode 100644 index bfa3e121329..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const gildedGroveTheme: NewThemeOptions = { - name: 'Gilded grove', - colors: { - mode: 'dark', - border: { - weak: 'rgba(200, 200, 180, 0.12)', - medium: 'rgba(200, 200, 180, 0.20)', - strong: 'rgba(200, 200, 180, 0.30)', - }, - text: { - primary: 'rgb(250, 250, 239)', - secondary: 'rgba(200, 200, 180, 0.85)', - disabled: 'rgba(200, 200, 180, 0.6)', - link: '#FEAC34', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#FEAC34', - text: '#FFD783', - border: '#FFD783', - name: 'primary', - shade: 'rgb(255, 173, 80)', - transparent: '#FEAC3426', - contrastText: '#111614', - borderTransparent: '#FFD78340', - }, - secondary: { - main: 'rgba(200, 200, 180, 0.10)', - shade: 'rgba(200, 200, 180, 0.14)', - transparent: 'rgba(200, 200, 180, 0.08)', - text: 'rgb(200, 200, 180)', - contrastText: 'rgb(200, 200, 180)', - border: 'rgba(200, 200, 180, 0.08)', - name: 'secondary', - borderTransparent: 'rgba(200, 200, 180, 0.25)', - }, - background: { - canvas: '#111614', - primary: '#1d2220', - secondary: '#27312E', - elevated: '#27312E', - }, - action: { - hover: 'rgba(200, 200, 180, 0.16)', - selected: 'rgba(200, 200, 180, 0.12)', - selectedBorder: '#FEAC34', - focus: 'rgba(200, 200, 180, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(200, 200, 180, 0.6)', - disabledBackground: 'rgba(200, 200, 180, 0.04)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #FEAC34 0%, #FFD783 100%)', - brandVertical: 'linear-gradient(0.01deg, #FEAC34 0.01%, #FFD783 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default gildedGroveTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/gloom.json b/packages/grafana-data/src/themes/themeDefinitions/gloom.json new file mode 100644 index 00000000000..8558c942511 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/gloom.json @@ -0,0 +1,52 @@ +{ + "name": "Gloom", + "id": "gloom", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(210, 210, 220, 0.12)", + "medium": "rgba(210, 210, 220, 0.20)", + "strong": "rgba(210, 210, 220, 0.30)" + }, + "text": { + "primary": "rgb(210, 210, 220)", + "secondary": "rgba(210, 210, 220, 0.65)", + "disabled": "rgba(210, 210, 220, 0.48)", + "link": "#f99a5c", + "maxContrast": "#FFF" + }, + "primary": { + "main": "#ff934d", + "text": "#f99a5c", + "border": "#ff934d", + "name": "primary" + }, + "secondary": { + "main": "rgba(195, 195, 245, 0.10)", + "shade": "rgba(195, 195, 245, 0.14)", + "transparent": "rgba(195, 195, 245, 0.08)", + "text": "rgba(195, 195, 245)", + "contrastText": "rgb(195, 195, 245)", + "border": "rgba(195, 195, 245, 0.08)" + }, + "background": { + "canvas": "#000", + "primary": "#121118", + "secondary": "#211e28", + "elevated": "#211e28" + }, + "action": { + "hover": "rgba(195, 195, 245, 0.07)", + "selected": "rgba(195, 195, 245, 0.11)", + "selectedBorder": "#ff934d", + "focus": "rgba(195, 195, 245, 0.07)", + "hoverOpacity": 0.05, + "disabledText": "rgba(210, 210, 220, 0.48)", + "disabledBackground": "rgba(210, 210, 220, 0.04)", + "disabledOpacity": 0.38 + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/gloom.ts b/packages/grafana-data/src/themes/themeDefinitions/gloom.ts deleted file mode 100644 index 49c105626fb..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/gloom.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -/** - * Torkel's GrafanaCon theme - * very WIP state - */ - -const whiteBase = `210, 210, 220`; -const secondaryBase = `195, 195, 245`; - -//const brandMain = '#3d71d9'; -//const brandText = '#6e9fff'; -const brandMain = '#ff934d'; -const brandText = '#f99a5c'; -const disabledText = `rgba(${whiteBase}, 0.48)`; - -const gloomTheme: NewThemeOptions = { - name: 'Gloom', - colors: { - mode: 'dark', - border: { - weak: `rgba(${whiteBase}, 0.12)`, - medium: `rgba(${whiteBase}, 0.20)`, - strong: `rgba(${whiteBase}, 0.30)`, - }, - - text: { - primary: `rgb(${whiteBase})`, - secondary: `rgba(${whiteBase}, 0.65)`, - disabled: disabledText, - link: brandText, - maxContrast: '#FFF', - }, - - primary: { - main: brandMain, - text: brandText, - border: brandMain, - name: 'primary', - }, - - secondary: { - main: `rgba(${secondaryBase}, 0.10)`, - shade: `rgba(${secondaryBase}, 0.14)`, - transparent: `rgba(${secondaryBase}, 0.08)`, - text: `rgba(${secondaryBase})`, - contrastText: `rgb(${secondaryBase})`, - border: `rgba(${secondaryBase}, 0.08)`, - }, - - background: { - canvas: '#000', - primary: '#121118', - secondary: '#211e28', - elevated: '#211e28', - }, - - action: { - hover: `rgba(${secondaryBase}, 0.07)`, - selected: `rgba(${secondaryBase}, 0.11)`, - selectedBorder: brandMain, - focus: `rgba(${secondaryBase}, 0.07)`, - hoverOpacity: 0.05, - disabledText: disabledText, - disabledBackground: `rgba(${whiteBase}, 0.04)`, - disabledOpacity: 0.38, - }, - - // gradients: { - // brandHorizontal: 'linear-gradient(270deg, #ff934d 0%, #FEAC34 100%)', - // brandVertical: 'linear-gradient(0.01deg, #ff934d 0.01%, #FEAC34 99.99%)', - // }, - - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default gloomTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/index.ts b/packages/grafana-data/src/themes/themeDefinitions/index.ts index 151ae00593e..b4270192032 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/index.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/index.ts @@ -1,12 +1,12 @@ -export { default as aubergine } from './aubergine'; -export { default as debug } from './debug'; -export { default as desertbloom } from './desertbloom'; -export { default as gildedgrove } from './gildedgrove'; -export { default as mars } from './mars'; -export { default as matrix } from './matrix'; -export { default as sapphiredusk } from './sapphiredusk'; -export { default as synthwave } from './synthwave'; -export { default as tron } from './tron'; -export { default as victorian } from './victorian'; -export { default as zen } from './zen'; -export { default as gloom } from './gloom'; +export { default as aubergine } from './aubergine.json'; +export { default as debug } from './debug.json'; +export { default as desertbloom } from './desertbloom.json'; +export { default as gildedgrove } from './gildedgrove.json'; +export { default as mars } from './mars.json'; +export { default as matrix } from './matrix.json'; +export { default as sapphiredusk } from './sapphiredusk.json'; +export { default as synthwave } from './synthwave.json'; +export { default as tron } from './tron.json'; +export { default as victorian } from './victorian.json'; +export { default as zen } from './zen.json'; +export { default as gloom } from './gloom.json'; diff --git a/packages/grafana-data/src/themes/themeDefinitions/mars.json b/packages/grafana-data/src/themes/themeDefinitions/mars.json new file mode 100644 index 00000000000..1aeb874f018 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/mars.json @@ -0,0 +1,50 @@ +{ + "name": "Mars", + "id": "mars", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(210, 90, 60, 0.2)", + "medium": "rgba(210, 90, 60, 0.35)", + "strong": "rgba(210, 90, 60, 0.5)" + }, + "text": { + "primary": "#DDDDDD", + "secondary": "#BBBBBB", + "disabled": "rgba(221, 221, 221, 0.5)", + "link": "#FF6F61", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#FF6F61" + }, + "secondary": { + "main": "#6a2f2f", + "text": "#BBBBBB", + "border": "rgba(210, 90, 60, 0.2)" + }, + "background": { + "canvas": "#3C1E1E", + "primary": "#522626", + "secondary": "#6A2F2F", + "elevated": "#6A2F2F" + }, + "action": { + "hover": "rgba(210, 90, 60, 0.16)", + "selected": "rgba(210, 90, 60, 0.12)", + "selectedBorder": "#FF6F61", + "focus": "rgba(210, 90, 60, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(221, 221, 221, 0.5)", + "disabledBackground": "rgba(210, 90, 60, 0.08)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #FF6F61 0%, #D25A3C 100%)", + "brandVertical": "linear-gradient(0.01deg, #FF6F61 0.01%, #D25A3C 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.05, + "tonalOffset": 0.2 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/mars.ts b/packages/grafana-data/src/themes/themeDefinitions/mars.ts deleted file mode 100644 index f1db51e23b2..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/mars.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const marsTheme: NewThemeOptions = { - name: 'Mars', - colors: { - mode: 'dark', - border: { - weak: 'rgba(210, 90, 60, 0.2)', - medium: 'rgba(210, 90, 60, 0.35)', - strong: 'rgba(210, 90, 60, 0.5)', - }, - text: { - primary: '#DDDDDD', - secondary: '#BBBBBB', - disabled: 'rgba(221, 221, 221, 0.5)', - link: '#FF6F61', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#FF6F61', - }, - secondary: { - main: '#6a2f2f', - text: '#BBBBBB', - border: 'rgba(210, 90, 60, 0.2)', - }, - background: { - canvas: '#3C1E1E', - primary: '#522626', - secondary: '#6A2F2F', - elevated: '#6A2F2F', - }, - action: { - hover: 'rgba(210, 90, 60, 0.16)', - selected: 'rgba(210, 90, 60, 0.12)', - selectedBorder: '#FF6F61', - focus: 'rgba(210, 90, 60, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(221, 221, 221, 0.5)', - disabledBackground: 'rgba(210, 90, 60, 0.08)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #FF6F61 0%, #D25A3C 100%)', - brandVertical: 'linear-gradient(0.01deg, #FF6F61 0.01%, #D25A3C 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.05, - tonalOffset: 0.2, - }, -}; - -export default marsTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/matrix.json b/packages/grafana-data/src/themes/themeDefinitions/matrix.json new file mode 100644 index 00000000000..a64a7ccce40 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/matrix.json @@ -0,0 +1,41 @@ +{ + "name": "Matrix", + "id": "matrix", + "colors": { + "mode": "dark", + "background": { + "canvas": "#000000", + "primary": "#020202", + "secondary": "#080808", + "elevated": "#080808" + }, + "text": { + "primary": "#00c017", + "secondary": "#008910", + "disabled": "#006a0c", + "link": "#00ff41", + "maxContrast": "#00ff41" + }, + "border": { + "weak": "#008f1144", + "medium": "#008f1188", + "strong": "#008910" + }, + "primary": { + "main": "#008910" + }, + "secondary": { + "text": "#008910" + }, + "gradients": { + "brandVertical": "linear-gradient(0deg, #008910 0%, #00ff41 100%)", + "brandHorizontal": "linear-gradient(90deg, #008910 0%, #00ff41 100%)" + } + }, + "shape": { + "borderRadius": 0 + }, + "typography": { + "fontFamily": "monospace" + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/matrix.ts b/packages/grafana-data/src/themes/themeDefinitions/matrix.ts deleted file mode 100644 index 51c58b9b394..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/matrix.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const matrixTheme: NewThemeOptions = { - name: 'Matrix', - colors: { - mode: 'dark', - background: { - canvas: '#000000', - primary: '#020202', - secondary: '#080808', - elevated: '#080808', - }, - text: { - primary: '#00c017', - secondary: '#008910', - disabled: '#006a0c', - link: '#00ff41', - maxContrast: '#00ff41', - }, - border: { - weak: '#008f1144', - medium: '#008f1188', - strong: '#008910', - }, - primary: { - main: '#008910', - }, - secondary: { - text: '#008910', - }, - gradients: { - brandVertical: 'linear-gradient(0deg, #008910 0%, #00ff41 100%)', - brandHorizontal: 'linear-gradient(90deg, #008910 0%, #00ff41 100%)', - }, - }, - shape: { - borderRadius: 0, - }, - typography: { - fontFamily: 'monospace', - }, -}; - -export default matrixTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json new file mode 100644 index 00000000000..8d5f7731f05 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.json @@ -0,0 +1,76 @@ +{ + "name": "Sapphire dusk", + "id": "sapphiredusk", + "colors": { + "mode": "dark", + "border": { + "weak": "#232e47", + "medium": "#2c3853", + "strong": "#404d6b" + }, + "text": { + "primary": "#FFFFFF", + "secondary": "#bcccdd", + "disabled": "#838da5", + "link": "#93EBF0", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#93EBF0", + "text": "#a8e9ed", + "border": "#93ebf0", + "name": "primary", + "shade": "#c0f5d9", + "transparent": "#93EBF029", + "contrastText": "#111614", + "borderTransparent": "#93ebf040" + }, + "secondary": { + "main": "#2c364f", + "shade": "#36415e", + "transparent": "rgba(200, 200, 180, 0.08)", + "text": "#d1dfff", + "contrastText": "#acfeff", + "border": "rgba(200, 200, 180, 0.08)", + "name": "secondary", + "borderTransparent": "rgba(200, 200, 180, 0.25)" + }, + "info": { + "main": "#4d4593", + "text": "#a8e9ed", + "border": "#5d54a7" + }, + "error": { + "main": "#c63370" + }, + "success": { + "main": "#1A7F4B" + }, + "warning": { + "main": "#D448EA" + }, + "background": { + "canvas": "#1e273d", + "primary": "#12192e", + "secondary": "#212c47", + "elevated": "#212c47" + }, + "action": { + "hover": "#364057", + "selected": "#364260", + "selectedBorder": "#D448EA", + "focus": "#364057", + "hoverOpacity": 0.08, + "disabledText": "#838da5", + "disabledBackground": "rgba(54, 64, 87, 0.2)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #D346EF 0%, #2C83FE 100%)", + "brandVertical": "linear-gradient(0deg, #D346EF 0%, #2C83FE 100%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts deleted file mode 100644 index c777c61b055..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const sapphireDuskTheme: NewThemeOptions = { - name: 'Sapphire dusk', - colors: { - mode: 'dark', - border: { - weak: '#232e47', - medium: '#2c3853', - strong: '#404d6b', - }, - text: { - primary: '#FFFFFF', - secondary: '#bcccdd', - disabled: '#838da5', - link: '#93EBF0', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#93EBF0', - text: '#a8e9ed', - border: '#93ebf0', - name: 'primary', - shade: '#c0f5d9', - transparent: '#93EBF029', - contrastText: '#111614', - borderTransparent: '#93ebf040', - }, - secondary: { - main: '#2c364f', - shade: '#36415e', - transparent: 'rgba(200, 200, 180, 0.08)', - text: '#d1dfff', - contrastText: '#acfeff', - border: 'rgba(200, 200, 180, 0.08)', - name: 'secondary', - borderTransparent: 'rgba(200, 200, 180, 0.25)', - }, - info: { - main: '#4d4593', - text: '#a8e9ed', - border: '#5d54a7', - }, - error: { - main: '#c63370', - }, - success: { - main: '#1A7F4B', - }, - warning: { - main: '#D448EA', - }, - background: { - canvas: '#1e273d', - primary: '#12192e', - secondary: '#212c47', - elevated: '#212c47', - }, - action: { - hover: '#364057', - selected: '#364260', - selectedBorder: '#D448EA', - focus: '#364057', - hoverOpacity: 0.08, - disabledText: '#838da5', - disabledBackground: 'rgba(54, 64, 87, 0.2)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #D346EF 0%, #2C83FE 100%)', - brandVertical: 'linear-gradient(0deg, #D346EF 0%, #2C83FE 100%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default sapphireDuskTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/synthwave.json b/packages/grafana-data/src/themes/themeDefinitions/synthwave.json new file mode 100644 index 00000000000..377f09f2585 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/synthwave.json @@ -0,0 +1,50 @@ +{ + "name": "Synthwave", + "id": "synthwave", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(255, 20, 147, 0.12)", + "medium": "rgba(255, 20, 147, 0.20)", + "strong": "rgba(255, 20, 147, 0.30)" + }, + "text": { + "primary": "#E0E0E0", + "secondary": "rgba(224, 224, 224, 0.75)", + "disabled": "rgba(224, 224, 224, 0.5)", + "link": "#FF69B4", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#FF1493" + }, + "secondary": { + "main": "#37183a", + "text": "rgba(224, 224, 224, 0.75)", + "border": "rgba(255, 20, 147, 0.10)" + }, + "background": { + "canvas": "#1A1A2E", + "primary": "#16213E", + "secondary": "#0F3460", + "elevated": "#0F3460" + }, + "action": { + "hover": "rgba(255, 20, 147, 0.16)", + "selected": "rgba(255, 20, 147, 0.12)", + "selectedBorder": "#FF1493", + "focus": "rgba(255, 20, 147, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(224, 224, 224, 0.5)", + "disabledBackground": "rgba(255, 20, 147, 0.08)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #FF1493 0%, #1E90FF 100%)", + "brandVertical": "linear-gradient(0.01deg, #FF1493 0.01%, #1E90FF 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.15 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts b/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts deleted file mode 100644 index 5fc53cda0bb..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const synthwaveTheme: NewThemeOptions = { - name: 'Synthwave', - colors: { - mode: 'dark', - border: { - weak: 'rgba(255, 20, 147, 0.12)', - medium: 'rgba(255, 20, 147, 0.20)', - strong: 'rgba(255, 20, 147, 0.30)', - }, - text: { - primary: '#E0E0E0', - secondary: 'rgba(224, 224, 224, 0.75)', - disabled: 'rgba(224, 224, 224, 0.5)', - link: '#FF69B4', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#FF1493', - }, - secondary: { - main: '#37183a', - text: 'rgba(224, 224, 224, 0.75)', - border: 'rgba(255, 20, 147, 0.10)', - }, - background: { - canvas: '#1A1A2E', - primary: '#16213E', - secondary: '#0F3460', - elevated: '#0F3460', - }, - action: { - hover: 'rgba(255, 20, 147, 0.16)', - selected: 'rgba(255, 20, 147, 0.12)', - selectedBorder: '#FF1493', - focus: 'rgba(255, 20, 147, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(224, 224, 224, 0.5)', - disabledBackground: 'rgba(255, 20, 147, 0.08)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #FF1493 0%, #1E90FF 100%)', - brandVertical: 'linear-gradient(0.01deg, #FF1493 0.01%, #1E90FF 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.15, - }, -}; - -export default synthwaveTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/tron.json b/packages/grafana-data/src/themes/themeDefinitions/tron.json new file mode 100644 index 00000000000..a92cf07fcb0 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/tron.json @@ -0,0 +1,50 @@ +{ + "name": "Tron", + "id": "tron", + "colors": { + "mode": "dark", + "border": { + "weak": "rgba(0, 255, 255, 0.12)", + "medium": "rgba(0, 255, 255, 0.20)", + "strong": "rgba(0, 255, 255, 0.30)" + }, + "text": { + "primary": "#E0E0E0", + "secondary": "rgba(224, 224, 224, 0.75)", + "disabled": "rgba(224, 224, 224, 0.5)", + "link": "#00FFFF", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#00FFFF" + }, + "secondary": { + "main": "#0b2e36", + "text": "rgba(224, 224, 224, 0.75)", + "border": "rgba(0, 255, 255, 0.10)" + }, + "background": { + "canvas": "#0A0F18", + "primary": "#0F1B2A", + "secondary": "#152234", + "elevated": "#152234" + }, + "action": { + "hover": "rgba(0, 255, 255, 0.16)", + "selected": "rgba(0, 255, 255, 0.12)", + "selectedBorder": "#00FFFF", + "focus": "rgba(0, 255, 255, 0.16)", + "hoverOpacity": 0.08, + "disabledText": "rgba(224, 224, 224, 0.5)", + "disabledBackground": "rgba(0, 255, 255, 0.08)", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #00FFFF 0%, #29ABE2 100%)", + "brandVertical": "linear-gradient(0.01deg, #00FFFF 0.01%, #29ABE2 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.05, + "tonalOffset": 0.2 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/tron.ts b/packages/grafana-data/src/themes/themeDefinitions/tron.ts deleted file mode 100644 index a9f0b8c3ed4..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/tron.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const tronTheme: NewThemeOptions = { - name: 'Tron', - colors: { - mode: 'dark', - border: { - weak: 'rgba(0, 255, 255, 0.12)', - medium: 'rgba(0, 255, 255, 0.20)', - strong: 'rgba(0, 255, 255, 0.30)', - }, - text: { - primary: '#E0E0E0', - secondary: 'rgba(224, 224, 224, 0.75)', - disabled: 'rgba(224, 224, 224, 0.5)', - link: '#00FFFF', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#00FFFF', - }, - secondary: { - main: '#0b2e36', - text: 'rgba(224, 224, 224, 0.75)', - border: 'rgba(0, 255, 255, 0.10)', - }, - background: { - canvas: '#0A0F18', - primary: '#0F1B2A', - secondary: '#152234', - elevated: '#152234', - }, - action: { - hover: 'rgba(0, 255, 255, 0.16)', - selected: 'rgba(0, 255, 255, 0.12)', - selectedBorder: '#00FFFF', - focus: 'rgba(0, 255, 255, 0.16)', - hoverOpacity: 0.08, - disabledText: 'rgba(224, 224, 224, 0.5)', - disabledBackground: 'rgba(0, 255, 255, 0.08)', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #00FFFF 0%, #29ABE2 100%)', - brandVertical: 'linear-gradient(0.01deg, #00FFFF 0.01%, #29ABE2 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.05, - tonalOffset: 0.2, - }, -}; - -export default tronTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/victorian.json b/packages/grafana-data/src/themes/themeDefinitions/victorian.json new file mode 100644 index 00000000000..14483578450 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/victorian.json @@ -0,0 +1,54 @@ +{ + "name": "Victorian", + "id": "victorian", + "colors": { + "mode": "dark", + "border": { + "weak": "#3A2C22", + "medium": "#3A2C22", + "strong": "#4B3D32" + }, + "text": { + "primary": "#D9D0A2", + "secondary": "#C4B89B", + "disabled": "#A89F91", + "link": "#C28A4D", + "maxContrast": "#FFFFFF" + }, + "primary": { + "main": "#C28A4D" + }, + "secondary": { + "main": "#3A2C22", + "text": "#C4B89B", + "border": "#4B3D32" + }, + "background": { + "canvas": "#1F1510", + "primary": "#2C1A13", + "secondary": "#402A21", + "elevated": "#402A21" + }, + "action": { + "hover": "#3A2C22", + "selected": "#4B3D32", + "selectedBorder": "#C28A4D", + "focus": "#C28A4D", + "hoverOpacity": 0.1, + "disabledText": "#A89F91", + "disabledBackground": "#402A21", + "disabledOpacity": 0.38 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #D9D0a1 0%, #C28A4D 100%)", + "brandVertical": "linear-gradient(0.01deg, #D9D0a1 0.01%, #C28A4D 99.99%)" + }, + "contrastThreshold": 4, + "hoverFactor": 0.07, + "tonalOffset": 0.15 + }, + "typography": { + "fontFamily": "\"Georgia\", \"Times New Roman\", serif", + "fontFamilyMonospace": "'Courier New', monospace" + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/victorian.ts b/packages/grafana-data/src/themes/themeDefinitions/victorian.ts deleted file mode 100644 index 32ddbcb244e..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/victorian.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const victorianTheme: NewThemeOptions = { - name: 'Victorian', - colors: { - mode: 'dark', - border: { - weak: '#3A2C22', - medium: '#3A2C22', - strong: '#4B3D32', - }, - text: { - primary: '#D9D0A2', - secondary: '#C4B89B', - disabled: '#A89F91', - link: '#C28A4D', - maxContrast: '#FFFFFF', - }, - primary: { - main: '#C28A4D', - }, - secondary: { - main: '#3A2C22', - text: '#C4B89B', - border: '#4B3D32', - }, - background: { - canvas: '#1F1510', - primary: '#2C1A13', - secondary: '#402A21', - elevated: '#402A21', - }, - action: { - hover: '#3A2C22', - selected: '#4B3D32', - selectedBorder: '#C28A4D', - focus: '#C28A4D', - hoverOpacity: 0.1, - disabledText: '#A89F91', - disabledBackground: '#402A21', - disabledOpacity: 0.38, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #D9D0a1 0%, #C28A4D 100%)', - brandVertical: 'linear-gradient(0.01deg, #D9D0a1 0.01%, #C28A4D 99.99%)', - }, - contrastThreshold: 4, - hoverFactor: 0.07, - tonalOffset: 0.15, - }, - typography: { - fontFamily: '"Georgia", "Times New Roman", serif', - fontFamilyMonospace: "'Courier New', monospace", - }, -}; - -export default victorianTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/zen.json b/packages/grafana-data/src/themes/themeDefinitions/zen.json new file mode 100644 index 00000000000..99a8b900052 --- /dev/null +++ b/packages/grafana-data/src/themes/themeDefinitions/zen.json @@ -0,0 +1,50 @@ +{ + "name": "Zen", + "id": "zen", + "colors": { + "mode": "light", + "text": { + "primary": "#333333", + "secondary": "#666666", + "disabled": "#B8B8B8", + "link": "#4F9F6E", + "maxContrast": "#000000" + }, + "border": { + "weak": "#B1B7B3", + "medium": "#A2A8A2", + "strong": "#7C7F7A" + }, + "primary": { + "main": "#6D8E6D" + }, + "secondary": { + "main": "#E0E0E0", + "text": "#666666", + "border": "#A2A8A2" + }, + "background": { + "canvas": "#F4F4F4", + "primary": "#E9E9E9", + "secondary": "#D8D8D8", + "elevated": "#E9E9E9" + }, + "action": { + "hover": "#D1D1D1", + "selected": "#B8B8B8", + "selectedBorder": "#88B88B", + "hoverOpacity": 0.1, + "focus": "#D1D1D1", + "disabledBackground": "#E0E0E0", + "disabledText": "#B8B8B8", + "disabledOpacity": 0.5 + }, + "gradients": { + "brandHorizontal": "linear-gradient(270deg, #88B88B 0%, #6D8E6D 100%)", + "brandVertical": "linear-gradient(0.01deg, #88B88B 0.01%, #6D8E6D 99.99%)" + }, + "contrastThreshold": 3, + "hoverFactor": 0.03, + "tonalOffset": 0.2 + } +} diff --git a/packages/grafana-data/src/themes/themeDefinitions/zen.ts b/packages/grafana-data/src/themes/themeDefinitions/zen.ts deleted file mode 100644 index f2735f41b74..00000000000 --- a/packages/grafana-data/src/themes/themeDefinitions/zen.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NewThemeOptions } from '../createTheme'; - -const zenTheme: NewThemeOptions = { - name: 'Zen', - colors: { - mode: 'light', - text: { - primary: '#333333', - secondary: '#666666', - disabled: '#B8B8B8', - link: '#4F9F6E', - maxContrast: '#000000', - }, - border: { - weak: '#B1B7B3', - medium: '#A2A8A2', - strong: '#7C7F7A', - }, - primary: { - main: '#6D8E6D', - }, - secondary: { - main: '#E0E0E0', - text: '#666666', - border: '#A2A8A2', - }, - background: { - canvas: '#F4F4F4', - primary: '#E9E9E9', - secondary: '#D8D8D8', - elevated: '#E9E9E9', - }, - action: { - hover: '#D1D1D1', - selected: '#B8B8B8', - selectedBorder: '#88B88B', - hoverOpacity: 0.1, - focus: '#D1D1D1', - disabledBackground: '#E0E0E0', - disabledText: '#B8B8B8', - disabledOpacity: 0.5, - }, - gradients: { - brandHorizontal: 'linear-gradient(270deg, #88B88B 0%, #6D8E6D 100%)', - brandVertical: 'linear-gradient(0.01deg, #88B88B 0.01%, #6D8E6D 99.99%)', - }, - contrastThreshold: 3, - hoverFactor: 0.03, - tonalOffset: 0.2, - }, -}; - -export default zenTheme; diff --git a/packages/grafana-data/src/themes/types.ts b/packages/grafana-data/src/themes/types.ts index f586937cf3c..d77c53062d3 100644 --- a/packages/grafana-data/src/themes/types.ts +++ b/packages/grafana-data/src/themes/types.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { GrafanaTheme } from '../types/theme'; import { ThemeBreakpoints } from './breakpoints'; @@ -35,27 +37,36 @@ export interface GrafanaTheme2 { flags: {}; } -/** @alpha */ -export interface ThemeRichColor { +export const ThemeRichColorInputSchema = z.object({ /** color intent (primary, secondary, info, error, etc) */ - name: string; + name: z.string().optional(), /** Main color */ - main: string; + main: z.string().optional(), /** Used for hover */ - shade: string; + shade: z.string().optional(), /** Used for text */ - text: string; + text: z.string().optional(), /** Used for borders */ - border: string; + border: z.string().optional(), /** Used subtly colored backgrounds */ - transparent: string; + transparent: z.string().optional(), /** Used for weak colored borders like larger alert/banner boxes and smaller badges and tags */ - borderTransparent: string; + borderTransparent: z.string().optional(), /** Text color for text ontop of main */ - contrastText: string; -} + contrastText: z.string().optional(), +}); + +export const ThemeRichColorSchema = ThemeRichColorInputSchema.required(); + +/** @alpha */ +export type ThemeRichColor = z.infer; /** @internal */ export type DeepPartial = { [P in keyof T]?: DeepPartial; }; + +/** @internal */ +export type DeepRequired = Required<{ + [P in keyof T]: T[P] extends Required ? T[P] : DeepRequired; +}>; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3982bdc33e5..4850555be08 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -527,10 +527,6 @@ export interface FeatureToggles { */ dashboardTemplates?: boolean; /** - * Sets the logs table as default visualisation in logs explore - */ - logsExploreTableDefaultVisualization?: boolean; - /** * Enables the new alert list view design */ alertingListViewV2?: boolean; @@ -626,10 +622,6 @@ export interface FeatureToggles { */ exploreLogsAggregatedMetrics?: boolean; /** - * Used in Logs Drilldown to limit the time range - */ - exploreLogsLimitedTimeRange?: boolean; - /** * Enables the gRPC client to authenticate with the App Platform by using ID & access tokens */ appPlatformGrpcClientAuth?: boolean; @@ -657,14 +649,6 @@ export interface FeatureToggles { */ rolePickerDrawer?: boolean; /** - * Enable unified storage search - */ - unifiedStorageSearch?: boolean; - /** - * Enable sprinkles on unified storage search - */ - unifiedStorageSearchSprinkles?: boolean; - /** * Pick the dual write mode from database configs */ managedDualWriter?: boolean; @@ -703,10 +687,6 @@ export interface FeatureToggles { */ passwordlessMagicLinkAuthentication?: boolean; /** - * Display Related Logs in Grafana Metrics Drilldown - */ - exploreMetricsRelatedLogs?: boolean; - /** * Adds support for quotes and special characters in label values for Prometheus queries */ prometheusSpecialCharsInLabelValues?: boolean; diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 34e672b66f5..3dc7215a2cf 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -52,6 +52,7 @@ export const availableIconsIndex = { bookmark: true, 'book-open': true, 'brackets-curly': true, + brain: true, 'browser-alt': true, bug: true, building: true, diff --git a/packages/grafana-data/src/unstable.ts b/packages/grafana-data/src/unstable.ts index 8a42447206f..3200085428a 100644 --- a/packages/grafana-data/src/unstable.ts +++ b/packages/grafana-data/src/unstable.ts @@ -9,5 +9,4 @@ * and be subject to the standard policies */ -// This is a dummy export so typescript doesn't error importing an "empty module" -export const unstable = {}; +export { default as themeJsonSchema } from './themes/schema.generated.json'; diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index 36a9faa541b..93dd9837897 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -29,7 +29,6 @@ "@grafana-app/source": "./src/internal/index.ts" }, "./eslint-plugin": { - "@grafana-app/source": "./src/eslint/index.cjs", "types": "./src/eslint/index.d.ts", "default": "./src/eslint/index.cjs" } diff --git a/packages/grafana-test-utils/src/fixtures/scopes.ts b/packages/grafana-test-utils/src/fixtures/scopes.ts new file mode 100644 index 00000000000..1d7c6ee0143 --- /dev/null +++ b/packages/grafana-test-utils/src/fixtures/scopes.ts @@ -0,0 +1,500 @@ +/** + * Types for Scopes API - matching @grafana/data types + */ + +export interface ScopeFilter { + key: string; + value: string; + operator: 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match'; +} + +export interface ScopeSpec { + title: string; + filters: ScopeFilter[]; +} + +export interface Scope { + metadata: { + name: string; + }; + spec: ScopeSpec; +} + +export interface ScopeNodeSpec { + nodeType: 'container' | 'leaf'; + title: string; + description?: string; + disableMultiSelect?: boolean; + linkType?: 'scope'; + linkId?: string; + parentName: string; +} + +export interface ScopeNode { + metadata: { + name: string; + }; + spec: ScopeNodeSpec; +} + +export interface ScopeDashboardBindingSpec { + dashboard: string; + scope: string; +} + +export interface ScopeDashboardBindingStatus { + dashboardTitle: string; + groups?: string[]; +} + +export interface ScopeDashboardBinding { + metadata: { + name: string; + }; + spec: ScopeDashboardBindingSpec; + status: ScopeDashboardBindingStatus; +} + +export interface ScopeNavigation { + metadata: { + name: string; + }; + spec: { + url: string; + scope: string; + subScope?: string; + preLoadSubScopeChildren?: boolean; + expandOnLoad?: boolean; + disableSubScopeSelection?: boolean; + }; + status: { + title: string; + groups?: string[]; + }; +} + +export const MOCK_SCOPES: Scope[] = [ + { + metadata: { name: 'cloud' }, + spec: { + title: 'Cloud', + filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }], + }, + }, + { + metadata: { name: 'dev' }, + spec: { + title: 'Dev', + filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }], + }, + }, + { + metadata: { name: 'ops' }, + spec: { + title: 'Ops', + filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }], + }, + }, + { + metadata: { name: 'prod' }, + spec: { + title: 'Prod', + filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }], + }, + }, + { + metadata: { name: 'grafana' }, + spec: { + title: 'Grafana', + filters: [{ key: 'app', value: 'grafana', operator: 'equals' }], + }, + }, + { + metadata: { name: 'mimir' }, + spec: { + title: 'Mimir', + filters: [{ key: 'app', value: 'mimir', operator: 'equals' }], + }, + }, + { + metadata: { name: 'loki' }, + spec: { + title: 'Loki', + filters: [{ key: 'app', value: 'loki', operator: 'equals' }], + }, + }, + { + metadata: { name: 'tempo' }, + spec: { + title: 'Tempo', + filters: [{ key: 'app', value: 'tempo', operator: 'equals' }], + }, + }, + { + metadata: { name: 'dev-env' }, + spec: { + title: 'Development', + filters: [{ key: 'environment', value: 'dev', operator: 'equals' }], + }, + }, + { + metadata: { name: 'prod-env' }, + spec: { + title: 'Production', + filters: [{ key: 'environment', value: 'prod', operator: 'equals' }], + }, + }, +]; + +const dashboardBindingsGenerator = ( + scopes: string[], + dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }> +) => + scopes.reduce((scopeAcc, scopeTitle) => { + const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); + + return [ + ...scopeAcc, + ...dashboards.reduce((acc, { dashboardTitle, groups, dashboardKey }, idx) => { + dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); + const group = !groups + ? '' + : groups.length === 1 + ? groups[0] === '' + ? '' + : `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-` + : `multiple${idx}-`; + const dashboard = `${group}${dashboardKey}`; + + return [ + ...acc, + { + metadata: { name: `${scope}-${dashboard}` }, + spec: { + dashboard, + scope, + }, + status: { + dashboardTitle, + groups, + }, + }, + ]; + }, []), + ]; + }, []); + +export const MOCK_SCOPE_DASHBOARD_BINDINGS: ScopeDashboardBinding[] = [ + ...dashboardBindingsGenerator( + ['Grafana'], + [ + { dashboardTitle: 'Data Sources', groups: ['General'] }, + { dashboardTitle: 'Usage', groups: ['General'] }, + { dashboardTitle: 'Frontend Errors', groups: ['Observability'] }, + { dashboardTitle: 'Frontend Logs', groups: ['Observability'] }, + { dashboardTitle: 'Backend Errors', groups: ['Observability'] }, + { dashboardTitle: 'Backend Logs', groups: ['Observability'] }, + { dashboardTitle: 'Usage Overview', groups: ['Usage'] }, + { dashboardTitle: 'Data Sources', groups: ['Usage'] }, + { dashboardTitle: 'Stats', groups: ['Usage'] }, + { dashboardTitle: 'Overview', groups: [''] }, + { dashboardTitle: 'Frontend' }, + { dashboardTitle: 'Stats' }, + ] + ), + ...dashboardBindingsGenerator( + ['Loki', 'Tempo', 'Mimir'], + [ + { dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] }, + { dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] }, + { dashboardTitle: 'Overview' }, + { dashboardTitle: 'Stats', dashboardKey: 'another-stats' }, + ] + ), + ...dashboardBindingsGenerator( + ['Dev', 'Ops', 'Prod'], + [ + { dashboardTitle: 'Overview', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Metrics', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Labels', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Overview', groups: ['Usage Insights'] }, + { dashboardTitle: 'Data Sources', groups: ['Usage Insights'] }, + { dashboardTitle: 'Query Errors', groups: ['Usage Insights'] }, + { dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] }, + { dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] }, + { dashboardTitle: 'Billing/Usage' }, + ] + ), +]; + +export const MOCK_NODES: ScopeNode[] = [ + { + metadata: { name: 'applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Application Scopes', + parentName: '', + }, + }, + { + metadata: { name: 'cloud' }, + spec: { + nodeType: 'container', + title: 'Cloud', + description: 'Cloud Scopes', + disableMultiSelect: true, + linkType: 'scope', + linkId: 'cloud', + parentName: '', + }, + }, + { + metadata: { name: 'applications-grafana' }, + spec: { + nodeType: 'leaf', + title: 'Grafana', + description: 'Grafana', + linkType: 'scope', + linkId: 'grafana', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-mimir' }, + spec: { + nodeType: 'leaf', + title: 'Mimir', + description: 'Mimir', + linkType: 'scope', + linkId: 'mimir', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-loki' }, + spec: { + nodeType: 'leaf', + title: 'Loki', + description: 'Loki', + linkType: 'scope', + linkId: 'loki', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-tempo' }, + spec: { + nodeType: 'leaf', + title: 'Tempo', + description: 'Tempo', + linkType: 'scope', + linkId: 'tempo', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-cloud' }, + spec: { + nodeType: 'container', + title: 'Cloud', + description: 'Application/Cloud Scopes', + linkType: 'scope', + linkId: 'cloud', + parentName: 'applications', + }, + }, + { + metadata: { name: 'applications-cloud-dev' }, + spec: { + nodeType: 'leaf', + title: 'Dev', + description: 'Dev', + linkType: 'scope', + linkId: 'dev', + parentName: 'applications-cloud', + }, + }, + { + metadata: { name: 'applications-cloud-ops' }, + spec: { + nodeType: 'leaf', + title: 'Ops', + description: 'Ops', + linkType: 'scope', + linkId: 'ops', + parentName: 'applications-cloud', + }, + }, + { + metadata: { name: 'applications-cloud-prod' }, + spec: { + nodeType: 'leaf', + title: 'Prod', + description: 'Prod', + linkType: 'scope', + linkId: 'prod', + parentName: 'applications-cloud', + }, + }, + { + metadata: { name: 'cloud-dev' }, + spec: { + nodeType: 'leaf', + title: 'Dev', + description: 'Dev', + linkType: 'scope', + linkId: 'dev', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-ops' }, + spec: { + nodeType: 'leaf', + title: 'Ops', + description: 'Ops', + linkType: 'scope', + linkId: 'ops', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-prod' }, + spec: { + nodeType: 'leaf', + title: 'Prod', + description: 'Prod', + linkType: 'scope', + linkId: 'prod', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Cloud/Application Scopes', + parentName: 'cloud', + }, + }, + { + metadata: { name: 'cloud-applications-grafana' }, + spec: { + nodeType: 'leaf', + title: 'Grafana', + description: 'Grafana', + linkType: 'scope', + linkId: 'grafana', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'cloud-applications-mimir' }, + spec: { + nodeType: 'leaf', + title: 'Mimir', + description: 'Mimir', + linkType: 'scope', + linkId: 'mimir', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'cloud-applications-loki' }, + spec: { + nodeType: 'leaf', + title: 'Loki', + description: 'Loki', + linkType: 'scope', + linkId: 'loki', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'cloud-applications-tempo' }, + spec: { + nodeType: 'leaf', + title: 'Tempo', + description: 'Tempo', + linkType: 'scope', + linkId: 'tempo', + parentName: 'cloud-applications', + }, + }, + { + metadata: { name: 'environments' }, + spec: { + nodeType: 'container', + title: 'Environments', + description: 'Environment Scopes', + disableMultiSelect: true, + parentName: '', + }, + }, + { + metadata: { name: 'environments-dev' }, + spec: { + nodeType: 'container', + title: 'Development', + description: 'Development Environment', + linkType: 'scope', + linkId: 'dev-env', + parentName: 'environments', + }, + }, + { + metadata: { name: 'environments-prod' }, + spec: { + nodeType: 'container', + title: 'Production', + description: 'Production Environment', + linkType: 'scope', + linkId: 'prod-env', + parentName: 'environments', + }, + }, +]; + +export const MOCK_SUB_SCOPE_MIMIR_ITEMS: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-item-2' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-2', + }, + status: { + title: 'Mimir Dashboard 2', + groups: ['Observability'], + }, + }, +]; + +export const MOCK_SUB_SCOPE_LOKI_ITEMS: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { + scope: 'loki', + url: '/d/loki-dashboard-1', + }, + status: { + title: 'Loki Dashboard 1', + groups: ['General'], + }, + }, +]; diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 5fa473b55d5..83a34d7455f 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -12,6 +12,7 @@ import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v import appPlatformDashboardv1beta1Handlers from './apis/dashboard.grafana.app/v1beta1/handlers'; import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; +import appPlatformScopev0alpha1Handlers from './apis/scope.grafana.app/v0alpha1/handlers'; const allHandlers: HttpHandler[] = [ // Legacy handlers @@ -29,6 +30,7 @@ const allHandlers: HttpHandler[] = [ ...appPlatformFolderv1beta1Handlers, ...appPlatformIamv0alpha1Handlers, ...appPlatformCollectionsv1alpha1Handlers, + ...appPlatformScopev0alpha1Handlers, ]; export default allHandlers; diff --git a/packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts new file mode 100644 index 00000000000..098548caad7 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/scope.grafana.app/v0alpha1/handlers.ts @@ -0,0 +1,131 @@ +import { HttpResponse, http } from 'msw'; + +import { + MOCK_NODES, + MOCK_SCOPES, + MOCK_SCOPE_DASHBOARD_BINDINGS, + MOCK_SUB_SCOPE_LOKI_ITEMS, + MOCK_SUB_SCOPE_MIMIR_ITEMS, + ScopeNavigation, +} from '../../../../fixtures/scopes'; +import { getErrorResponse } from '../../../helpers'; + +const API_BASE = '/apis/scope.grafana.app/v0alpha1/namespaces/:namespace'; + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/scopes/:name + * + * Fetches a single scope by name. + */ +const getScopeHandler = () => + http.get<{ namespace: string; name: string }>(`${API_BASE}/scopes/:name`, ({ params }) => { + const { name } = params; + const scope = MOCK_SCOPES.find((s) => s.metadata.name === name); + + if (!scope) { + return HttpResponse.json(getErrorResponse(`scopes.scope.grafana.app "${name}" not found`, 404), { + status: 404, + }); + } + + return HttpResponse.json(scope); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/scopenodes/:name + * + * Fetches a single scope node by name. + */ +const getScopeNodeHandler = () => + http.get<{ namespace: string; name: string }>(`${API_BASE}/scopenodes/:name`, ({ params }) => { + const { name } = params; + const node = MOCK_NODES.find((n) => n.metadata.name === name); + + if (!node) { + return HttpResponse.json(getErrorResponse(`scopenodes.scope.grafana.app "${name}" not found`, 404), { + status: 404, + }); + } + + return HttpResponse.json(node); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_node_children + * + * Finds scope node children based on parent and query filters. + */ +const findScopeNodeChildrenHandler = () => + http.get(`${API_BASE}/find/scope_node_children`, ({ request }) => { + const url = new URL(request.url); + const parent = url.searchParams.get('parent') ?? ''; + const query = url.searchParams.get('query') ?? ''; + const limitParam = url.searchParams.get('limit'); + const names = url.searchParams.getAll('names'); + + let filtered = MOCK_NODES.filter( + (node) => node.spec.parentName === parent && node.spec.title.toLowerCase().includes(query.toLowerCase()) + ); + + if (names.length > 0) { + filtered = MOCK_NODES.filter((node) => names.includes(node.metadata.name)); + } + + if (limitParam) { + const limit = parseInt(limitParam, 10); + filtered = filtered.slice(0, limit); + } + + return HttpResponse.json({ + items: filtered, + }); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_dashboard_bindings + * + * Finds scope dashboard bindings for the given scope names. + */ +const findScopeDashboardBindingsHandler = () => + http.get(`${API_BASE}/find/scope_dashboard_bindings`, ({ request }) => { + const url = new URL(request.url); + const scopeNames = url.searchParams.getAll('scope'); + + const bindings = MOCK_SCOPE_DASHBOARD_BINDINGS.filter((b) => scopeNames.includes(b.spec.scope)); + + return HttpResponse.json({ + items: bindings, + }); + }); + +/** + * GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_navigations + * + * Finds scope navigations for the given scope names. + */ +const findScopeNavigationsHandler = () => + http.get(`${API_BASE}/find/scope_navigations`, ({ request }) => { + const url = new URL(request.url); + const scopeNames = url.searchParams.getAll('scope'); + + let items: ScopeNavigation[] = []; + + if (scopeNames.includes('mimir')) { + items = [...items, ...MOCK_SUB_SCOPE_MIMIR_ITEMS]; + } + if (scopeNames.includes('loki')) { + items = [...items, ...MOCK_SUB_SCOPE_LOKI_ITEMS]; + } + + return HttpResponse.json({ + items, + }); + }); + +export default [ + getScopeHandler(), + getScopeNodeHandler(), + findScopeNodeChildrenHandler(), + findScopeDashboardBindingsHandler(), + findScopeNavigationsHandler(), +]; diff --git a/packages/grafana-test-utils/src/unstable.ts b/packages/grafana-test-utils/src/unstable.ts index d03bc685d9e..698d57a774c 100644 --- a/packages/grafana-test-utils/src/unstable.ts +++ b/packages/grafana-test-utils/src/unstable.ts @@ -2,3 +2,12 @@ import { wellFormedTree } from './fixtures/folders'; export const getFolderFixtures = wellFormedTree; export { MOCK_TEAMS, MOCK_TEAM_GROUPS } from './fixtures/teams'; +export { + MOCK_SCOPES, + MOCK_NODES, + MOCK_SCOPE_DASHBOARD_BINDINGS, + MOCK_SUB_SCOPE_MIMIR_ITEMS, + MOCK_SUB_SCOPE_LOKI_ITEMS, +} from './fixtures/scopes'; +export { default as allHandlers } from './handlers/all-handlers'; +export { default as scopeHandlers } from './handlers/apis/scope.grafana.app/v0alpha1/handlers'; diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx index a10ad157120..5b919c05ec2 100644 --- a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx @@ -14,6 +14,8 @@ export type Props = React.ComponentProps & { isConfigured: boolean; /** Called when the user clicks on the "Reset" button in order to clear the secret */ onReset: () => void; + /** If true, the text area will grow to fill available width. */ + grow?: boolean; }; export const CONFIGURED_TEXT = 'configured'; @@ -35,11 +37,11 @@ const getStyles = (theme: GrafanaTheme2) => { * * https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs */ -export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => { +export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => { const styles = useStyles2(getStyles); return ( - + {!isConfigured &&