diff --git a/.betterer.results b/.betterer.results index adbcfdd1d1e..0cbbf2c2539 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1283,14 +1283,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], - "public/app/core/navigation/testRoutes.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] - ], "public/app/core/navigation/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -1825,9 +1817,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], "public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], @@ -4790,11 +4779,7 @@ exports[`better eslint`] = { "public/app/features/playlist/PlaylistTableRows.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], "public/app/features/playlist/StartModal.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -4988,9 +4973,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Do not use any type assertions.", "12"] ], - "public/app/features/plugins/extensions/getPluginExtensions.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -5867,8 +5849,7 @@ exports[`better eslint`] = { ], "public/app/plugins/datasource/alertmanager/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/plugins/datasource/azuremonitor/azureMetadata/index.ts:5381": [ [0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7a998bb1099..5bc3cab07ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -693,6 +693,7 @@ embed.go @grafana/grafana-as-code /.github/workflows/commands.yml @torkelo /.github/workflows/community-release.yml @grafana/grafana-release-guild /.github/workflows/detect-breaking-changes-* @grafana/plugins-platform-frontend +/.github/workflows/auto-triager.yml @grafana/plugins-platform-frontend /.github/workflows/doc-validator.yml @grafana/docs-tooling /.github/workflows/epic-add-to-platform-ux-parent-project.yml @meanmina /.github/workflows/github-release.yml @grafana/grafana-release-guild diff --git a/.github/commands.json b/.github/commands.json index 5f02fc4967e..dac2f7ed60d 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -458,5 +458,637 @@ "addToProject": { "url": "https://github.com/orgs/grafana/projects/471" } + }, + { + "type": "label", + "name": "area/alerting", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/52" + } + }, + { + "type": "label", + "name": "area/backend", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/665" + } + }, + { + "type": "label", + "name": "area/dashboard", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "type/accessibility", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/78" + } + }, + { + "type": "label", + "name": "area/transformations", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/56" + } + }, + { + "type": "label", + "name": "area/dashboard/templating", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/auth", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/plugins", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/76" + } + }, + { + "type": "label", + "name": "area/annotations", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/56" + } + }, + { + "type": "label", + "name": "area/annotations", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/provisioning", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/599" + } + }, + { + "type": "label", + "name": "area/provisioning", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/52" + } + }, + { + "type": "label", + "name": "area/scenes", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "datasource/Postgres", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "area/panel/logs", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/203" + } + }, + { + "type": "label", + "name": "area/dashboards/panel", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/public-dashboards", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/482" + } + }, + { + "type": "label", + "name": "area/exploremetrics", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/112" + } + }, + { + "type": "label", + "name": "area/auth/oauth", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/panel/traceview", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/221" + } + }, + { + "type": "label", + "name": "area/auth/rbac", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/expressions", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/112" + } + }, + { + "type": "label", + "name": "area/dashboard/data-links", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/backend/api", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/319" + } + }, + { + "type": "label", + "name": "area/security", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/47" + } + }, + { + "type": "label", + "name": "area/admin/user", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/panel/node-graph", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/221" + } + }, + { + "type": "label", + "name": "area/dashboard/snapshot", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/dashboard/folders", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/configuration", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/96" + } + }, + { + "type": "label", + "name": "area/frontend/library-panels", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/auth/ldap", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/dashboard/timerange", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "datasource/MSSQL", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "label", + "name": "datasource/Jaeger", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "area/navigation", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/78" + } + }, + { + "type": "label", + "name": "area/search", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/78" + } + }, + { + "type": "label", + "name": "area/search", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/panel/flame-graph", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/221" + } + }, + { + "type": "label", + "name": "area/auth/serviceaccount", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/field/overrides", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/backend/db/sql", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "area/image-rendering", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/482" + } + }, + { + "type": "label", + "name": "area/backend/db/postgres", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/96" + } + }, + { + "type": "label", + "name": "area/permissions", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/660" + } + }, + { + "type": "label", + "name": "area/dashboard/import", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/backend/db/sqlite", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/599" + } + }, + { + "type": "label", + "name": "area/backend/db/mysql", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/45" + } + }, + { + "type": "label", + "name": "type/browser-compatibility", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/78" + } + }, + { + "type": "label", + "name": "area/panel/text", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/78" + } + }, + { + "type": "label", + "name": "area/dashboard/links", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "datasource/Zipkin", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "area/streaming", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/319" + } + }, + { + "type": "label", + "name": "area/frontend/login", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/78" + } + }, + { + "type": "label", + "name": "datasource/Parca", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "datasource/CSV", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "area/panel/datagrid", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/56" + } + }, + { + "type": "label", + "name": "area/panel/dashboard-list", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "team/grafana-aws-datasources", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/97" + } + }, + { + "type": "label", + "name": "datasource/Zabbix", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "datasource/TestDataDB", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/76" + } + }, + { + "type": "label", + "name": "datasource/Splunk", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/55" + } + }, + { + "type": "label", + "name": "datasource/SiteWIse", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/97" + } + }, + { + "type": "label", + "name": "datasource/Phlare", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/221" + } + }, + { + "type": "label", + "name": "datasource/JSON", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "datasource/BigQuery", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "datasource/Alertmanager", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/52" + } + }, + { + "type": "label", + "name": "area/recorded-queries", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/112" + } + }, + { + "type": "label", + "name": "area/panel/singlestat", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/56" + } + }, + { + "type": "label", + "name": "area/panel/annotation-list", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/202" + } + }, + { + "type": "label", + "name": "area/editor", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/grafana/grafana/projects/21" + } + }, + { + "type": "label", + "name": "area/data/export", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/56" + } + }, + { + "type": "label", + "name": "datasource/azure-cosmosdb", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/190" + } + }, + { + "type": "label", + "name": "datasource/X-Ray", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/97" + } + }, + { + "type": "label", + "name": "datasource/Timestream", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/97" + } + }, + { + "type": "label", + "name": "datasource/OpenSearch", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/97" + } + }, + { + "type": "label", + "name": "datasource/GoogleSheets", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "datasource/GitHub", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } + }, + { + "type": "label", + "name": "datasource/MySQL", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/457" + } } -] \ No newline at end of file +] diff --git a/.github/issue-opened.json b/.github/issue-opened.json index 86b0164d2b0..276c9464a76 100644 --- a/.github/issue-opened.json +++ b/.github/issue-opened.json @@ -6,6 +6,6 @@ }, "noLabels": true, "addLabel": "internal", - "comment": " please add one or more appropriate labels. Here are some tips:\r\n\r\n- if you are making an issue, TODO, or reminder for yourself or your team, please add one label that best describes the product or feature area. Please also add the issue to your project board. :rocket:\r\n\r\n- if you are making an issue for any other reason (docs typo, you found a bug, etc), please add at least one label that best describes the product or feature that you are discussing (e.g. `area/alerting`, `datasource/loki`, `type/docs`, `type/bug`, etc). [Our issue triage](https://github.com/grafana/grafana/blob/main/ISSUE_TRIAGE.md#3-categorizing-an-issue) doc also provides additional guidance on labeling. :rocket:\r\n\r\n Thank you! :heart:" + "comment": " please add one or more appropriate labels. Here are some tips:\r\n\r\n- if you are making an issue, TODO, or reminder for yourself or your team, please add one label that best describes the product or feature area. Please also add the issue to your project board. :rocket:\r\n\r\n- if you are making an issue for any other reason (docs typo, you found a bug, etc), please add at least one label that best describes the product or feature that you are discussing (e.g. `area/alerting`, `datasource/loki`, `type/docs`, `type/bug`, etc). [Our issue triage](https://github.com/grafana/grafana/blob/main/contribute/ISSUE_TRIAGE.md#3-categorize-an-issue) doc also provides additional guidance on labeling. :rocket:\r\n\r\n Thank you! :heart:" } ] \ No newline at end of file diff --git a/.github/workflows/auto-triager.yml b/.github/workflows/auto-triager.yml new file mode 100644 index 00000000000..80eeba554cb --- /dev/null +++ b/.github/workflows/auto-triager.yml @@ -0,0 +1,75 @@ +# This workflow is triggered when a new issue is opened +# It will run an internal github action to try to automate the triage process +name: Auto Triage Issues +on: + issues: + types: [opened] + +jobs: + config: + runs-on: "ubuntu-latest" + outputs: + has-secrets: ${{ steps.check.outputs.has-secrets }} + steps: + - name: "Check for secrets" + id: check + shell: bash + run: | + if [ -n "${{ (secrets.GRAFANA_DELIVERY_BOT_APP_ID != '' && + secrets.GRAFANA_DELIVERY_BOT_APP_PEM != '' && + secrets.OPENAI_API_KEY != '' && + secrets.SLACK_WEBHOOK_URL != '' + ) || '' }}" ]; then + echo "has-secrets=1" >> "$GITHUB_OUTPUT" + fi + auto-triage: + needs: config + if: needs.config.outputs.has-secrets + runs-on: ubuntu-latest + steps: + - name: "Generate token" + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} + private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} + + - name: Checkout auto-triager repository + uses: actions/checkout@v4 + with: + repository: grafana/auto-triager + path: auto-triager + token: ${{ steps.generate_token.outputs.token }} + + - name: Send issue to the auto triager action + id: auto_triage + # https://github.com/grafana/auto-triager/blob/main/action.yml + #uses: grafana/auto-triager@main + uses: ./auto-triager + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue_number: ${{ github.event.issue.number }} + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + # Leaving the actionin monitoring mode for now + # should be set to true when ready to use + # add_labels: true + add_labels: false + + - name: Labels from auto triage + run: | + echo ${{ steps.auto_triage.outputs.triage_labels }} + + - name: "Send Slack notification" + if : ${{ steps.auto_triage.outputs.triage_labels != '' }} + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: > + { + "icon_emoji": ":robocto:", + "username": "Auto Triager", + "type": "mrkdwn", + "text": "Auto triager found the following labels: ${{ steps.auto_triage.outputs.triage_labels }} for [issue #${{ github.event.issue.number }}](${{ github.event.issue.html_url }})", + "channel": "#triage-automation-ci" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/docs/sources/administration/announcement-banner/_index.md b/docs/sources/administration/announcement-banner/_index.md index 2d62de69734..b67e0d7ca1d 100644 --- a/docs/sources/administration/announcement-banner/_index.md +++ b/docs/sources/administration/announcement-banner/_index.md @@ -1,5 +1,4 @@ --- -draft: true keywords: - grafana - announcement @@ -52,9 +51,6 @@ To create or update an announcement banner, follow these steps: 1. Select the banner's end date and time in the **Ends at** field. By default, the banner is displayed indefinitely. You can set a date and time for the banner to stop displaying. -1. Select the banner's visibility. - **Everyone** - The banner is visible to all users, including on login page. - **Authenticated users** - The banner is visible to only authenticated users. 1. Select the type of banner in the **Variant** field. This determines the color of the banner's background. 1. Click **Save** to save the banner settings. diff --git a/docs/sources/administration/roles-and-permissions/access-control/_index.md b/docs/sources/administration/roles-and-permissions/access-control/_index.md index 967f2f440ad..d4aac02464b 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/_index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/_index.md @@ -92,7 +92,7 @@ Assign fixed roles when the basic roles do not meet your permission requirements - [Alerting]({{< relref "../../../alerting/" >}}) - [Annotations]({{< relref "../../../dashboards/build-dashboards/annotate-visualizations" >}}) -- [API keys]({{< relref "../../api-keys/" >}}) +- [API keys](/docs/grafana//administration/service-accounts/migrate-api-keys/) - [Dashboards and folders]({{< relref "../../../dashboards/" >}}) - [Data sources]({{< relref "../../../datasources/" >}}) - [Explore]({{< relref "../../../explore/" >}}) diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index c2d81caf443..35d8a9a572a 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -14,13 +14,13 @@ weight: 80 # RBAC permissions, actions, and scopes -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise/" >}}) and [Grafana Cloud](/docs/grafana-cloud). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -A permission is comprised of an action and a scope. When creating a custom role, consider the actions the user can perform and the resource(s) on which they can perform those actions. +A permission is comprised of an action and a scope. When creating a custom role, consider the actions the user can perform and the resources on which they can perform those actions. -To learn more about the Grafana resources to which you can apply RBAC, refer to [Resources with RBAC permissions]({{< relref "../#fixed-roles" >}}). +To learn more about the Grafana resources to which you can apply RBAC, refer to [Resources with RBAC permissions](/docs/grafana//administration/roles-and-permissions/access-control/#fixed-roles). - **Action:** An action describes what tasks a user can perform on a resource. - **Scope:** A scope describes where an action can be performed, such as reading a specific user profile. In this example, a permission is associated with the scope `users:` to the relevant role. @@ -29,222 +29,223 @@ To learn more about the Grafana resources to which you can apply RBAC, refer to The following list contains role-based access control actions. -| Action | Applicable scope | Description | -| ------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `alert.instances.external:read` | `datasources:*`
`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | -| `alert.instances.external:write` | `datasources:*`
`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. | -| `alert.instances:create` | n/a | Create silences in the current organization. | -| `alert.instances:read` | n/a | Read alerts and silences in the current organization. | -| `alert.instances:write` | n/a | Update and expire silences in the current organization. | -| `alert.notifications.external:read` | `datasources:*`
`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications.external:write` | `datasources:*`
`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. | -| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. | -| `alert.rules.external:read` | `datasources:*`
`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | -| `alert.rules.external:write` | `datasources:*`
`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | -| `alert.rules:create` | `folders:*`
`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:delete` | `folders:*`
`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:read` | `folders:*`
`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:write` | `folders:*`
`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.silences:create` | `folders:*`
`folders:uid:*` | Create rule-specific silences in a folder and its subfolders. | -| `alert.silences:read` | `folders:*`
`folders:uid:*` | Read all general silences and rule-specific silences in a folder and its subfolders. | -| `alert.silences:write` | `folders:*`
`folders:uid:*` | Update and expire rule-specific silences in a folder and its subfolders. | -| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | -| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. | -| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | -| `alert.provisioning.provenance:write` | n/a | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources | -| `annotations:create` | `annotations:*`
`annotations:type:*` | Create annotations. | -| `annotations:delete` | `annotations:*`
`annotations:type:*` | Delete annotations. | -| `annotations:read` | `annotations:*`
`annotations:type:*` | Read annotations and annotation tags. | -| `annotations:write` | `annotations:*`
`annotations:type:*` | Update annotations. | -| `apikeys:create` | n/a | Create API keys. | -| `apikeys:read` | `apikeys:*`
`apikeys:id:*` | Read API keys. | -| `apikeys:delete` | `apikeys:*`
`apikeys:id:*` | Delete API keys. | -| `dashboards:create` | `folders:*`
`folders:uid:*` | Create dashboards in one or more folders and their subfolders. | -| `dashboards:delete` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Delete one or more dashboards. | -| `dashboards.insights:read` | n/a | Read dashboard insights data and see presence indicators. | -| `dashboards.permissions:read` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Read permissions for one or more dashboards. | -| `dashboards.permissions:write` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Update permissions for one or more dashboards. | -| `dashboards:read` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Read one or more dashboards. | -| `dashboards:write` | `dashboards:*`
`dashboards:uid:*`
`folders:*`
`folders:uid:*` | Update one or more dashboards. | -| `dashboards.public:write` | `dashboards:*`
`dashboards:uid:*` | Write public dashboard configuration. | -| `datasources.caching:read` | `datasources:*`
`datasources:uid:*` | Read data source query caching settings. | -| `datasources.caching:write` | `datasources:*`
`datasources:uid:*` | Update data source query caching settings. | -| `datasources:create` | n/a | Create data sources. | -| `datasources:delete` | `datasources:*`
`datasources:uid:*` | Delete data sources. | -| `datasources:explore` | n/a | Enable access to the **Explore** tab. | -| `datasources.id:read` | `datasources:*`
`datasources:uid:*` | Read data source IDs. | -| `datasources.insights:read` | n/a | Read data sources insights data. | -| `datasources.permissions:read` | `datasources:*`
`datasources:uid:*` | List data source permissions. | -| `datasources.permissions:write` | `datasources:*`
`datasources:uid:*` | Update data source permissions. | -| `datasources:query` | `datasources:*`
`datasources:uid:*` | Query data sources. | -| `datasources:read` | `datasources:*`
`datasources:uid:*` | List data sources. | -| `datasources:write` | `datasources:*`
`datasources:uid:*` | Update data sources. | -| `featuremgmt.read` | n/a | Read feature toggles. | -| `featuremgmt.write` | n/a | Write feature toggles. | -| `folders.permissions:read` | `folders:*`
`folders:uid:*` | Read permissions for one or more folders and their subfolders. | -| `folders.permissions:write` | `folders:*`
`folders:uid:*` | Update permissions for one or more folders and their subfolders. | -| `folders:create` | n/a | Create folders in the root level. If granted together with `folders:write`, also allows creating subfolders under all folders that the user can update. | -| `folders:delete` | `folders:*`
`folders:uid:*` | Delete one or more folders and their subfolders. | -| `folders:read` | `folders:*`
`folders:uid:*` | Read one or more folders and their subfolders. | -| `folders:write` | `folders:*`
`folders:uid:*` | Update one or more folders and their subfolders. If granted together with `folders:create` permission, also allows creating subfolders under these folders. | -| `ldap.config:reload` | n/a | Reload the LDAP configuration. | -| `ldap.status:read` | n/a | Verify the availability of the LDAP server or servers. | -| `ldap.user:read` | n/a | Read users via LDAP. | -| `ldap.user:sync` | n/a | Sync users via LDAP. | -| `library.panels:create` | `folders:*`
`folders:uid:*` | Create a library panel in one or more folders and their subfolders. | -| `library.panels:read` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Read one or more library panels. | -| `library.panels:write` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Update one or more library panels. | -| `library.panels:delete` | `folders:*`
`folders:uid:*`
`library.panels:*`
`library.panels:uid:*` | Delete one or more library panels. | -| `licensing.reports:read` | n/a | Get custom permission reports. | -| `licensing:delete` | n/a | Delete the license token. | -| `licensing:read` | n/a | Read licensing information. | -| `licensing:write` | n/a | Update the license token. | -| `org.users:write` | `users:*`
`users:id:*` | Update the organization role (`None`, `Viewer`, `Editor`, or `Admin`) of a user. | -| `org.users:add` | `users:*`
`users:id:*` | Add a user to an organization or invite a new user to an organization. | -| `org.users:read` | `users:*`
`users:id:*` | Get user profiles within an organization. | -| `org.users:remove` | `users:*`
`users:id:*` | Remove a user from an organization. | -| `orgs.preferences:read` | n/a | Read organization preferences. | -| `orgs.preferences:write` | n/a | Update organization preferences. | -| `orgs.quotas:read` | n/a | Read organization quotas. | -| `orgs.quotas:write` | n/a | Update organization quotas. | -| `orgs:create` | n/a | Create an organization. | -| `orgs:delete` | n/a | Delete one or more organizations. | -| `orgs:read` | n/a | Read one or more organizations. | -| `orgs:write` | n/a | Update one or more organizations. | -| `plugins.app:access` | `plugins:*`
`plugins:id:*` | Access one or more application plugins (still enforcing the organization role) | -| `plugins:install` | n/a | Install and uninstall plugins. | -| `plugins:write` | `plugins:*`
`plugins:id:*` | Edit settings for one or more plugins. | -| `provisioning:reload` | `provisioners:*` | Reload provisioning files. To find the exact scope for specific provisioner, see [Scope definitions]({{< relref "#scope-definitions" >}}). | -| `reports:create` | n/a | Create reports. | -| `reports:write` | `reports:*`
`reports:id:*` | Update reports. | -| `reports.settings:read` | n/a | Read report settings. | -| `reports.settings:write` | n/a | Update report settings. | -| `reports:delete` | `reports:*`
`reports:id:*` | Delete reports. | -| `reports:read` | `reports:*`
`reports:id:*` | List all available reports or get a specific report. | -| `reports:send` | `reports:*`
`reports:id:*` | Send a report email. | -| `roles:delete` | `permissions:type:delegate` | Delete a custom role. | -| `roles:read` | `roles:*`
`roles:uid:*` | List roles and read a specific role with its permissions. | -| `roles:write` | `permissions:type:delegate` | Create or update a custom role. | -| `roles:write` | `permissions:type:escalate` | Reset basic roles to their default permissions. | -| `server.stats:read` | n/a | Read Grafana instance statistics. | -| `server.usagestats.report:read` | n/a | View usage statistics report. | -| `serviceaccounts:write` | `serviceaccounts:*` | Create Grafana service accounts. | -| `serviceaccounts:create` | n/a | Update Grafana service accounts. | -| `serviceaccounts:delete` | `serviceaccounts:*`
`serviceaccounts:id:*` | Delete Grafana service accounts. | -| `serviceaccounts:read` | `serviceaccounts:*`
`serviceaccounts:id:*` | Read Grafana service accounts. | -| `serviceaccounts.permissions:write` | `serviceaccounts:*`
`serviceaccounts:id:*` | Update Grafana service account permissions to control who can do what with the service account. | -| `serviceaccounts.permissions:read` | `serviceaccounts:*`
`serviceaccounts:id:*` | Read Grafana service account permissions to see who can do what with the service account. | -| `settings:read` | `settings:*`
`settings:auth.saml:*`
`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../../setup-grafana/configure-grafana/" >}}) | -| `settings:write` | `settings:*`
`settings:auth.saml:*`
`settings:auth.saml:enabled` (property level) | Update any Grafana configuration settings that can be [updated at runtime]({{< relref "../../../../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}). | -| `support.bundles:create` | n/a | Create support bundles. | -| `support.bundles:delete` | n/a | Delete support bundles. | -| `support.bundles:read` | n/a | List and download support bundles. | -| `status:accesscontrol` | `services:accesscontrol` | Get access-control enabled status. | -| `teams.permissions:read` | `teams:*`
`teams:id:*` | Read members and Team Sync setup for teams. | -| `teams.permissions:write` | `teams:*`
`teams:id:*` | Add, remove and update members and manage Team Sync setup for teams. | -| `teams.roles:add` | `permissions:type:delegate` | Assign a role to a team. | -| `teams.roles:read` | `teams:*`
`teams:id:*` | List roles assigned directly to a team. | -| `teams.roles:remove` | `permissions:type:delegate` | Unassign a role from a team. | -| `teams:create` | n/a | Create teams. | -| `teams:delete` | `teams:*`
`teams:id:*` | Delete one or more teams. | -| `teams:read` | `teams:*`
`teams:id:*` | Read one or more teams and team preferences. To list teams through the UI one of the following permissions is required in addition to `teams:read`: `teams:write`, `teams.permissions:read` or `teams.permissions:write`. | -| `teams:write` | `teams:*`
`teams:id:*` | Update one or more teams and team preferences. | -| `users.authtoken:read` | `global.users:*`
`global.users:id:*` | List authentication tokens that are assigned to a user. | -| `users.authtoken:write` | `global.users:*`
`global.users:id:*` | Update authentication tokens that are assigned to a user. | -| `users.password:write` | `global.users:*`
`global.users:id:*` | Update a user’s password. | -| `users.permissions:read` | `users:*` | List permissions of a user. | -| `users.permissions:write` | `global.users:*`
`global.users:id:*` | Update a user’s organization-level permissions. | -| `users.quotas:read` | `global.users:*`
`global.users:id:*` | List a user’s quotas. | -| `users.quotas:write` | `global.users:*`
`global.users:id:*` | Update a user’s quotas. | -| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user or a service account. | -| `users.roles:read` | `users:*` | List roles assigned directly to a user or a service account. | -| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user or a service account. | -| `users:create` | n/a | Create a user. | -| `users:delete` | `global.users:*`
`global.users:id:*` | Delete a user. | -| `users:disable` | `global.users:*`
`global.users:id:*` | Disable a user. | -| `users:enable` | `global.users:*`
`global.users:id:*` | Enable a user. | -| `users:logout` | `global.users:*`
`global.users:id:*` | Sign out a user. | -| `users:read` | `global.users:*` | Read or search user profiles. | -| `users:write` | `global.users:*`
`global.users:id:*` | Update a user’s profile. | + +| Action | Applicable scopes | Description | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alert.instances.external:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read alerts and silences in data sources that support alerting. | +| `alert.instances.external:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Manage alerts and silences in data sources that support alerting. | +| `alert.instances:create` | None | Create silences in the current organization. | +| `alert.instances:read` | None | Read alerts and silences in the current organization. | +| `alert.instances:write` | None | Update and expire silences in the current organization. | +| `alert.notifications.external:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | +| `alert.notifications.external:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. | +| `alert.notifications:write` | None | Manage templates, contact points, notification policies, and mute timings in the current organization. | +| `alert.notifications:read` | None | Read all templates, contact points, notification policies, and mute timings in the current organization. | +| `alert.rules.external:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | +| `alert.rules.external:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | +| `alert.rules:create` |
  • `folders:*`
  • `folders:uid:*`
| Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:delete` |
  • `folders:*`
  • `folders:uid:*`
| Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:read` |
  • `folders:*`
  • `folders:uid:*`
| Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:write` |
  • `folders:*`
  • `folders:uid:*`
| Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.silences:create` |
  • `folders:*`
  • `folders:uid:*`
| Create rule-specific silences in a folder and its subfolders. | +| `alert.silences:read` |
  • `folders:*`
  • `folders:uid:*`
| Read all general silences and rule-specific silences in a folder and its subfolders. | +| `alert.silences:write` |
  • `folders:*`
  • `folders:uid:*`
| Update and expire rule-specific silences in a folder and its subfolders. | +| `alert.provisioning:read` | None | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | +| `alert.provisioning.secrets:read` | None | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. | +| `alert.provisioning:write` | None | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and datasource are not required. | +| `alert.provisioning.provenance:write` | None | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources | +| `annotations:create` |
  • `annotations:*`
  • `annotations:type:*`
| Create annotations. | +| `annotations:delete` |
  • `annotations:*`
  • `annotations:type:*`
| Delete annotations. | +| `annotations:read` |
  • `annotations:*`
  • `annotations:type:*`
| Read annotations and annotation tags. | +| `annotations:write` |
  • `annotations:*`
  • `annotations:type:*`
| Update annotations. | +| `apikeys:create` | None | Create API keys. | +| `apikeys:read` |
  • `apikeys:*`
  • `apikeys:id:*`
| Read API keys. | +| `apikeys:delete` |
  • `apikeys:*`
  • `apikeys:id:*`
| Delete API keys. | +| `dashboards:create` |
  • `folders:*`
  • `folders:uid:*`
| Create dashboards in one or more folders and their subfolders. | +| `dashboards:delete` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Delete one or more dashboards. | +| `dashboards.insights:read` | None | Read dashboard insights data and see presence indicators. | +| `dashboards.permissions:read` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Read permissions for one or more dashboards. | +| `dashboards.permissions:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update permissions for one or more dashboards. | +| `dashboards:read` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Read one or more dashboards. | +| `dashboards:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update one or more dashboards. | +| `dashboards.public:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
| Write public dashboard configuration. | +| `datasources.caching:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read data source query caching settings. | +| `datasources.caching:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Update data source query caching settings. | +| `datasources:create` | None | Create data sources. | +| `datasources:delete` |
  • `datasources:*`
  • `datasources:uid:*`
| Delete data sources. | +| `datasources:explore` | None | Enable access to the **Explore** tab. | +| `datasources.id:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read data source IDs. | +| `datasources.insights:read` | None | Read data sources insights data. | +| `datasources.permissions:read` |
  • `datasources:*`
  • `datasources:uid:*`
| List data source permissions. | +| `datasources.permissions:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Update data source permissions. | +| `datasources:query` |
  • `datasources:*`
  • `datasources:uid:*`
| Query data sources. | +| `datasources:read` |
  • `datasources:*`
  • `datasources:uid:*`
| List data sources. | +| `datasources:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Update data sources. | +| `featuremgmt.read` | None | Read feature toggles. | +| `featuremgmt.write` | None | Write feature toggles. | +| `folders.permissions:read` |
  • `folders:*`
  • `folders:uid:*`
| Read permissions for one or more folders and their subfolders. | +| `folders.permissions:write` |
  • `folders:*`
  • `folders:uid:*`
| Update permissions for one or more folders and their subfolders. | +| `folders:create` |
  • `folders:*`
  • `folders:uid:*`
  • `folders:uid:general`
| Create folders or subfolders. If granted with scope `folders:uid:general`, it allows to create root level folders. Otherwise, it allows creating subfolders under the specified folders. | +| `folders:delete` |
  • `folders:*`
  • `folders:uid:*`
| Delete one or more folders and their subfolders. | +| `folders:read` |
  • `folders:*`
  • `folders:uid:*`
| Read one or more folders and their subfolders. | +| `folders:write` |
  • `folders:*`
  • `folders:uid:*`
| Update one or more folders and their subfolders. | +| `ldap.config:reload` | None | Reload the LDAP configuration. | +| `ldap.status:read` | None | Verify the availability of the LDAP server or servers. | +| `ldap.user:read` | None | Read users via LDAP. | +| `ldap.user:sync` | None | Sync users via LDAP. | +| `library.panels:create` |
  • `folders:*`
  • `folders:uid:*`
| Create a library panel in one or more folders and their subfolders. | +| `library.panels:read` |
  • `folders:*`
  • `folders:uid:*`
  • `library.panels:*`
  • `library.panels:uid:*`
| Read one or more library panels. | +| `library.panels:write` |
  • `folders:*`
  • `folders:uid:*`
  • `library.panels:*`
  • `library.panels:uid:*`
| Update one or more library panels. | +| `library.panels:delete` |
  • `folders:*`
  • `folders:uid:*`
  • `library.panels:*`
  • `library.panels:uid:*`
| Delete one or more library panels. | +| `licensing.reports:read` | None | Get custom permission reports. | +| `licensing:delete` | None | Delete the license token. | +| `licensing:read` | None | Read licensing information. | +| `licensing:write` | None | Update the license token. | +| `org.users:write` |
  • `users:*`
  • `users:id:*`
| Update the organization role (`None`, `Viewer`, `Editor`, or `Admin`) of a user. | +| `org.users:add` |
  • `users:*`
  • `users:id:*`
| Add a user to an organization or invite a new user to an organization. | +| `org.users:read` |
  • `users:*`
  • `users:id:*`
| Get user profiles within an organization. | +| `org.users:remove` |
  • `users:*`
  • `users:id:*`
| Remove a user from an organization. | +| `orgs.preferences:read` | None | Read organization preferences. | +| `orgs.preferences:write` | None | Update organization preferences. | +| `orgs.quotas:read` | None | Read organization quotas. | +| `orgs.quotas:write` | None | Update organization quotas. | +| `orgs:create` | None | Create an organization. | +| `orgs:delete` | None | Delete one or more organizations. | +| `orgs:read` | None | Read one or more organizations. | +| `orgs:write` | None | Update one or more organizations. | +| `plugins.app:access` |
  • `plugins:*`
  • `plugins:id:*`
| Access one or more application plugins (still enforcing the organization role) | +| `plugins:install` | None | Install and uninstall plugins. | +| `plugins:write` |
  • `plugins:*`
  • `plugins:id:*`
| Edit settings for one or more plugins. | +| `provisioning:reload` | `provisioners:*` | Reload provisioning files. To find the exact scope for specific provisioner, refer to [Scope definitions](#scope-definitions). | +| `reports:create` | None | Create reports. | +| `reports:write` |
  • `reports:*`
  • `reports:id:*`
| Update reports. | +| `reports.settings:read` | None | Read report settings. | +| `reports.settings:write` | None | Update report settings. | +| `reports:delete` |
  • `reports:*`
  • `reports:id:*`
| Delete reports. | +| `reports:read` |
  • `reports:*`
  • `reports:id:*`
| List all available reports or get a specific report. | +| `reports:send` |
  • `reports:*`
  • `reports:id:*`
| Send a report email. | +| `roles:delete` |
  • `permissions:type:delegate`
    • | Delete a custom role. | +| `roles:read` |
      • `roles:*`
      • `roles:uid:*`
      | List roles and read a specific role with its permissions. | +| `roles:write` |
      • `permissions:type:delegate`
        • | Create or update a custom role. | +| `roles:write` |
          • `permissions:type:escalate`
            • | Reset basic roles to their default permissions. | +| `server.stats:read` | None | Read Grafana instance statistics. | +| `server.usagestats.report:read` | None | View usage statistics report. | +| `serviceaccounts:write` |
              • `serviceaccounts:*`
                • | Create Grafana service accounts. | +| `serviceaccounts:create` | None | Update Grafana service accounts. | +| `serviceaccounts:delete` |
                  • `serviceaccounts:*`
                  • `serviceaccounts:id:*`
                  | Delete Grafana service accounts. | +| `serviceaccounts:read` |
                  • `serviceaccounts:*`
                  • `serviceaccounts:id:*`
                  | Read Grafana service accounts. | +| `serviceaccounts.permissions:write` |
                  • `serviceaccounts:*`
                  • `serviceaccounts:id:*`
                  | Update Grafana service account permissions to control who can do what with the service account. | +| `serviceaccounts.permissions:read` |
                  • `serviceaccounts:*`
                  • `serviceaccounts:id:*`
                  | Read Grafana service account permissions to see who can do what with the service account. | +| `settings:read` |
                  • `settings:*`
                  • `settings:auth.saml:*`
                  • `settings:auth.saml:enabled`
                  (property level) | Read the [Grafana configuration settings](/docs/grafana//setup-grafana/configure-grafana/) | +| `settings:write` |
                  • `settings:*`
                  • `settings:auth.saml:*`
                  • `settings:auth.saml:enabled`
                  (property level) | Update any Grafana configuration settings that can be [updated at runtime](/docs/grafana//setup-grafana/configure-grafana/settings-updates-at-runtime/). | +| `support.bundles:create` | None | Create support bundles. | +| `support.bundles:delete` | None | Delete support bundles. | +| `support.bundles:read` | None | List and download support bundles. | +| `status:accesscontrol` |
                  • `services:accesscontrol`
                    • | Get access-control enabled status. | +| `teams.permissions:read` |
                      • `teams:*`
                      • `teams:id:*`
                      | Read members and Team Sync setup for teams. | +| `teams.permissions:write` |
                      • `teams:*`
                      • `teams:id:*`
                      | Add, remove and update members and manage Team Sync setup for teams. | +| `teams.roles:add` |
                      • `permissions:type:delegate`
                        • | Assign a role to a team. | +| `teams.roles:read` |
                          • `teams:*`
                          • `teams:id:*`
                          | List roles assigned directly to a team. | +| `teams.roles:remove` |
                          • `permissions:type:delegate`
                            • | Unassign a role from a team. | +| `teams:create` | None | Create teams. | +| `teams:delete` |
                              • `teams:*`
                              • `teams:id:*`
                              | Delete one or more teams. | +| `teams:read` |
                              • `teams:*`
                              • `teams:id:*`
                              | Read one or more teams and team preferences. To list teams through the UI one of the following permissions is required in addition to `teams:read`: `teams:write`, `teams.permissions:read` or `teams.permissions:write`. | +| `teams:write` |
                              • `teams:*`
                              • `teams:id:*`
                              | Update one or more teams and team preferences. | +| `users.authtoken:read` |
                              • `global.users:*`
                              • `global.users:id:*`
                              | List authentication tokens that are assigned to a user. | +| `users.authtoken:write` |
                              • `global.users:*`
                              • `global.users:id:*`
                              | Update authentication tokens that are assigned to a user. | +| `users.password:write` |
                              • `global.users:*`
                              • `global.users:id:*`
                              | Update a user’s password. | +| `users.permissions:read` |
                              • `users:*`
                                • | List permissions of a user. | +| `users.permissions:write` |
                                  • `global.users:*`
                                  • `global.users:id:*`
                                  | Update a user’s organization-level permissions. | +| `users.quotas:read` |
                                  • `global.users:*`
                                  • `global.users:id:*`
                                  | List a user’s quotas. | +| `users.quotas:write` |
                                  • `global.users:*`
                                  • `global.users:id:*`
                                  | Update a user’s quotas. | +| `users.roles:add` |
                                  • `permissions:type:delegate`
                                    • | Assign a role to a user or a service account. | +| `users.roles:read` |
                                      • `users:*`
                                        • | List roles assigned directly to a user or a service account. | +| `users.roles:remove` |
                                          • `permissions:type:delegate`
                                            • | Unassign a role from a user or a service account. | +| `users:create` | None | Create a user. | +| `users:delete` |
                                              • `global.users:*`
                                              • `global.users:id:*`
                                              | Delete a user. | +| `users:disable` |
                                              • `global.users:*`
                                              • `global.users:id:*`
                                              | Disable a user. | +| `users:enable` |
                                              • `global.users:*`
                                              • `global.users:id:*`
                                              | Enable a user. | +| `users:logout` |
                                              • `global.users:*`
                                              • `global.users:id:*`
                                              | Sign out a user. | +| `users:read` |
                                              • `global.users:*`
                                                • | Read or search user profiles. | +| `users:write` |
                                                  • `global.users:*`
                                                  • `global.users:id:*`
                                                  | Update a user’s profile. | +{ .no-spacing-list } + ### Grafana OnCall action definitions (beta) -> **Note:** Available from Grafana 9.4 in early access. - -> **Note:** This feature is behind the `accessControlOnCall` feature toggle. -> You can enable feature toggles through configuration file or environment variables. See configuration [docs]({{< relref "../../../../setup-grafana/configure-grafana/#feature_toggles" >}}) for details. - The following list contains role-based access control actions used by Grafana OnCall application plugin. -| Action | Applicable scope | Description | -| ------------------------------------------------ | ---------------- | ------------------------------------------------- | -| `grafana-oncall-app.alert-groups:read` | n/a | Read OnCall alert groups. | -| `grafana-oncall-app.alert-groups:write` | n/a | Create, edit and delete OnCall alert groups. | -| `grafana-oncall-app.integrations:read` | n/a | Read OnCall integrations. | -| `grafana-oncall-app.integrations:write` | n/a | Create, edit and delete OnCall integrations. | -| `grafana-oncall-app.integrations:test` | n/a | Test OnCall integrations. | -| `grafana-oncall-app.escalation-chains:read` | n/a | Read OnCall escalation chains. | -| `grafana-oncall-app.escalation-chains:write` | n/a | Create, edit and delete OnCall escalation chains. | -| `grafana-oncall-app.schedules:read` | n/a | Read OnCall schedules. | -| `grafana-oncall-app.schedules:write` | n/a | Create, edit and delete OnCall schedules. | -| `grafana-oncall-app.schedules:export` | n/a | Export OnCall schedules. | -| `grafana-oncall-app.chatops:read` | n/a | Read OnCall ChatOps. | -| `grafana-oncall-app.chatops:write` | n/a | Edit OnCall ChatOps. | -| `grafana-oncall-app.chatops:update-settings` | n/a | Edit OnCall ChatOps settings. | -| `grafana-oncall-app.maintenance:read` | n/a | Read OnCall maintenance. | -| `grafana-oncall-app.maintenance:write` | n/a | Edit OnCall maintenance. | -| `grafana-oncall-app.api-keys:read` | n/a | Read OnCall API keys. | -| `grafana-oncall-app.api-keys:write` | n/a | Create, edit and delete OnCall API keys. | -| `grafana-oncall-app.notifications:read` | n/a | Receive OnCall notifications. | -| `grafana-oncall-app.notification-settings:read` | n/a | Read OnCall notification settings. | -| `grafana-oncall-app.notification-settings:write` | n/a | Edit OnCall notification settings. | -| `grafana-oncall-app.user-settings:read` | n/a | Read user's own OnCall user settings. | -| `grafana-oncall-app.user-settings:write` | n/a | Edit user's own OnCall user settings. | -| `grafana-oncall-app.user-settings:admin` | n/a | Read and edit all users' OnCall user settings. | -| `grafana-oncall-app.other-settings:read` | n/a | Read OnCall settings. | -| `grafana-oncall-app.other-settings:write` | n/a | Edit OnCall settings. | +| Action | Applicable scopes | Description | +| ------------------------------------------------ | ----------------- | ------------------------------------------------- | +| `grafana-oncall-app.alert-groups:read` | None | Read OnCall alert groups. | +| `grafana-oncall-app.alert-groups:write` | None | Create, edit and delete OnCall alert groups. | +| `grafana-oncall-app.integrations:read` | None | Read OnCall integrations. | +| `grafana-oncall-app.integrations:write` | None | Create, edit and delete OnCall integrations. | +| `grafana-oncall-app.integrations:test` | None | Test OnCall integrations. | +| `grafana-oncall-app.escalation-chains:read` | None | Read OnCall escalation chains. | +| `grafana-oncall-app.escalation-chains:write` | None | Create, edit and delete OnCall escalation chains. | +| `grafana-oncall-app.schedules:read` | None | Read OnCall schedules. | +| `grafana-oncall-app.schedules:write` | None | Create, edit and delete OnCall schedules. | +| `grafana-oncall-app.schedules:export` | None | Export OnCall schedules. | +| `grafana-oncall-app.chatops:read` | None | Read OnCall ChatOps. | +| `grafana-oncall-app.chatops:write` | None | Edit OnCall ChatOps. | +| `grafana-oncall-app.chatops:update-settings` | None | Edit OnCall ChatOps settings. | +| `grafana-oncall-app.maintenance:read` | None | Read OnCall maintenance. | +| `grafana-oncall-app.maintenance:write` | None | Edit OnCall maintenance. | +| `grafana-oncall-app.api-keys:read` | None | Read OnCall API keys. | +| `grafana-oncall-app.api-keys:write` | None | Create, edit and delete OnCall API keys. | +| `grafana-oncall-app.notifications:read` | None | Receive OnCall notifications. | +| `grafana-oncall-app.notification-settings:read` | None | Read OnCall notification settings. | +| `grafana-oncall-app.notification-settings:write` | None | Edit OnCall notification settings. | +| `grafana-oncall-app.user-settings:read` | None | Read user's own OnCall user settings. | +| `grafana-oncall-app.user-settings:write` | None | Edit user's own OnCall user settings. | +| `grafana-oncall-app.user-settings:admin` | None | Read and edit all users' OnCall user settings. | +| `grafana-oncall-app.other-settings:read` | None | Read OnCall settings. | +| `grafana-oncall-app.other-settings:write` | None | Edit OnCall settings. | ### Grafana Adaptive Metrics action definitions The following list contains role-based access control actions used by Grafana Adaptive Metrics. -| Action | Applicable scope | Description | -| ---------------------------------------------------- | ---------------- | ----------------------------------------------------- | -| `grafana‑adaptive‑metrics‑app.plugin:access` | n/a | Access the Adaptive Metrics plugin in Grafana Cloud. | -| `grafana‑adaptive‑metrics‑app.config:read` | n/a | Read the Adaptive Metrics app configuration. | -| `grafana‑adaptive‑metrics‑app.config:write` | n/a | Update the Adaptive Metrics app configuration. | -| `grafana‑adaptive‑metrics‑app.recommendations:read` | n/a | Read aggregation recommendations. | -| `grafana‑adaptive‑metrics‑app.recommendations:apply` | n/a | Apply aggregation recommendations. | -| `grafana‑adaptive‑metrics‑app.rules:read` | n/a | Read aggregation rules. | -| `grafana‑adaptive‑metrics‑app.rules:write` | n/a | Create aggregation rules. | -| `grafana‑adaptive‑metrics‑app.rules:delete` | n/a | Delete aggregation rules. | -| `grafana‑adaptive‑metrics‑app.exemptions:read` | n/a | Read recommendation exemptions. | -| `grafana‑adaptive‑metrics‑app.exemptions:write` | n/a | Create, update, and delete recommendation exemptions. | +| Action | Applicable scopes | Description | +| ---------------------------------------------------- | ----------------- | ----------------------------------------------------- | +| `grafana‑adaptive‑metrics‑app.plugin:access` | None | Access the Adaptive Metrics plugin in Grafana Cloud. | +| `grafana‑adaptive‑metrics‑app.config:read` | None | Read the Adaptive Metrics app configuration. | +| `grafana‑adaptive‑metrics‑app.config:write` | None | Update the Adaptive Metrics app configuration. | +| `grafana‑adaptive‑metrics‑app.recommendations:read` | None | Read aggregation recommendations. | +| `grafana‑adaptive‑metrics‑app.recommendations:apply` | None | Apply aggregation recommendations. | +| `grafana‑adaptive‑metrics‑app.rules:read` | None | Read aggregation rules. | +| `grafana‑adaptive‑metrics‑app.rules:write` | None | Create aggregation rules. | +| `grafana‑adaptive‑metrics‑app.rules:delete` | None | Delete aggregation rules. | +| `grafana‑adaptive‑metrics‑app.exemptions:read` | None | Read recommendation exemptions. | +| `grafana‑adaptive‑metrics‑app.exemptions:write` | None | Create, update, and delete recommendation exemptions. | ## Scope definitions The following list contains role-based access control scopes. -| Scopes | Descriptions | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `annotations:*`
                                                  `annotations:type:*` | Restrict an action to a set of annotations. For example, `annotations:*` matches any annotation, `annotations:type:dashboard` matches annotations associated with dashboards and `annotations:type:organization` matches organization annotations. | -| `apikeys:*`
                                                  `apikeys:id:*` | Restrict an action to a set of API keys. For example, `apikeys:*` matches any API key, `apikey:id:1` matches the API key whose id is `1`. | -| `dashboards:*`
                                                  `dashboards:uid:*` | Restrict an action to a set of dashboards. For example, `dashboards:*` matches any dashboard, and `dashboards:uid:1` matches the dashboard whose UID is `1`. | -| `datasources:*`
                                                  `datasources:uid:*` | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:uid:1` matches the data source whose UID is `1`. | -| `folders:*`
                                                  `folders:uid:*` | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:uid:1` matches the folder whose UID is `1`. Note that permissions granted to a folder cascade down to subfolders located under it | -| `global.users:*`
                                                  `global.users:id:*` | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. | -| `library.panels:*`
                                                  `library.panels:uid:*` | Restrict an action to a set of library panels. For example, `library.panels:*` matches any library panel, and `library.panel:uid:1` matches the library panel whose UID is `1`. | -| `orgs:*`
                                                  `orgs:id:*` | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. | -| `permissions:type:delegate` | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. | -| `permissions:type:escalate` | The scope is required to trigger the reset of basic roles permissions. It indicates that users might acquire additional permissions they did not previously have. | -| `plugins:*`
                                                  `plugins:id:*` | Restrict an action to a set of plugins. For example, `plugins:id:grafana-oncall-app` matches Grafana OnCall plugin, and `plugins:*` matches all plugins. | -| `provisioners:*` | Restrict an action to a set of provisioners. For example, `provisioners:*` matches any provisioner, and `provisioners:accesscontrol` matches the role-based access control [provisioner]({{< relref "./rbac-grafana-provisioning/" >}}). | -| `reports:*`
                                                  `reports:id:*` | Restrict an action to a set of reports. For example, `reports:*` matches any report and `reports:id:1` matches the report whose ID is `1`. | -| `roles:*`
                                                  `roles:uid:*` | Restrict an action to a set of roles. For example, `roles:*` matches any role and `roles:uid:randomuid` matches only the role whose UID is `randomuid`. | -| `services:accesscontrol` | Restrict an action to target only the role-based access control service. You can use this in conjunction with the `status:accesscontrol` actions. | -| `serviceaccounts:*`
                                                  `serviceaccounts:id:*` | Restrict an action to a set of service account from an organization. For example, `serviceaccounts:*` matches any service account and `serviceaccount:id:1` matches the service account whose ID is `1`. | -| `settings:*` | Restrict an action to a subset of settings. For example, `settings:*` matches all settings, `settings:auth.saml:*` matches all SAML settings, and `settings:auth.saml:enabled` matches the enable property on the SAML settings. | -| `teams:*`
                                                  `teams:id:*` | Restrict an action to a set of teams from an organization. For example, `teams:*` matches any team and `teams:id:1` matches the team whose ID is `1`. | -| `users:*`
                                                  `users:id:*` | Restrict an action to a set of users from an organization. For example, `users:*` matches any user and `users:id:1` matches the user whose ID is `1`. | -| `n/a` | `n/a` means not applicable. If an action has `n/a` specified for the scope, then the action does not require a scope. For example, the `teams:create` action does not require a scope and allows users to create teams. | + +| Scopes | Descriptions | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|
                                                  • `annotations:*`
                                                  • `annotations:type:*`
                                                  | Restrict an action to a set of annotations. For example, `annotations:*` matches any annotation, `annotations:type:dashboard` matches annotations associated with dashboards and `annotations:type:organization` matches organization annotations. | +|
                                                  • `apikeys:*`
                                                  • `apikeys:id:*`
                                                  | Restrict an action to a set of API keys. For example, `apikeys:*` matches any API key, `apikey:id:1` matches the API key whose id is `1`. | +|
                                                  • `dashboards:*`
                                                  • `dashboards:uid:*`
                                                  | Restrict an action to a set of dashboards. For example, `dashboards:*` matches any dashboard, and `dashboards:uid:1` matches the dashboard whose UID is `1`. | +|
                                                  • `datasources:*`
                                                  • `datasources:uid:*`
                                                  | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:uid:1` matches the data source whose UID is `1`. | +|
                                                  • `folders:*`
                                                  • `folders:uid:*`
                                                  | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:uid:1` matches the folder whose UID is `1`. Note that permissions granted to a folder cascade down to subfolders located under it. | +|
                                                  • `global.users:*`
                                                  • `global.users:id:*`
                                                  | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. | +|
                                                  • `library.panels:*`
                                                  • `library.panels:uid:*`
                                                  | Restrict an action to a set of library panels. For example, `library.panels:*` matches any library panel, and `library.panel:uid:1` matches the library panel whose UID is `1`. | +|
                                                  • `orgs:*`
                                                  • `orgs:id:*`
                                                  | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. | +|
                                                  • `permissions:type:delegate`
                                                    • | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. | +|
                                                      • `permissions:type:escalate`
                                                        • | The scope is required to trigger the reset of basic roles permissions. It indicates that users might acquire additional permissions they did not previously have. | +|
                                                          • `plugins:*`
                                                          • `plugins:id:*`
                                                          | Restrict an action to a set of plugins. For example, `plugins:id:grafana-oncall-app` matches Grafana OnCall plugin, and `plugins:*` matches all plugins. | +|
                                                          • `provisioners:*`
                                                            • | Restrict an action to a set of provisioners. For example, `provisioners:*` matches any provisioner, and `provisioners:accesscontrol` matches the role-based access control [provisioner]({{< relref "./rbac-grafana-provisioning/" >}}). | +|
                                                              • `reports:*`
                                                              • `reports:id:*`
                                                              | Restrict an action to a set of reports. For example, `reports:*` matches any report and `reports:id:1` matches the report whose ID is `1`. | +|
                                                              • `roles:*`
                                                              • `roles:uid:*`
                                                              | Restrict an action to a set of roles. For example, `roles:*` matches any role and `roles:uid:randomuid` matches only the role whose UID is `randomuid`. | +|
                                                              • `services:accesscontrol`
                                                                • | Restrict an action to target only the role-based access control service. You can use this in conjunction with the `status:accesscontrol` actions. | +|
                                                                  • `serviceaccounts:*`
                                                                  • `serviceaccounts:id:*`
                                                                  | Restrict an action to a set of service account from an organization. For example, `serviceaccounts:*` matches any service account and `serviceaccount:id:1` matches the service account whose ID is `1`. | +|
                                                                  • `settings:*`
                                                                    • | Restrict an action to a subset of settings. For example, `settings:*` matches all settings, `settings:auth.saml:*` matches all SAML settings, and `settings:auth.saml:enabled` matches the enable property on the SAML settings. | +|
                                                                      • `teams:*`
                                                                      • `teams:id:*`
                                                                      | Restrict an action to a set of teams from an organization. For example, `teams:*` matches any team and `teams:id:1` matches the team whose ID is `1`. | +|
                                                                      • `users:*`
                                                                      • `users:id:*`
                                                                      | Restrict an action to a set of users from an organization. For example, `users:*` matches any user and `users:id:1` matches the user whose ID is `1`. | +|
                                                                      • None
                                                                        • | If an action has "None" specified for the scope, then the action doesn't require a scope. For example, the `teams:create` action doesn't require a scope and allows users to create teams. | +{ .no-spacing-list } + diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 07bc725d2ac..e6864815761 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -78,9 +78,9 @@ To learn how to use the roles API to determine the role UUIDs, refer to [Manage | `fixed:datasources.insights:reader` | `fixed_EBZ3NwlfecNPp2p0XcZRC1nfEYk` | `datasources.insights:read` | Read data source insights data. | | `fixed:datasources.permissions:reader` | `fixed_ErYA-cTN3yn4h4GxaVPcawRhiOY` | `datasources.permissions:read` | Read data source permissions. | | `fixed:datasources.permissions:writer` | `fixed_aiQh9YDfLOKjQhYasF9_SFUjQiw` | All permissions from `fixed:datasources.permissions:reader` and
                                                                          `datasources.permissions:write` | Create, read, or delete permissions of a data source. | -| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. If granted together with `folders:write` permission, also allows creating subfolders under all folders. | +| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. | | `fixed:folders:reader` | `fixed_yeW-5QPeo-i5PZUIUXMlAA97GnQ` | `folders:read`
                                                                          `dashboards:read` | Read all folders and dashboards. | -| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and
                                                                          `folders:read`
                                                                          `folders:write`
                                                                          `folders:create`
                                                                          `folders:delete`
                                                                          `folders.permissions:read`
                                                                          `folders.permissions:write` | Read, create, update, and delete all folders and dashboards. If granted together with `fixed:folders:creator`, allows creating subfolders under all folders. | +| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and
                                                                          `folders:read`
                                                                          `folders:write`
                                                                          `folders:create`
                                                                          `folders:delete`
                                                                          `folders.permissions:read`
                                                                          `folders.permissions:write` | Read, update, and delete all folders and dashboards. Create folders and subfolders. | | `fixed:folders.permissions:reader` | `fixed_E06l4cx0JFm47EeLBE4nmv3pnSo` | `folders.permissions:read` | Read all folder permissions. | | `fixed:folders.permissions:writer` | `fixed_3GAgpQ_hWG8o7-lwNb86_VB37eI` | All permissions from `fixed:folders.permissions:reader` and
                                                                          `folders.permissions:write` | Read and update all folder permissions. | | `fixed:ldap:reader` | `fixed_lMcOPwSkxKY-qCK8NMJc5k6izLE` | `ldap.user:read`
                                                                          `ldap.status:read` | Read the LDAP configuration and LDAP status information. | diff --git a/docs/sources/administration/service-accounts/index.md b/docs/sources/administration/service-accounts/_index.md similarity index 98% rename from docs/sources/administration/service-accounts/index.md rename to docs/sources/administration/service-accounts/_index.md index ff6971f2096..c5c44cb4dac 100644 --- a/docs/sources/administration/service-accounts/index.md +++ b/docs/sources/administration/service-accounts/_index.md @@ -23,7 +23,7 @@ weight: 800 You can use a service account to run automated workloads in Grafana, such as dashboard provisioning, configuration, or report generation. Create service accounts and tokens to authenticate applications, such as Terraform, with the Grafana API. {{% admonition type="note" %}} -Service accounts replace [API keys]({{< relref "../api-keys/" >}}) as the primary way to authenticate applications that interact with Grafana. +Service accounts replace [API keys](/docs/grafana//administration/service-accounts/migrate-api-keys/) as the primary way to authenticate applications that interact with Grafana. {{% /admonition %}} A common use case for creating a service account is to perform operations on automated or triggered tasks. You can use service accounts to: diff --git a/docs/sources/administration/api-keys/index.md b/docs/sources/administration/service-accounts/migrate-api-keys.md similarity index 61% rename from docs/sources/administration/api-keys/index.md rename to docs/sources/administration/service-accounts/migrate-api-keys.md index fa3bb643b08..b6801913780 100644 --- a/docs/sources/administration/api-keys/index.md +++ b/docs/sources/administration/service-accounts/migrate-api-keys.md @@ -1,93 +1,37 @@ --- aliases: - - about-api-keys/ - - create-api-key/ -description: This section contains information about API keys in Grafana + - ../api-keys/ # /docs/grafana//administration/api-keys/ + - ../about-api-keys/ # /docs/grafana//administration/about-api-keys/ + - ../create-api-key/ # /docs/grafana//administration/create-api-key/ +description: Learn how to migrate legacy API keys to service account tokens. keywords: - API keys - Service accounts labels: products: - enterprise + - cloud - oss -menuTitle: API keys -title: API keys +menuTitle: Migrate API keys +title: Migrate API keys to service account tokens weight: 700 --- -# API keys +# Migrate API keys to service account tokens {{% admonition type="note" %}} -Deprecated: [Service accounts]({{< relref "../service-accounts/" >}}) have replaced API keys as the primary way to authenticate applications that interact with Grafana. +API keys are deprecated. [Service accounts](/docs/grafana/latest/administration/service-accounts/) now replace API keys for authenticating with the **HTTP APIs** and interacting with Grafana. {{% /admonition %}} -An API key is a randomly generated string that external systems use to interact with Grafana HTTP APIs. +API keys specify a role—either **Admin**, **Editor**, or **Viewer**—that determine the permissions associated with interacting with Grafana. -When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources. +Compared to API keys, service accounts have limited scopes that provide more security. For more information on the benefits of service accounts, refer to [service account benefits](/docs/grafana/latest/administration/service-accounts/#service-account-benefits). -{{% admonition type="note" %}} -If you use Grafana v9.1 or newer, use service accounts instead of API keys. For more information, refer to [Grafana service accounts]({{< relref "../service-accounts/" >}}). -{{% /admonition %}} +When you migrate an API key to a service account, a service account is created with a service account token. Your existing API key—now migrated to a service account token—will continue working as before. -{{< section >}} +To find the migrated API keys, click **Administration** in the left-side menu, then **Users and access -> Service Accounts**, select the service account, and locate the **Token**. -## Create an API key - -Create an API key when you want to manage your computed workload with a user. - -This topic shows you how to create an API key using the Grafana UI. You can also create an API key using the Grafana HTTP API. For more information about creating API keys via the API, refer to [Create API key via API]({{< relref "../../developers/http_api/create-api-tokens-for-org/#how-to-create-a-new-organization-and-an-api-token" >}}). - -### Before you begin - -To follow these instructions, you need at least one of the following: - -- Administrator permissions -- Editor permissions -- Service account writer - -- To ensure you have permission to create and edit API keys, follow the instructions in [Roles and permissions]({{< relref "../roles-and-permissions/#" >}}). - -### Steps - -To create an API key, complete the following steps: - -1. Sign in to Grafana. -1. Click **Administration** in the left-side menu, **Users and access**, and select **API Keys**. -1. Click **Add API key**. -1. Enter a unique name for the key. -1. In the **Role** field, select one of the following access levels you want to assign to the key. - - **Admin**: Enables a user to use APIs at the broadest, most powerful administrative level. - - **Editor** or **Viewer** to limit the key's users to those levels of power. -1. In the **Time to live** field, specify how long you want the key to be valid. - - The maximum length of time is 30 days (one month). You enter a number and a letter. Valid letters include `s` for seconds,`m` for minutes, `h` for hours, `d `for days, `w` for weeks, and `M `for month. For example, `12h` is 12 hours and `1M` is 1 month (30 days). - - If you are unsure about how long an API key should be valid, we recommend that you choose a short duration, such as a few hours. This approach limits the risk of having API keys that are valid for a long time. -1. Click **Add**. - -## Migrate API keys to Grafana service accounts - -As an alternative to using API keys for authentication, you can use a service account-based authentication system. When compared to API keys, service accounts have limited scopes that provide more security than using API keys. - -For more information about the benefits of service accounts, refer to [Grafana service account benefits]({{< relref "../service-accounts/#service-account-benefits" >}}). - -The service account endpoints generate a machine user for authentication instead of using API keys. When you migrate an API key to a service account, a service account will be created with a service account token. - -{{% admonition type="note" %}} -If you're currently using API keys for authentication, we strongly recommend to use Grafana Service Accounts instead. Rest assured, when migrating to Service Accounts, your existing API keys will continue working as before. To find the migrated API keys, navigate to the Service Accounts section and select the Service Account Tokens tab. For more information, please refer to the [Grafana service account tokens]({{< relref "../service-accounts/#service-account-tokens" >}}) details. -{{% /admonition %}} - -## Ways of migrating API keys to service accounts - -If you are currently using API keys in your environment, you need to reconfigure your setup to use service accounts. - -Depending on your current setup, you may need to use one or all of the following methods to migrate your environment to service accounts: - -- The Grafana user interface: Use this method if you have been using the UI to manage your API keys and want to switch to using service accounts. -- The Grafana API: Use this method if you have been using API calls to manage your API keys and want to switch to using service accounts programmatically. -- Terraform: If you have a Terraform configuration that sets up API keys, you need to reconfigure your Terraform to use service accounts instead. - -By following these steps, you can successfully migrate your integration from API keys to service accounts and continue using Grafana seamlessly. - -### Migrate API keys to Grafana service accounts using the Grafana user interface +## Migrate API keys using the Grafana user interface This section shows you how to migrate API keys to Grafana service accounts using the Grafana user interface. You can choose to migrate a single API key or all API keys. When you migrate all API keys, you can no longer create API keys and must use service accounts instead. @@ -118,9 +62,9 @@ To migrate a single API key to a service account, complete the following steps: 1. Find the API Key you want to migrate. 1. Click **Migrate to service account**. -### Migrate API keys to Grafana service accounts for API calls +## Migrate API keys using the HTTP API -This section shows you how to migrate API keys to Grafana service accounts for Grafana API workflows. For references see: [Grafana Service Accounts for the Grafana API]({{< relref "../../developers/http_api/serviceaccount/#create-service-account" >}}). +This section shows you how to programmatically migrate API keys to Grafana service accounts using the HTTP API. For API additional information, refer to [Service account HTTP APIs](/docs/grafana/latest/developers/http_api/serviceaccount/). #### Before you begin @@ -180,7 +124,7 @@ curl --request GET --url http://localhost:3000/api/folders --header 'Authorizati [{"id":1,"uid":"a5261a84-eebc-4733-83a9-61f4713561d1","title":"gdev dashboards"}]% ``` -### Migrate API keys to Grafana service accounts in Terraform +## Migrate API keys using Terraform {{< admonition type="note" >}} The terraform resource `api_key` is removed from the Grafana Terraform Provider in v3.0.0. @@ -201,9 +145,7 @@ terraform { } ``` -This section shows you how to migrate your Terraform configuration for API keys to Grafana service accounts. For resources, see [Grafana Service Accounts in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/service_account_token). - -For migration your cloud stack api keys, use the `grafana_cloud_stack_service_account` and `gafana_cloud_stack_service_account_token` resources see [Grafana Cloud Stack Service Accounts in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_stack_service_account). +This section shows you how to migrate your Terraform configuration for API keys to Grafana service accounts. For additional information, refer to [Grafana Service Accounts in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/service_account_token). #### Steps @@ -283,9 +225,11 @@ resource "grafana_service_account_token" "sat-foo" { } ``` -### Migrate Cloud **Stack** API keys to Grafana cloud stack service accounts in Terraform +## Migrate Cloud Stack API keys using Terraform -This section shows you how to migrate your Terraform configuration for Grafana cloud stack API keys to Grafana cloud stack service accounts. For migration your cloud stack api keys, use the `grafana_cloud_stack_service_account` and `gafana_cloud_stack_service_account_token` resources see [Grafana Cloud Stack Service Accounts in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_stack_service_account). +This section shows you how to migrate your Terraform configuration for Grafana cloud stack API keys to Grafana cloud stack service accounts. + +For migration your cloud stack api keys, use the `grafana_cloud_stack_service_account` and `gafana_cloud_stack_service_account_token` resources. For additional information, refer to [Grafana Cloud Stack Service Accounts in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_stack_service_account). {{% admonition type="note" %}} This is only relevant for Grafana Cloud **Stack** API keys `grafana_cloud_stack_api_key`. Grafana Cloud API keys resource `grafana_cloud_api_key` are not deprecated and should be used for authentication for managing your Grafana cloud. diff --git a/docs/sources/alerting/fundamentals/_index.md b/docs/sources/alerting/fundamentals/_index.md index 3ef02d5c1b5..d8ec2362cbd 100644 --- a/docs/sources/alerting/fundamentals/_index.md +++ b/docs/sources/alerting/fundamentals/_index.md @@ -57,7 +57,11 @@ refs: # Introduction to Alerting -Whether you’re just starting out or you're a more experienced user of Grafana Alerting, learn more about the fundamentals and available features that help you create, manage, and respond to alerts; and improve your team’s ability to resolve issues quickly. For a hands-on introduction, refer to our [tutorial to get started with Grafana Alerting](http://grafana.com/tutorials/alerting-get-started/). +Whether you’re just starting out or you're a more experienced user of Grafana Alerting, learn more about the fundamentals and available features that help you create, manage, and respond to alerts; and improve your team’s ability to resolve issues quickly. + +{{< admonition type="tip" >}} +For a hands-on introduction, refer to our [tutorial to get started with Grafana Alerting](http://grafana.com/tutorials/alerting-get-started/). +{{< /admonition >}} The following diagram gives you an overview of Grafana Alerting and introduces you to some of the fundamental features that are the principles of how Grafana Alerting works. diff --git a/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md b/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md index 6b516e80a92..4ea464f6eed 100644 --- a/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md +++ b/docs/sources/alerting/fundamentals/alert-rule-evaluation/state-and-health.md @@ -72,6 +72,15 @@ In [Configure no data and error handling](ref:no-data-and-error-handling), you c {{< figure src="/media/docs/alerting/alert-rule-configure-no-data-and-error.png" alt="A screenshot of the `Configure no data and error handling` option in Grafana Alerting." max-width="500px" >}} +To reduce the number of **No Data** or **Error** state alerts received, try the following. + +1. Use the **Keep last state** option. For more information, refer to the section below. This option allows the alert to retain its last known state when there is no data available, rather than switching to a **No Data** state. +1. For **No Data** alerts, you can optimize your alert rule by expanding the time range of the query. However, if the time range is too big, it affects the performance of the query and can lead to errors due to timeout. + + To minimize timeouts resulting in the **Error** state, reduce the time range to request less data every evaluation cycle. + +1. Change the default [evaluation time out](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#evaluation_timeout). The default is set at 30 seconds. To increase the default evaluation timeout, open a support ticket from the [Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/support/#grafana-cloud-support-options). Note that this should be a last resort, because it may affect the performance of all alert rules and cause missed evaluations if the timeout is too long. + #### Keep last state The "Keep Last State" option helps mitigate temporary data source issues, preventing alerts from unintentionally firing, resolving, and re-firing. diff --git a/docs/sources/breaking-changes/breaking-changes-v10-0.md b/docs/sources/breaking-changes/breaking-changes-v10-0.md index 750c713ee19..946ba52c93c 100644 --- a/docs/sources/breaking-changes/breaking-changes-v10-0.md +++ b/docs/sources/breaking-changes/breaking-changes-v10-0.md @@ -99,11 +99,11 @@ Grafana's [HTTP API endpoints for generating and managing API Keys]({{< relref " #### Migration path -While upgrading to Grafana v10, you don't need to take any action; your API keys will be automatically migrated. To test or perform the migration from API keys to service accounts before upgrading to Grafana v10, follow our [migration documentation]({{< relref "../administration/api-keys/#migrate-api-keys-to-grafana-service-account" >}}"). +While upgrading to Grafana v10, you don't need to take any action; your API keys will be automatically migrated. To test or perform the migration from API keys to service accounts before upgrading to Grafana v10, follow our [migration documentation](https://grafana.com/docs/grafana//administration/service-accounts/migrate-api-keys/). #### Learn more -- [Documentation on migrating from API keys to service accounts]({{< relref "../administration/api-keys/" >}}) +- [Documentation on migrating from API keys to service accounts](https://grafana.com/docs/grafana//administration/service-accounts/migrate-api-keys/) - [Blog post announcement with a video demo including how to migrate](https://grafana.com/blog/2022/08/24/new-in-grafana-9.1-service-accounts-are-now-ga/) diff --git a/docs/sources/dashboards/manage-dashboards/index.md b/docs/sources/dashboards/manage-dashboards/index.md index 7a9e7371ceb..6853bcb02a7 100644 --- a/docs/sources/dashboards/manage-dashboards/index.md +++ b/docs/sources/dashboards/manage-dashboards/index.md @@ -70,7 +70,7 @@ If you have permission to view all folders, you won't see a **Shared with me**. Folders help you organize and group dashboards, which is useful when you have many dashboards or multiple teams using the same Grafana instance. -> **Before you begin:** Ensure you have Editor permissions or greater to create folders. For more information about dashboard permissions, refer to [Dashboard permissions](ref:dashboard-permissions). +> **Before you begin:** Ensure you have organization Editor permissions or greater to create root level folders or Edit or Admin access to a parent folder to create subfolders. For more information about dashboard permissions, refer to [Dashboard permissions](ref:dashboard-permissions). **To create a dashboard folder:** diff --git a/docs/sources/developers/http_api/_index.md b/docs/sources/developers/http_api/_index.md index 806ab1f8650..28ec50df492 100644 --- a/docs/sources/developers/http_api/_index.md +++ b/docs/sources/developers/http_api/_index.md @@ -80,40 +80,41 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - [Admin API]({{< relref "admin/" >}}) - [Alerting API (unstable)](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/pkg/services/ngalert/api/tooling/post.json) -- [Alerting Provisioning API]({{< relref "alerting_provisioning/" >}}) +- [Alerting provisioning API]({{< relref "alerting_provisioning/" >}}) - [Annotations API]({{< relref "annotations/" >}}) - [Correlations API]({{< relref "correlations/" >}}) - [Dashboard API]({{< relref "dashboard/" >}}) -- [Dashboard Permissions API]({{< relref "dashboard_permissions/" >}}) -- [Dashboard Versions API]({{< relref "dashboard_versions/" >}}) +- [Dashboard permissions API]({{< relref "dashboard_permissions/" >}}) +- [Dashboard versions API]({{< relref "dashboard_versions/" >}}) - [Data source API]({{< relref "data_source/" >}}) - [Folder API]({{< relref "folder/" >}}) -- [Folder Permissions API]({{< relref "folder_permissions/" >}}) -- [Folder/Dashboard Search API]({{< relref "folder_dashboard_search/" >}}) -- [Library Element API]({{< relref "library_element/" >}}) +- [Folder permissions API]({{< relref "folder_permissions/" >}}) +- [Folder/Dashboard search API]({{< relref "folder_dashboard_search/" >}}) +- [Library element API]({{< relref "library_element/" >}}) - [Organization API]({{< relref "org/" >}}) - [Other API]({{< relref "other/" >}}) - [Playlists API]({{< relref "playlist/" >}}) - [Preferences API]({{< relref "preferences/" >}}) -- [Short URL API]({{< relref "short_url/" >}}) +- [Public dashboard API]({{< relref "dashboard_public/" >}}) - [Query history API]({{< relref "query_history/" >}}) +- [Service account API]({{< relref "serviceaccount/" >}}) +- [Short URL API]({{< relref "short_url/" >}}) - [Snapshot API]({{< relref "snapshot/" >}}) +- [SSO settings API]({{< relref "sso-settings/" >}}) - [Team API]({{< relref "team/" >}}) - [User API]({{< relref "user/" >}}) ## Deprecated HTTP APIs -- [Alerting Notification Channels API]({{< relref "alerting_notification_channels/" >}}) -- [Alerting API]({{< relref "alerting/" >}}) - [Authentication API]({{< relref "auth/" >}}) ## Grafana Enterprise HTTP APIs Grafana Enterprise includes all of the Grafana OSS APIs as well as those that follow: -- [Role-based access control API]({{< relref "access_control/" >}}) - [Data source permissions API]({{< relref "datasource_permissions/" >}}) -- [Team sync API]({{< relref "team_sync/" >}}) - [License API]({{< relref "licensing/" >}}) -- [Reporting API]({{< relref "reporting/" >}}) - [Query and resource caching API]({{< relref "query_and_resource_caching/" >}}) +- [Reporting API]({{< relref "reporting/" >}}) +- [Role-based access control API]({{< relref "access_control/" >}}) +- [Team sync API]({{< relref "team_sync/" >}}) diff --git a/docs/sources/developers/http_api/auth.md b/docs/sources/developers/http_api/auth.md index 4d5ce4d8740..71b1b5c491f 100644 --- a/docs/sources/developers/http_api/auth.md +++ b/docs/sources/developers/http_api/auth.md @@ -85,7 +85,7 @@ This endpoint has been made obsolete in Grafana 11.3.0. {{% /admonition %}} -Endpoint is obsolete and has been moved to [Grafana service account API]({{< relref "./serviceaccount/" >}}). For more information, refer to [Migrate to Grafana service account API]({{< relref "../../administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api" >}}). +Endpoint is obsolete and has been moved to [Grafana service account API]({{< relref "./serviceaccount/" >}}). For more information, refer to [Migrate to Grafana service account API](/docs/grafana//administration/service-accounts/migrate-api-keys/). `POST /api/auth/keys` diff --git a/docs/sources/developers/http_api/folder.md b/docs/sources/developers/http_api/folder.md index aaae48dc486..fcb4f806063 100644 --- a/docs/sources/developers/http_api/folder.md +++ b/docs/sources/developers/http_api/folder.md @@ -143,11 +143,11 @@ Creates a new folder. See note in the [introduction]({{< ref "#folder-api" >}}) for an explanation. -`folders:create` allows creating folders in the root level. To create a subfolder, `folders:write` scoped to the parent folder is required in addition to `folders:create`. +`folders:create` allows creating folders and subfolders. If granted with scope `folders:uid:general`, allows creating root level folders. Otherwise, allows creating subfolders under the specified folders. | Action | Scope | | ---------------- | ----------- | -| `folders:create` | n/a | +| `folders:create` | `folders:*` | | `folders:write` | `folders:*` | **Example Request**: @@ -411,14 +411,14 @@ See note in the [introduction]({{< ref "#folder-api" >}}) for an explanation. If moving the folder under another folder: -| Action | Scope | -| --------------- | -------------------------------------- | -| `folders:write` | `folders:uid:` | +| Action | Scope | +| ---------------- | ----------------------------------------------------- | +| `folders:create` | `folders:uid:`
                                                                          `folders:*` | If moving the folder under root: | Action | Scope | | -------------- | ------------- | -| `folders:create` | `folders:*` | +| `folders:create` | `folders:uid:general`
                                                                          `folders:*` | JSON body schema: diff --git a/docs/sources/developers/http_api/preferences.md b/docs/sources/developers/http_api/preferences.md index 91a81849cea..42847e1e2eb 100644 --- a/docs/sources/developers/http_api/preferences.md +++ b/docs/sources/developers/http_api/preferences.md @@ -13,7 +13,7 @@ labels: products: - enterprise - oss -title: 'HTTP Preferences API ' +title: 'Preferences API' --- # User and Org Preferences API diff --git a/docs/sources/developers/http_api/snapshot.md b/docs/sources/developers/http_api/snapshot.md index 54360b768dd..8329d4b62b2 100644 --- a/docs/sources/developers/http_api/snapshot.md +++ b/docs/sources/developers/http_api/snapshot.md @@ -13,7 +13,7 @@ labels: products: - enterprise - oss -title: 'HTTP Snapshot API ' +title: 'Snapshot API' --- # Snapshot API diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 1913d9aef08..3442ae7d2a4 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -308,6 +308,10 @@ "type": "boolean", "description": "For data source plugins, if the plugin supports metric queries. Used to enable the plugin in the panel editor." }, + "multiValueFilterOperators": { + "type": "boolean", + "description": "For data source plugins, if the plugin supports multi value operators in adhoc filters." + }, "pascalName": { "type": "string", "description": "[internal only] The PascalCase name for the plugin. Used for creating machine-friendly identifiers, typically in code generation. If not provided, defaults to name, but title-cased and sanitized (only alphabetical characters allowed).", diff --git a/docs/sources/panels-visualizations/visualizations/alert-list/index.md b/docs/sources/panels-visualizations/visualizations/alert-list/index.md index d40d3acf325..e0bcecfcc9a 100644 --- a/docs/sources/panels-visualizations/visualizations/alert-list/index.md +++ b/docs/sources/panels-visualizations/visualizations/alert-list/index.md @@ -50,6 +50,10 @@ Once you’ve created a [dashboard](https://grafana.com/docs/grafana/ We would love your feedback on Canvas. Please check out the [open Github issues](https://github.com/grafana/grafana/issues?page=1&q=is%3Aopen+is%3Aissue+label%3Aarea%2Fpanel%2Fcanvas) and [submit a new feature request](https://github.com/grafana/grafana/issues/new?assignees=&labels=type%2Ffeature-request,area%2Fpanel%2Fcanvas&title=Canvas:&projects=grafana-dataviz&template=1-feature_requests.md) as needed. +{{< video-embed src="/static/img/docs/canvas-panel/canvas-beta-overview-9-2-0.mp4" max-width="750px" alt="Canvas beta overview" >}} -{{< video-embed src="/static/img/docs/canvas-panel/canvas-beta-overview-9-2-0.mp4" max-width="750px" caption="Canvas beta overview" >}} +If you've used popular UI and web design tools, then designing canvases will feel very familiar. +With all of these dynamic elements, there's almost no limit to what a canvas can display. + +{{< admonition type="note" >}} +We'd love your feedback on the canvas visualization. Please check out the [open Github issues](https://github.com/grafana/grafana/issues?page=1&q=is%3Aopen+is%3Aissue+label%3Aarea%2Fpanel%2Fcanvas) and [submit a new feature request](https://github.com/grafana/grafana/issues/new?assignees=&labels=type%2Ffeature-request,area%2Fpanel%2Fcanvas&title=Canvas:&projects=grafana-dataviz&template=1-feature_requests.md) as needed. +{{< /admonition >}} + +## Supported data formats + +The canvas visualization is unique in that it doesn't have any specific data requirements. You can even start adding and configuring visual elements without providing any data. However, any data you plan to consume should be accessible through supported Grafana data sources and structured in a way that ensures smooth integration with your custom elements. + +If your canvas is going to update in real time, your data should support refreshes at your desired intervals without degrading the user experience. + +You can tie [Elements](#elements) and [Connections](#connections) to data through options like text, colors, and background pattern images, etc. available in the canvas visualization. ## Elements diff --git a/docs/sources/panels-visualizations/visualizations/histogram/index.md b/docs/sources/panels-visualizations/visualizations/histogram/index.md index 79a2bf1bac2..70fcc675d02 100644 --- a/docs/sources/panels-visualizations/visualizations/histogram/index.md +++ b/docs/sources/panels-visualizations/visualizations/histogram/index.md @@ -100,6 +100,10 @@ The data is converted as follows: Use the following options to refine your histogram visualization. +### Bucket count + +Specifies the number of bins used to group your data in the histogram, affecting the granularity of the displayed distribution. Leave this empty for automatic bucket count of 30. + ### Bucket size The size of the buckets. Leave this empty for automatic bucket sizing (~10% of the full range). @@ -112,6 +116,14 @@ If the first bucket should not start at zero. A non-zero offset has the effect o This will merge all series and fields into a combined histogram. +### Stacking + +Controls how multiple series are displayed in the histogram. Choose from the following: + +- **Off** - Series are not stacked, but instead shown side by side. +- **Normal** - Series are stacked on top of each other, showing cumulative values. +- **100%** - Series are stacked to fill 100% of the chart, showing the relative proportion of each series. + ### Line width Controls line width of the bars. @@ -126,17 +138,12 @@ Set the mode of the gradient fill. Fill gradient is based on the line color. To Gradient display is influenced by the **Fill opacity** setting. -#### None +Choose from the following: -No gradient fill. This is the default setting. - -#### Opacity - -Transparency of the gradient is calculated based on the values on the Y-axis. The opacity of the fill is increasing with the values on the Y-axis. - -#### Hue - -Gradient color is generated based on the hue of the line color. +- **None** - No gradient fill. This is the default setting. +- **Opacity** - Transparency of the gradient is calculated based on the values on the Y-axis. The opacity of the fill is increasing with the values on the Y-axis. +- **Hue** - Gradient color is generated based on the hue of the line color. +- **Scheme** - The selected [color palette](https://grafana.com/docs/grafana/latest/panels-visualizations/configure-standard-options/#color-scheme) is applied to the histogram bars. ## Tooltip options diff --git a/docs/sources/panels-visualizations/visualizations/pie-chart/index.md b/docs/sources/panels-visualizations/visualizations/pie-chart/index.md index 01f49e3de1a..9f56def9011 100644 --- a/docs/sources/panels-visualizations/visualizations/pie-chart/index.md +++ b/docs/sources/panels-visualizations/visualizations/pie-chart/index.md @@ -23,9 +23,18 @@ refs: # Pie chart -{{< figure src="/static/img/docs/pie-chart-panel/pie-chart-example.png" max-width="1200px" lightbox="true" caption="Pie charts" >}} +A pie chart is a graph that displays data as segments of a circle proportional to the whole, making it look like a sliced pie. Each slice corresponds to a value or measurement. -Pie charts display reduced series, or values in a series, from one or more queries, as they relate to each other, in the form of slices of a pie. The arc length, area and central angle of a slice are all proportional to the slices value, as it relates to the sum of all values. This type of chart is best used when you want a quick comparison of a small set of values in an aesthetically pleasing form. +{{< figure src="/static/img/docs/pie-chart-panel/pie-chart-example.png" max-width="1200px" lightbox="true" alt="Pie charts" >}} + +The pie chart visualization is ideal when you have data that adds up to a total and you want to show the proportion of each value compared to other slices, as well as to the whole of the pie. + +You can use a pie chart if you need to compare: + +- Browser share distribution in the market +- Incident causes per category +- Network traffic sources +- User demographics ## Configure a pie chart visualization @@ -35,6 +44,60 @@ The following video guides you through the creation steps and common customizati {{< docs/play title="Grafana Bar Charts and Pie Charts" url="https://play.grafana.org/d/ktMs4D6Mk/" >}} +## Supported data formats + +The pie chart is different from other visualizations in that it will only display one pie, regardless of the number of datasets, fields, or records queried in it. + +To create a pie chart visualization, you need a dataset containing a set of numeric values either in rows, columns, or both. + +### Example - One row + +The easiest way to provide data for a pie chart visualization is in a dataset with a single record (or row) containing the fields (or columns) that you want in the pie, as in the following example. The default settings of the pie chart visualization automatically display each column as a slice of the pie. + +| Value1 | Value2 | Value3 | Optional | +| ------ | ------ | ------ | -------- | +| 5 | 3 | 2 | Sums10 | + +![Pie chart visualization with multiple values in a single row](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example1.png) + +### Example - Multiple rows + +If you need to use numeric data that's in multiple rows, the default **Show** parameter of the visualization [Value options](#value-options) is set to **Calculate** and use data from the last row. + +| Value | Label | +| ----- | ------ | +| 5 | Value1 | +| 3 | Value2 | +| 2 | Value3 | + +![Pie chart visualization with multiple row values showing the last one](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example2.png) + +By default, the visualization is configured to [calculate](#value-options) a single value per column or series and to display only the last row of data. + +To allow values in multiple rows to be displayed, change the **Show** setting in the [Value options](#value-options) from **Calculate** to **All values**. + +![Pie chart visualization with multiple row values showing all values](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example3.png) + +### Example - Multiple rows and columns + +If your dataset contains multiple rows and columns with numeric data, by default only the last row's values are summed. + +| Value1 | Value2 | Value3 | Optional | +| ------ | ------ | ------ | -------- | +| 5 | 3 | 2 | Sums10 | +| 10 | 6 | 4 | Sums20 | +| 20 | 8 | 2 | Sums30 | + +![Pie chart visualization with multiple rows and columns showing the last one](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example4.png) + +If you want to display all the cells, change the **Show** setting in the [Value options](#value-options) from **Calculate** to **All values**. This also labels the elements by concatenating all the text fields (if you have any) with the column name. + +![Pie chart visualization with multiple rows and columns showing the all values](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example5.png) + +If you want to display only the values from a given field (or column), once the **Show** setting in the [Value options](#value-options) is set to **All values**, set the **Fields** option to the column you wish to sum in the display. The value labels are also concatenated as indicated before. + +![Pie chart visualization with multiple rows and columns showing values from one column](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example6.png) + ## Panel options {{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} @@ -96,10 +159,6 @@ The following example shows a pie chart with **Name** and **Percent** labels dis ![Pie chart labels](/static/img/docs/pie-chart-panel/pie-chart-labels-7-5.png) -## Standard options - -{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} - ## Tooltip options {{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="" >}} @@ -137,6 +196,14 @@ Select values to display in the legend. You can select more than one. - **Percent:** The percentage of the whole. - **Value:** The raw numerical value. +## Standard options + +{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} + +## Data links + +{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}} + ## Value mappings {{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="" >}} diff --git a/docs/sources/panels-visualizations/visualizations/status-history/index.md b/docs/sources/panels-visualizations/visualizations/status-history/index.md index 67326723ea5..b7825b94b82 100644 --- a/docs/sources/panels-visualizations/visualizations/status-history/index.md +++ b/docs/sources/panels-visualizations/visualizations/status-history/index.md @@ -107,6 +107,10 @@ Use these options to refine the visualization. Controls whether values are rendered inside the value boxes. Auto will render values if there is sufficient space. +### Row height + +Controls the height of boxes. 1 = maximum space and 0 = minimum space. + ### Column width Controls the width of boxes. 1 = maximum space and 0 = minimum space. @@ -119,10 +123,6 @@ Controls line width of state regions. Controls the opacity of state regions. -## Standard options - -{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} - ## Legend options {{< docs/shared lookup="visualizations/legend-options-2.md" source="grafana" version="" >}} @@ -131,6 +131,10 @@ Controls the opacity of state regions. {{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="" >}} +## Standard options + +{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} + ## Data links {{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}} diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 66328509bd7..a0fae3e97e7 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -193,6 +193,7 @@ Experimental features might be changed or removed without prior notice. | `backgroundPluginInstaller` | Enable background plugin installer | | `dataplaneAggregator` | Enable grafana dataplane aggregator | | `adhocFilterOneOf` | Exposes a new 'one of' operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter. | +| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | | `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying | | `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | | `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md index 3c4ca721bf4..d18be432199 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md @@ -241,7 +241,7 @@ This app registration will be used as a Service Account to retrieve more informa #### Set up permissions for the application 1. In the overview pane, look for **API permissions** section and select **Add a permission**. -1. In the **Request API permissions** pane, select **Microsoft Graph**. +1. In the **Request API permissions** pane, select **Microsoft Graph**, and click **Application permissions**. 1. In the **Select permissions** pane, under the **GroupMember** section, select **GroupMember.Read.All**. 1. In the **Select permissions** pane, under the **User** section, select **User.Read**. 1. Select **Add permissions** at the bottom of the page. diff --git a/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md b/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md index b665009cda2..46979fc11ab 100644 --- a/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md +++ b/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md @@ -96,7 +96,7 @@ These are just a few examples of how Grafana can be used in M2M scenarios. The p You can use a service account to run automated workloads in Grafana, such as dashboard provisioning, configuration, or report generation. Create service accounts and service accounts tokens to authenticate applications, such as Terraform, with the Grafana API. {{% admonition type="note" %}} -Service accounts will eventually replace [API keys]({{< relref ".#api-keys" >}}) as the primary way to authenticate applications that interact with Grafana. +Service accounts will eventually replace [API keys](/docs/grafana//administration/service-accounts/migrate-api-keys/) as the primary way to authenticate applications that interact with Grafana. {{% /admonition %}} A common use case for creating a service account is to perform operations on automated or triggered tasks. You can use service accounts to: diff --git a/docs/sources/setup-grafana/configure-security/secret-scan.md b/docs/sources/setup-grafana/configure-security/secret-scan.md index dbca7a971cf..031c1c06175 100644 --- a/docs/sources/setup-grafana/configure-security/secret-scan.md +++ b/docs/sources/setup-grafana/configure-security/secret-scan.md @@ -46,7 +46,7 @@ Secret scanning is disabled by default. Outgoing connections are made once you e ## Before you begin - Ensure all your API keys have been migrated to service accounts. - For more information about service account migration, refer to [Migrate API keys to Grafana service accounts]({{< relref "../../administration/api-keys#migrate-api-keys-to-grafana-service-accounts" >}}). + For more information about service account migration, refer to [Migrate API keys to Grafana service accounts](/docs/grafana//administration/service-accounts/migrate-api-keys/). ## Configure secret scanning diff --git a/docs/sources/tutorials/create-alerts-with-logs/index.md b/docs/sources/tutorials/create-alerts-with-logs/index.md index 87eaf7bb994..cc04b4731e7 100644 --- a/docs/sources/tutorials/create-alerts-with-logs/index.md +++ b/docs/sources/tutorials/create-alerts-with-logs/index.md @@ -10,423 +10,304 @@ labels: products: - enterprise - oss + - cloud - loki -status: draft -summary: Create alerts with Logs + - alerting tags: - advanced title: How to create alerts with log data weight: 70 +killercoda: + title: How to create alerts with log data + description: Learn how to use Loki with Grafana Alerting to keep track of what’s happening in your environment with real log data. + preprocessing: + substitutions: + - regexp: docker compose + replacement: docker-compose + backend: + imageid: ubuntu --- -# How to create alerts with logs + -Loki stores your logs and only indexes labels for each log stream. Using Loki with Grafana Alerting is a powerful way to keep track of what's happening in your environment. You can create metric alerts based on content in your log lines to notify your team. Even better, you can add label data from the log message directly into your alert notification. +# How to create alert rules with log data + +Loki stores your logs and only indexes labels for each log stream. Using Loki with Grafana Alerting is a powerful way to keep track of what’s happening in your environment. You can create metric alert rules based on content in your log lines to notify your team. What’s even better is that you can add label data from the log message directly into your alert notification. In this tutorial, you'll: -- Create a conditional alert using Loki. -- Create a custom alert message template. -- Configure an email notification that includes part of the log message. +- Generate sample logs and pull them with Promtail to Grafana. +- Create an alert rule based on a Loki query (LogQL). +- Create a Webhook contact point to send alert notifications to. + + + +{{< admonition type="tip" >}} +Check out our [advanced alerting tutorial](https://grafana.com/tutorials/alerting-get-started-pt2/) to explore advanced topics such as alert instances and notification routing. +{{< /admonition >}} + + + +{{< docs/ignore >}} + +> Check out our [advanced alerting tutorial](https://grafana.com/tutorials/alerting-get-started-pt2/) to explore advanced topics such as alert instances and notification routing. + +{{< /docs/ignore >}} + + + + ## Before you begin -- Ensure you’ve [configured a Loki datasource](https://grafana.com/docs/grafana/latest/datasources/loki/#configure-the-data-source) in Grafana. -- If you already have logs to work with, you can skip the optional sections and go straight to [create an alert](#create-an-alert). -- If you want to use a log-generating sample script to create the logs demonstrated in this tutorial, refer to the optional steps: - - [Use promtail and log-generating script](#optional-use-promtail-and-a-python-script-to-create-sample-logs-and-send-them-to-loki) - - [Use docker with promtail and the log-generating script](#optional-running-the-tutorial-using-grafana-loki-and-promtail-with-docker-compose) + -## Create an alert +### Grafana Cloud users -In these steps you'll create an alert and define an expression to evaluate. These examples use a classic condition. +As a Grafana Cloud user, you don't have to install anything. -### Create a Grafana-managed alert +Continue to [Generate sample logs](#generate-sample-logs). -1. Navigate in Grafana to **Alerting**, then to **Alert Rules** and click **+ New alert rule**. -1. Choose **Grafana Managed Alert** to create an alert that uses expressions. -1. Select your Loki datasource from the drop-down. -1. Enter the alert query in the query editor, switch to **code** mode in the top right corner of the editor to paste the query below: + - ``` - sum by (message)(count_over_time({filename="/var/log/web_requests.log"} != `status=200` | pattern `<_> duration<_>` [10m])) +### Grafana OSS users + + + +In order to run a Grafana stack locally, ensure you have the following applications installed. + +- [Docker Compose](https://docs.docker.com/get-docker/) (included in Docker for Desktop for macOS and Windows) +- [Git](https://git-scm.com/) + + + +To demonstrate the observation of data using the Grafana stack, download the files to your local machine. + +1. Download and save a Docker compose file to run Grafana, Loki and Promtail. + + ```bash + wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml ``` - This query will count the number of log lines with a status code that is not 200 (OK), then sum the result set by message type using an **instant query** and the time interval indicated in brackets. It uses the logql pattern parser to add a new label called `message` that contains the level, method, url, and status from the log line. +2. Run the Grafana stack. - You can use the **explain query** toggle button for a full explanation of the query syntax. The optional log-generating script creates a sample log line similar to the one below: - - ``` - 2023-04-22T02:49:32.562825+00:00 level=info method=GET url=test.com status=200 duration=171ms + ```bash + docker compose up -d ``` - {{% admonition type="note" %}}If you're using your own logs, modify the logql query to match your own log message. Refer to the Loki docs to understand the [pattern parser](https://grafana.com/docs/loki/latest/logql/log_queries/#pattern). - {{% / admonition %}} +The first time you run `docker compose up -d`, Docker downloads all the necessary resources for the tutorial. This might take a few minutes, depending on your internet connection. -1. Update the default expressions to match the values shown in the tables below: + - **Box B - reduce expression** +{{< admonition type="note" >}} - | | | - | -------- | ------ | - | Function | Sum | - | Input | A | - | Mode | Strict | +If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again. - **Box C - threshold expression** - | | | - | ---------------- | --------------------------| - | Input | B | - | Expression value | Is above 5 | - | Alert condition |This is the alert condition| +{{< /admonition >}} -1. Expand **Options** and select **Instant** as the query type. + -1. Click **preview** to see a preview of the query result and alert evaluation. +{{< docs/ignore >}} -1. Expression B shows a table of labels and values returned. The message label captured the message string from the log line - and the value shows the number of times that string occurred during the evaluation interval. +> If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again. - | labels | values | - | ------------------------------------------------------ | ------ | - | message=level=info method=GET url=test.com status=500 | 27 | - | message=level=info method=POST url=test.com status=500 | 1 | +{{< /docs/ignore >}} -1. Configure your alert evaluation behavior. + - - Choose a folder or use **+add new** to add a new folder for this alert. - - Select an existing evaluation group from the drop-down or create a new one if this is your first alert. - - Set the **for** value to **0s** so the alert will fire instantly. - - Leave Configure no data and error handling No data handling on the default values. +{{< admonition type="tip" >}} +Alternatively, you can try out this example in our interactive learning environment: [Get started with Grafana Alerting](https://killercoda.com/grafana-labs/course/grafana/alerting-loki-logs). -1. Add an annotation that refers to labels and values from the query result in your alert notification. +It's a fully configured environment with all the dependencies already installed. - - Choose **+Add new** in the drop down and type the annotation name **AlertValues** into the blank box. - - In the blank `text` box paste `{{ $labels.message }} has returned an error status {{$values.B}} times.` +![Interactive](/media/docs/grafana/full-stack-ile.png) -1. Click the **Save and exit** button at the top of the alert definition page. +Provide feedback, report bugs, and raise issues in the [Grafana Killercoda repository](https://github.com/grafana/killercoda). +{{< /admonition >}} -### Create a Loki managed alert + -[Loki managed alerts](https://grafana.com/docs/loki/latest/rules/#alerting-and-recording-rules) are stored and evaluated by Loki. They use LogQL for their expressions. + -1. Choose Mimir or Loki managed alert to create an alert using Loki. -1. Select your Loki data source from the drop-down. -1. The optional script will output a sample log line similar to this: + - ``` - 2023-04-22T02:49:32.562825+00:00 level=info method=GET url=test.com status=200 duration=171ms +## Generate sample logs + +1. Download and save a Python file that generates logs. + + ```bash + wget https://raw.githubusercontent.com/grafana/tutorial-environment/master/app/loki/web-server-logs-simulator.py ``` -1. Enter the alert query below if you’re using the sample logs or modify it for your own file path and condition. +1. Execute the log-generating Python script. + ```bash + python3 ./web-server-logs-simulator.py | sudo tee -a /var/log/web_requests.log ``` - sum by (message)(count_over_time({filename="/var/log/web_requests.log"} != `status=200` | pattern `<_> duration<_>` [5m])) > 5 - ``` - - This query will search the interval period and count the number of log lines with a status code that is not 200 (OK), then sum the result set by message type. It uses the logql pattern parser to add a new label called `message` that captured the level, method, url, and status from the log line. - - For loki alerts, the interval needs to be specified in brackets instead of a variable and the alert threshold is added to the query. For this example, the interval is 5m and the alert will fire if there are more than 5 non-200 status messages. - -1. Click **preview alert** to see a preview of the labels and value. Hover over the **i** icon under the info column to see the query values. - -1. Add an annotation that refers to labels and values from the query result in your alert notification. - - - Choose **+Add new** in the drop down and type the annotation name **AlertValues** into the blank box. - - In the blank `text` box, paste the following: - - ``` - {{ $labels.message }} has returned an error status {{$values.B}} times - ``` - -1. Click **Save rule and exit** at the top of the alert screen. - -## Create a message template - -1. **Add an alert message template** and reference the annotation from your alert. - - - In Alerting under the Contact points tab: - - - Choose **Grafana** to use the built-in alertmanager - - Click **+Add template** - - Name the template `mynotification` - - Add the snippet below to your alert template in the **Content** field. Notice that you will reference the annotation from your alert by name `(.Annotations.AlertValues)` to insert the annotation string into the alert notification: - - ``` - {{ define "myalert" }} - [{{.Status}}] {{ .Labels.alertname }} - {{ .Annotations.AlertValues }} - {{ end }} - {{ define "mymessage" }} - {{ if gt (len .Alerts.Firing) 0 }} - {{ len .Alerts.Firing }} firing: - {{ range .Alerts.Firing }} {{ template "myalert" .}} {{ end }} - {{ end }} - {{ if gt (len .Alerts.Resolved) 0 }} - {{ len .Alerts.Resolved }} resolved: - {{ range .Alerts.Resolved }} {{ template "myalert" .}} {{ end }} - {{ end }} - {{ end }} - ``` - - - There are two sections to the notification template: - 1. The `myalert` template creates a single alert notification based on a specific alert. - 1. The `mymessage` template will find all of the grouped alerts that are firing and send them in a single notification. - - Save the template. - -1. Add the template to your contact point - - 1. Navigate to **Alerts > Contact point** and edit the email contact point. If you're using Grafana Cloud, SMTP is already enabled. Otherwise, for local installations you'll need to [configure SMTP](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#smtp). - 1. Add an email address in the to field for the recipient. - 1. Expand Optional Email Settings and refer to the template by adding this to the body field: - - ``` - {{ template "mynotification" . }} - ``` - -**Tada! You're finished!** Grafana will email an alert with a message that looks similar to the one below. The format varies slightly depending on which type of alert you created - Loki or Grafana managed. The contents should be the same: - -``` -1 firing: [firing] LokiAlertTest1 Error message level=info method=GET url=test.com status=500 has occurred 12 times. -``` - -## Optional: Use promtail with a sample log-generating script - -This optional step uses a python script to generate the sample logs used in this tutorial to create alerts. - -1. [Install promtail](https://grafana.com/docs/loki/latest/clients/promtail/installation/) on your local machine and configure it to send logs to your Loki instance. -1. Install Python3 on your local machine if needed. -1. Copy the python script below and paste it into a new file on your local machine. - -``` - -#!/bin/env python3 - -import datetime -import math -import random -import sys -import time - - -requests_per_second = 2 -failure_rate = 0.05 -get_post_ratio = 0.9 -get_average_duration_ms = 500 -post_average_duration_ms = 2000 - - -while True: - - # Exponential distribution random value of average 1/lines_per_second. - d = random.expovariate(requests_per_second) - time.sleep(d) - if random.random() < failure_rate: - status = "500" - else: - status = "200" - if random.random() < get_post_ratio: - method = "GET" - duration_ms = math.floor(random.expovariate(1/get_average_duration_ms)) - else: - method = "POST" - duration_ms = math.floor(random.expovariate(1/post_average_duration_ms)) - timestamp = datetime.datetime.now(tz=datetime.timezone.utc).isoformat() - print(f"{timestamp} level=info method={method} url=/ status={status} duration={duration_ms}ms") - sys.stdout.flush() - -``` - -1. Give the script executable permissions. - -In a terminal window on linux-based systems run the command: - -``` - -chmod 755 ./web-server-logs-simulator.py - - -``` - -1. Run the script. - -- Use `tee` to direct the script output to the console and the specified file path. For example, if promtail is - configured to monitor `/var/log` for `.log` files you can direct the script output to `/var/log/web_requests.log` file. - -- To avoid running the script with elevated permissions, create the log file manually and change the permissions for the output file only. - - ``` - sudo touch /var/log/web_requests.log - chmod 755 /var/log/web_requests.log - python3 ./web-server-logs-simulator.py | tee -a /var/log/web_requests.log - ``` - -1. Verify that the logs are showing up in Grafana’s Explore view: - -- Navigate to explore in Grafana. -- Select the Loki datasource from the drop-down. -- Check the toggle for **builder | code** in the top right corner of the query box and switch the query mode to builder if it’s not already selected. -- Select the filename label from the drop-down and choose your `web_requests.log` file from the value drop-down. -- Click **Run Query**. -- You should see logs and a graph of log volume. ### Troubleshooting the script If you don't see the sample logs in Explore: -- Does the output file exist, check /var/log/web_requests.log to see if it contains logs. -- If the file is empty, check that you followed the steps above to create the file and change the permissions. -- If the file exists, verify that promtail is running and check that it is configured correctly. +- Does the output file exist, check `/var/log/web_requests.log` to see if it contains logs. +- If the file is empty, check that you followed the steps above to create the file. +- If the file exists, verify that promtail container is running. - In Grafana Explore, check that the time range is only for the last 5 minutes. -## Optional: Use Docker compose to create the tutorial environment + -These optional steps walk you through installing Grafana, Loki and Promtail with Docker compose. You'll also configure a log-generating script -that generates the sample logs used in this tutorial to create alerts. + -### Pre-requisites +## Create a contact point -- [Docker Compose](https://docs.docker.com/compose/install/) -- Python 3 +Besides being an open-source observability tool, Grafana has its own built-in alerting service. This means that you can receive notifications whenever there is an event of interest in your data, and even see these events graphed in your visualizations. -1. Start a command line from a directory of your choice. -1. From that directory, get a `docker-compose.yaml` file to run Grafana, Loki, and Promtail: +In this step, we'll set up a new [contact point](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/). This contact point will use the _webhooks_ integration. In order to make this work, we also need an endpoint for our webhook integration to receive the alert. We will use [Webhook.site](https://webhook.site/) to quickly set up that test endpoint. This way we can make sure that our alert is actually sending a notification somewhere. -**Bash** + + +1. In your browser, **sign in** to your Grafana Cloud account. + + OSS users: To log in, navigate to [http://localhost:3000](http://localhost:3000), where Grafana is running. + +1. In another tab, go to [Webhook.site](https://webhook.site/). +1. Copy Your unique URL. + + +{{< docs/ignore >}} + +1. Navigate to [http://localhost:3000](http://localhost:3000), where Grafana is running. +1. In another tab, go to [Webhook.site](https://webhook.site/). +1. Copy Your unique URL. + {{< /docs/ignore >}} + +Your webhook endpoint is now waiting for the first request. + +Next, let's configure a contact point in Grafana's Alerting UI to send notifications to our webhook endpoint. + +1. Return to Grafana. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Contact points**. +1. Click **+ Add contact point**. +1. In **Name**, write **Webhook**. +1. In **Integration**, choose **Webhook**. +1. In **URL**, paste the endpoint to your webhook endpoint. +1. Click **Test**, and then click **Send test notification** to send a test alert to your webhook endpoint. +1. Navigate back to [Webhook.site](https://webhook.site/). On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent. + + {{< figure src="/media/docs/alerting/alerting-webhook-detail.png" max-width="1200px" caption="A POST entry in Webhook.site" >}} + +1. Return to Grafana and click **Save contact point**. + +We have created a dummy Webhook endpoint and created a new Alerting contact point in Grafana. Now, we can create an alert rule and link it to this new integration. + + + + + +## Create an alert rule + +Next, we'll establish an [alert rule](http://grafana.com/docs/grafana/next/alerting/fundamentals/alert-rule-evaluation/) within Grafana Alerting to notify us whenever alert rules are triggered and resolved. + +1. In Grafana, **navigate to Alerting** > **Alert rules**. +1. Click on **New alert rule**. +1. Enter alert rule name for your alert rule. Make it short and descriptive as this will appear in your alert notification. For instance, **web-requests-logs** + +### Define query and alert condition + +In this section, we define queries, expressions (used to manipulate the data), and the condition that must be met for the alert to be triggered. + +1. Select the **Loki** datasource from the drop-down. +2. In the Query editor, switch to Code mode by clicking the button on the right. +3. Paste the query below. + + ``` + sum by (message)(count_over_time({filename="/var/log/web_requests.log"} != "status=200" | pattern "<_> duration<_>" [10m])) + ``` + +This query will count the number of log lines with a status code that is not 200 (OK), then sum the result set by message type using an **instant query** and the time interval indicated in brackets. It uses the LogQL pattern parser to add a new label called `message` that contains the level, method, url, and status from the log line. + +You can use the **explain query** toggle button for a full explanation of the query syntax. The optional log-generating script creates a sample log line similar to the one below: ``` - -wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml - +2023-04-22T02:49:32.562825+00:00 level=info method=GET url=test.com status=200 duration=171ms ``` -**Windows Powershell** + -``` +{{% admonition type="note" %}} -$client = new-object System.Net.WebClient -$client.DownloadFile("https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml", -"C:\Users\$Env:UserName\Desktop\docker-compose.yaml") -#downloads the file to the Desktop +If you're using your own logs, modify the LogQL query to match your own log message. Refer to the Loki docs to understand the [pattern parser](https://grafana.com/docs/loki/latest/logql/log_queries/#pattern). -``` +{{% / admonition %}} -1. Run the container + -``` +{{< docs/ignore >}} -docker compose up -d +If you're using your own logs, modify the LogQL query to match your own log message. Refer to the Loki docs to understand the [pattern parser](https://grafana.com/docs/loki/latest/logql/log_queries/#pattern). -``` +{{< /docs/ignore >}} -1. Create and edit a python file that will generate logs. +4. Remove the ‘B’ **Reduce expression** (click the bin icon). The Reduce expression comes by default, and in this case, it is not needed since the queried data is already reduced. Note that the Threshold expression is now your **Alert condition**. -**Bash** +5. In the ‘C’ **Threshold expression**: -``` + - Change the **Input** to **'A'** to select the data source. + - Enter `0` as the threshold value. This is the value above which the alert rule should trigger. -touch web-server-logs-simulator.py && nano web-server-logs-simulator.py +6. Click **Preview** to run the queries. -``` + It should return alert instances from log lines with a status code that is not 200 (OK), and that has met the alert condition. The condition for the alert rule to fire is any occurrence that goes over the threshold of `0`. Since the Loki query has returned more than zero alert instances, the alert rule is `Firing`. -**Windows Powershell** + {{< figure src="/media/docs/alerting/expression-loki-alert.png" max-width="1200px" caption="Preview of a firing alert instances" >}} -``` +### Set evaluation behavior -New-Item web-server-logs-simulator.py ; notepad web-server-logs-simulator.py +An [evaluation group](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/) defines when an alert rule fires, and it’s based on two settings: -``` +- **Evaluation group**: how frequently the alert rule is evaluated. +- **Evaluation interval**: how long the condition must be met to start firing. This allows your data time to stabilize before triggering an alert, helping to reduce the frequency of unnecessary notifications. -1. Paste the following code into the file +To set up the evaluation: -``` +1. In **Folder**, click **+ New folder** and enter a name. For example: _web-server-alerts_. This folder will contain our alerts. +1. In the **Evaluation group**, repeat the above step to create a new evaluation group. We will name it _1m-evaluation_. +1. Choose an **Evaluation interval** (how often the alert will be evaluated). + For example, every `1m` (1 minute). +1. Set the pending period to, `0s` (zero seconds), so the alert rule fires the moment the condition is met. -#!/bin/env python3 +### Configure labels and notifications -import datetime -import math -import random -import sys -import time +Choose the contact point where you want to receive your alert notifications. +1. Under **Contact point**, select **Webhook** from the drop-down menu. +1. Click **Save rule and exit** at the top right corner. + -requests_per_second = 2 -failure_rate = 0.05 -get_post_ratio = 0.9 -get_average_duration_ms = 500 -post_average_duration_ms = 2000 + +## Trigger the alert rule -while True: +Since the Python script will continue to generate log data that matches the alert rule condition, once the evaluation interval has concluded, you should receive an alert notification in the Webhook endpoint. - # Exponential distribution random value of average 1/lines_per_second. - d = random.expovariate(requests_per_second) - time.sleep(d) - if random.random() < failure_rate: - status = "500" - else: - status = "200" - if random.random() < get_post_ratio: - method = "GET" - duration_ms = math.floor(random.expovariate(1/get_average_duration_ms)) - else: - method = "POST" - duration_ms = math.floor(random.expovariate(1/post_average_duration_ms)) - timestamp = datetime.datetime.now(tz=datetime.timezone.utc).isoformat() - print(f"{timestamp} level=info method={method} url=/ status={status} duration={duration_ms}ms") - sys.stdout.flush() +{{< figure src="/media/docs/alerting/alerting-webhook-firing-alert.png" max-width="1200px" caption="Firing alert notification details" >}} -``` + -1. Execute the log-generating python script. + -In a terminal window on linux-based systems run the command: + -``` +{{< admonition type="tip" >}} +Check out our [advanced alerting tutorial](https://grafana.com/tutorials/alerting-get-started-pt2/) to explore advanced topics such as alert instances and notification routing. +{{< /admonition >}} -chmod 755 ./web-server-logs-simulator.py + -``` +{{< docs/ignore >}} -- Use `tee` to direct the script output to the console and the specified file path. For example, if promtail is - configured to monitor `/var/log` for `.log` files you can direct the script output to `/var/log/web_requests.log` file. +> Check out our [advanced alerting tutorial](https://grafana.com/tutorials/alerting-get-started-pt2/) to explore advanced topics such as alert instances and notification routing. -- To avoid running the script with elevated permissions, create the log file manually and change the permissions for the output file only. +{{< /docs/ignore >}} -``` - -sudo touch /var/log/web_requests.log -chmod 755 /var/log/web_requests.log -python3 ./web-server-logs-simulator.py | tee -a /var/log/web_requests.log - - -``` - -**Running on Windows** - -Run Powershell as administrator - -``` - -python ./web-server-logs-simulator.py | Tee-Object "C:\ProgramFiles\GrafanaLabs\grafana\var\log\web_requests.log" - -``` - -1. Verify that the logs are showing up in Grafana’s Explore view: - -- Navigate to explore in Grafana. -- Select the Loki datasource from the drop-down. -- Check the toggle for **builder | code** in the top right corner of the query box and switch the query mode to builder if it’s not already selected. -- Select the filename label from the drop-down and choose your `web_requests.log` file from the value drop-down. -- Click **Run Query**. -- You should see logs and a graph of log volume. - -### Troubleshooting the script - -If you don't see the logs in Explore, check these things: - -- Does the output file exist, check /var/log/web_requests.log to see if it contains logs. -- If the file is empty, check that you followed the steps above to create the file and change the permissions. -- If the file exists, verify that promtail is running and check that it is configured correctly. -- In Grafana Explore, check that the time range is only for the last 5 minutes. + diff --git a/docs/sources/tutorials/grafana-fundamentals/index.md b/docs/sources/tutorials/grafana-fundamentals/index.md index f92da6381bb..5262044de5c 100644 --- a/docs/sources/tutorials/grafana-fundamentals/index.md +++ b/docs/sources/tutorials/grafana-fundamentals/index.md @@ -16,8 +16,15 @@ tags: - beginner title: Grafana fundamentals weight: 10 +killercoda: + title: Grafana fundamentals + description: Learn how to use Grafana to set up a monitoring solution for your application. You will explore metrics and logs, build and annotate dashboards, and set up alert rules. + backend: + imageid: ubuntu --- + + ## Introduction In this tutorial, you'll learn how to use Grafana to set up a monitoring solution for your application, and: @@ -25,7 +32,9 @@ In this tutorial, you'll learn how to use Grafana to set up a monitoring solutio - Explore metrics and logs - Build dashboards - Annotate dashboards -- Set up alerts +- Set up alert rules + + Alternatively, you can also watch our Grafana for Beginners series where we discuss fundamental concepts to help you get started with Grafana. @@ -42,12 +51,24 @@ Alternatively, you can also watch our Grafana for Beginners series where we disc - [Docker Compose](https://docs.docker.com/compose/) (included in Docker for Desktop for macOS and Windows) - [Git](https://git-scm.com/) -### KillerCoda sandbox environment (Alternative) - -If you would prefer to follow along with this tutorial without needing to set up a local environment, you can use the [KillerCoda sandbox environment](https://killercoda.com/grafana-labs/course/full-stack/tutorial-enviroment). - {{% /class %}} +{{< admonition type="tip" >}} +Alternatively, you can try out this example in our interactive learning environment: [Grafana Fundamentals](https://killercoda.com/grafana-labs/course/grafana/grafana-fundamentals). + +It's a fully configured environment with all the dependencies already installed. + +![Interactive](/media/docs/grafana/full-stack-ile.png) + +Provide feedback, report bugs, and raise issues in the [Grafana Killercoda repository](https://github.com/grafana/killercoda). +{{< /admonition >}} + + + + + + + ## Set up the sample application This tutorial uses a sample application to demonstrate some of the features in Grafana. To complete the exercises in this tutorial, you need to download the files to your local machine. @@ -58,19 +79,19 @@ In this step, you'll set up the sample application, as well as supporting servic 1. Clone the [github.com/grafana/tutorial-environment](https://github.com/grafana/tutorial-environment) repository. - ``` + ```bash git clone https://github.com/grafana/tutorial-environment.git ``` 1. Change to the directory where you cloned this repository: - ``` + ```bash cd tutorial-environment ``` 1. Make sure Docker is running: - ``` + ```bash docker ps ``` @@ -78,7 +99,7 @@ In this step, you'll set up the sample application, as well as supporting servic 1. Start the sample application: - ``` + ```bash docker-compose up -d ``` @@ -88,13 +109,13 @@ In this step, you'll set up the sample application, as well as supporting servic 1. Ensure all services are up-and-running: - ``` + ```bash docker-compose ps ``` In the **State** column, it should say `Up` for all services. -1. Browse to the sample application on [localhost:8081](http://localhost:8081). +1. Browse to the sample application on [http://localhost:8081](http://localhost:8081). ### Grafana News @@ -110,24 +131,49 @@ To add a link: To vote for a link, click the triangle icon next to the name of the link. + + + + ## Open Grafana -Grafana is an open-source platform for monitoring and observability that lets you visualize and explore the state of your systems. +Grafana is an open source platform for monitoring and observability that lets you visualize and explore the state of your systems. + + 1. Open a new tab. -1. Browse to [localhost:3000](http://localhost:3000). +1. Browse to [http://localhost:3000](http://localhost:3000). + + +{{< docs/ignore >}} + +1. Browse to [http://localhost:3000](http://localhost:3000). + {{< /docs/ignore >}} + + {{< admonition type="note" >}} This demo does not require a login page or credentials. However, if you choose to install Grafana locally, you will need to log in and provide credentials. In that case, the default username and password is `admin`. {{< /admonition >}} + + +{{< docs/ignore >}} + +> This demo does not require a login page or credentials. However, if you choose to install Grafana locally, you will need to log in and provide credentials. In that case, the default username and password is `admin`. +> {{< /docs/ignore >}} + The first thing you see is the Home dashboard, which helps you get started. In the top left corner, you can see the menu icon. Clicking it will open the _sidebar_, the main menu for navigating Grafana. + + + + ## Explore your metrics -Grafana Explore is a workflow for troubleshooting and data exploration. In this step, you'll be using Explore to create ad-hoc queries to understand the metrics exposed by the sample application. +Grafana Explore is a workflow for troubleshooting and data exploration. In this step, you'll be using Explore to create ad-hoc queries to understand the metrics exposed by the sample application. Specifically, you'll explore requests received by the sample application. > Ad-hoc queries are queries that are made interactively, with the purpose of exploring data. An ad-hoc query is commonly followed by another, more specific query. @@ -163,6 +209,10 @@ Grafana Explore is a workflow for troubleshooting and data exploration. In this Depending on your use case, you might want to group on other labels. Try grouping by other labels, such as `status_code`, by changing the `by(route)` part of the query to `by(status_code)`. + + + + ## Add a logging data source Grafana supports log data sources, like [Loki](/oss/loki/). Just like for metrics, you first need to add your data source to Grafana. @@ -170,11 +220,15 @@ Grafana supports log data sources, like [Loki](/oss/loki/). Just like for metric 1. Click the menu icon and, in the sidebar, click **Connections** and then **Data sources**. 1. Click **+ Add new data source**. 1. In the list of data sources, click **Loki**. -1. In the URL box, enter [http://loki:3100](http://loki:3100). +1. In the URL box, enter `http://loki:3100` 1. Scroll to the bottom of the page and click **Save & Test** to save your changes. You should see the message "Data source successfully connected." Loki is now available as a data source in Grafana. + + + + ## Explore your logs Grafana Explore not only lets you make ad-hoc queries for metrics, but lets you explore your logs as well. @@ -203,12 +257,16 @@ Let's generate an error, and analyze it with Explore. {filename="/var/log/tns-app.log"} |= "error" ``` -1. Click on the log line that says `level=error msg="empty url"` to see more information about the error. +1. Click the log line that says `level=error msg="empty url"` to see more information about the error. > **Note:** If you're in Live mode, clicking logs will not show more information about the error. Instead, stop and exit the live stream, then click the log line there. Logs are helpful for understanding what went wrong. Later in this tutorial, you'll see how you can correlate logs with metrics from Prometheus to better understand the context of the error. + + + + ## Build a dashboard A _dashboard_ gives you an at-a-glance view of your data and lets you track metrics through different visualizations. @@ -236,6 +294,10 @@ Every panel consists of a _query_ and a _visualization_. The query defines _what {{< figure src="/media/tutorials/grafana-fundamentals-dashboard.png" alt="A panel in a Grafana dashboard" caption="A panel in a Grafana dashboard" >}} + + + + ## Annotate events When things go bad, it often helps if you understand the context in which the failure occurred. Time of last deploy, system changes, or database migration can offer insight into what might have caused an outage. Annotations allow you to represent such events directly on your graphs. @@ -273,9 +335,9 @@ Manually annotating your dashboard is fine for those single events. For regularl ``` 1. Click **Apply**. Grafana displays the Annotations list, with your new annotation. -1. Click on your dashboard name to return to your dashboard. +1. Click your dashboard name to return to your dashboard. 1. At the top of your dashboard, there is now a toggle to display the results of the newly created annotation query. Press it if it's not already enabled. -1. Click the **Save dashboard** icon to save the changes. +1. Click the **Save dashboard** (disk) icon to save the changes. 1. To test the changes, go back to the [sample application](http://localhost:8081), post a new link without a URL to generate an error in your browser that says `empty url`. The log lines returned by your query are now displayed as annotations in the graph. @@ -284,17 +346,33 @@ The log lines returned by your query are now displayed as annotations in the gra Being able to combine data from multiple data sources in one graph allows you to correlate information from both Prometheus and Loki. -Annotations also work very well alongside alerts. In the next and final section, we will set up an alert for our app `grafana.news` and then we will trigger it. This will provide a quick intro to our new Alerting platform. +Annotations also work very well alongside alert rules. In the next and final section, we will set up an alert rules for our app `grafana.news` and then we will trigger it. This will provide a quick intro to our new Alerting platform. -## Create a Grafana Managed Alert + -Alerts allow you to identify problems in your system moments after they occur. By quickly identifying unintended changes in your system, you can minimize disruptions to your services. + -Grafana's new alerting platform debuted with Grafana 8. A year later, with Grafana 9, it became the default alerting method. In this step we will create a Grafana Managed Alert. Then we will trigger our new alert and send a test message to a dummy endpoint. +## Create a Grafana-managed alert rule -The most basic alert consists of two parts: +Alert rules allow you to identify problems in your system moments after they occur. By quickly identifying unintended changes in your system, you can minimize disruptions to your services. -1. A _Contact point_ - A Contact point defines how Grafana delivers an alert. When the conditions of an _alert rule_ are met, Grafana notifies the contact points, or channels, configured for that alert. +Grafana's new alerting platform debuted with Grafana 8. A year later, with Grafana 9, it became the default alerting method. In this step we will create a Grafana-managed alert rule. Then we will trigger our new alert rule and send a test message to a dummy endpoint. + +The most basic alert rule consists of two parts: + +1. A _Contact point_ - A Contact point defines how Grafana delivers an alert instance. When the conditions of an _alert rule_ are met, Grafana notifies the contact points, or channels, configured for that alert rule. + + + + {{< admonition type="note" >}} + An [alert instance](https://grafana.com/docs/grafana/latest/alerting/fundamentals/#alert-instances) is a specific occurrence that matches a condition defined by an alert rule, such as when the rate of requests for a specific route suddenly increases. + {{< /admonition >}} + + + {{< docs/ignore >}} + + > An [alert instance](https://grafana.com/docs/grafana/latest/alerting/fundamentals/#alert-instances) is a specific occurrence that matches a condition defined by an alert rule, such as when the rate of requests for a specific route suddenly increases. + > {{< /docs/ignore >}} Some popular channels include: @@ -304,13 +382,17 @@ The most basic alert consists of two parts: - [Slack](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/configure-slack/) - [PagerDuty](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/pager-duty/) -1. An _Alert rule_ - An Alert rule defines one or more _conditions_ that Grafana regularly evaluates. When these evaluations meet the rule's criteria, the alert is triggered. +1. An _Alert rule_ - An Alert rule defines one or more _conditions_ that Grafana regularly evaluates. When these evaluations meet the rule's criteria, the alert rule is triggered. To begin, let's set up a webhook contact point. Once we have a usable endpoint, we'll write an alert rule and trigger a notification. -### Create a contact point for Grafana Managed Alerts + -In this step, we'll set up a new contact point. This contact point will use the _webhooks_ channel. In order to make this work, we also need an endpoint for our webhook channel to receive the alert. We will use [requestbin.com](https://requestbin.com) to quickly set up that test endpoint. This way we can make sure that our alert is actually sending a notification somewhere. + + +### Create a contact point for Grafana-managed alert rules + +In this step, we'll set up a new contact point. This contact point will use the _webhooks_ channel. In order to make this work, we also need an endpoint for our webhook channel to receive the alert notification. We will use [requestbin.com](https://requestbin.com) to quickly set up that test endpoint. This way we can make sure that our alert manager is actually sending a notification somewhere. 1. Browse to [requestbin.com](https://requestbin.com). 1. Under the **Create Request Bin** button, click the link to create a **public bin** instead. @@ -320,26 +402,30 @@ Your Request Bin is now waiting for the first request. Next, let's configure a Contact Point in Grafana's Alerting UI to send notifications to our Request Bin. -1. Return to Grafana. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Contact points**. +1. Return to Grafana. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Manage Contact points**. 1. Click **+ Add contact point**. 1. In **Name**, write **RequestBin**. 1. In **Integration**, choose **Webhook**. 1. In **URL**, paste the endpoint to your request bin. -1. Click **Test**, and then click **Send test notification** to send a test alert to your request bin. +1. Click **Test**, and then click **Send test notification** to send a test alert notification to your request bin. 1. Navigate back to the Request Bin you created earlier. On the left side, there's now a `POST /` entry. Click it to see what information Grafana sent. 1. Return to Grafana and click **Save contact point**. We have now created a dummy webhook endpoint and created a new Alerting Contact Point in Grafana. Now we can create an alert rule and link it to this new channel. -### Add an Alert Rule to Grafana + + + + +### Add an alert rule to Grafana Now that Grafana knows how to notify us, it's time to set up an alert rule: 1. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Alert rules**. 1. Click **+ New alert rule**. 1. For **Section 1**, name the rule `fundamentals-test`. -1. For **Section 2**, Find the **query A** box. Choose your Prometheus datasource. Note that the rule type should automatically switch to Grafana-managed alert. +1. For **Section 2**, Find the **query A** box. Choose your Prometheus data source. Note that the rule type should automatically switch to Grafana-managed alert rule. 1. Switch to code mode by checking the Builder/Code toggle. 1. Enter the same Prometheus query that we used in our earlier panel: @@ -347,47 +433,60 @@ Now that Grafana knows how to notify us, it's time to set up an alert rule: sum(rate(tns_request_duration_seconds_count[5m])) by(route) ``` -1. Press **Preview**. You should see some data returned. -1. Keep expressions “B” and "C" as they are. These expressions (Reduce and Threshold, respectively) come by default when creating a new rule. Expression "B", selects the last value of our query “A”, while the Threshold expression "C" will check if the last value from expression "B" is above a specific value. In addition, the Threshold expression is the alert condition by default. Enter `0.2` as threshold value. [You can read more about queries and conditions here](/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/#expression-queries). -1. In **Section 3**, in Folder, create a new folder, by clicking `New folder` and typing a name for the folder. This folder will contain our alerts. For example: `fundamentals`. Then, click `create`. +1. Scroll down to bottom of section #2 and click the **Preview** button. You should see some data returned. +1. Keep expressions “B” and "C" as they are. These expressions (Reduce and Threshold, respectively) are included by default when creating a new rule. Expression "B", selects the last value of our query “A”, while the Threshold expression "C" will check if the last value from expression "B" is above a specific value. In addition, the Threshold expression is the alert rule condition by default. Enter `0.2` as threshold value. [You can read more about queries and conditions here](/docs/grafana/latest/alerting/fundamentals/alert-rules/queries-conditions/#expression-queries). +1. In **Section 3**, in Folder, create a new folder, by clicking `New folder` and typing a name for the folder. This folder will contain our alert rules. For example: `fundamentals`. Then, click `create`. 1. In the Evaluation group, repeat the above step to create a new one. We will name it `fundamentals` too. -1. Choose an Evaluation interval (how often the alert will be evaluated). For example, every `10s` (10 seconds). -1. Set the pending period. This is the time that a condition has to be met until the alert enters in Firing state and a notification is sent. Enter `0s`. For the purposes of this tutorial, the evaluation interval is intentionally short. This makes it easier to test. This setting makes Grafana wait until an alert has fired for a given time before Grafana sends the notification. +1. Choose an Evaluation interval (how often the alert rule will be evaluated). For example, every `10s` (10 seconds). +1. Set the pending period. This is the time that a condition has to be met until the alert instance enters in Firing state and a notification is sent. Enter `0s`. For the purposes of this tutorial, the evaluation interval is intentionally short. This makes it easier to test. This setting makes Grafana wait until an alert instance has fired for a given time before Grafana sends the notification. 1. In **Section 4**, choose **RequestBin** as the **Contact point**. 1. Click **Save rule and exit** at the top of the page. -### Trigger a Grafana Managed Alert +### Trigger a Grafana-managed alert rule -We have now configured an alert rule and a contact point. Now let's see if we can trigger a Grafana Managed Alert by generating some traffic on our sample application. +We have now configured an alert rule and a contact point. Now let's see if we can trigger a Grafana-managed alert rule by generating some traffic on our sample application. 1. Browse to [localhost:8081](http://localhost:8081). 1. Add a new title and URL, repeatedly click the vote button, or refresh the page to generate a traffic spike. -Once the query `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` returns a value greater than `0.2` Grafana will trigger our alert. Browse to the Request Bin we created earlier and find the sent Grafana alert notification with details and metadata. +Once the query `sum(rate(tns_request_duration_seconds_count[5m])) by(route)` returns a value greater than `0.2` Grafana will trigger our alert rule. Browse to the Request Bin we created earlier and find the sent Grafana alert notification with details and metadata. + + {{< admonition type="note" >}} -The alert may be triggered by the `/metrics` endpoint which is frequently accessed by Grafana when pulling metrics from the application. If this happens, you can increase the **Threshold** value in **Section 2** for testing purposes. +The alert rule may be triggered by the `/metrics` endpoint which is frequently accessed by Grafana when pulling metrics from the application. If this happens, you can increase the **Threshold** value in **Section 2** for testing purposes. {{< /admonition >}} -### Display Grafana Alerts to your dashboard + -In most cases, it's also valuable to display Grafana Alerts as annotations to your dashboard. Check out the video tutorial below to learn how to display alerting to your dashboard. +{{< docs/ignore >}} + +> The alert rule may be triggered by the `/metrics` endpoint which is frequently accessed by Grafana when pulling metrics from the application. If this happens, you can increase the **Threshold** value in **Section 2** for testing purposes. +> {{< /docs/ignore >}} + +### Display Grafana-managed alert rules to your dashboard + +In most cases, it's also valuable to display Grafana alert instances as annotations to your dashboard. Check out the video tutorial below to learn how to display alerting to your dashboard. + + {{< youtube id="ClLp-iSoaSY" >}} + + Let's see how we can configure this. 1. In Grafana's sidebar, hover over the **Alerting** (bell) icon and then click **Alert rules**. 1. Expand the `fundamentals > fundamentals` folder to view our created alert rule. 1. Click the **Edit** icon and scroll down to **Section 5**. -1. Click the **Link dashboard and panel** button and select the dashboard and panel to which you want the alert to be added as an annotation. +1. Click the **Link dashboard and panel** button and select the dashboard and panel to which you want the alert instance to be added as an annotation. 1. Click **Confirm** and **Save rule and exit** to save all the changes. 1. In Grafana's sidebar, navigate to the dashboard by clicking **Dashboards** and selecting the dashboard you created. -1. To test the changes, follow the steps listed to [trigger a Grafana Managed Alert](#trigger-a-grafana-managed-alert). +1. To test the changes, follow the steps listed to [trigger a Grafana-managed alert rule](#trigger-a-grafana-managed-alert). - You should now see a red, broken heart icon beside the panel name, signifying that the alert has been triggered. An annotation for the alert, represented as a vertical red line, is also displayed. + You should now see a red, broken heart icon beside the panel name, indicating that the alert rule has been triggered. An annotation for the alert instance, represented as a vertical red line, is also displayed. - {{< figure src="/media/tutorials/grafana-alert-on-dashboard.png" alt="A panel in a Grafana dashboard with alerting and annotations configured" caption="Displaying Grafana Alerts on a dashboard" >}} + {{< figure src="/media/tutorials/grafana-alert-on-dashboard.png" alt="A panel in a Grafana dashboard with alerting and annotations configured" caption="Displaying Grafana-managed alert rules on a dashboard" >}} @@ -405,17 +504,27 @@ Check out our [advanced alerting tutorial](http://grafana.com/tutorials/alerting {{< /docs/ignore >}} + + + + ## Summary + + In this tutorial you learned about fundamental features of Grafana. To do so, we ran several Docker containers on your local machine. When you are ready to clean up this local tutorial environment, run the following command: ``` docker-compose down -v ``` -### KillerCoda sandbox environment (completed tutorial) + -Do you want to see the finished result? Check out our [completed KillerCoda sandbox environment](https://killercoda.com/grafana-labs/course/full-stack/tutorial-enviroment-completed) containing the entire demo with dashboards, checks, and data sources configured. +{{< docs/ignore >}} + +In this tutorial you learned about fundamental features of Grafana. + +{{< /docs/ignore >}} ### Learn more @@ -427,3 +536,5 @@ Check out the links below to continue your learning journey with Grafana's LGTM - [Alerting Overview](/docs/grafana/latest/alerting/) - [Alert rules](/docs/grafana/latest/alerting/create-alerts/) - [Contact points](/docs/grafana/latest/alerting/fundamentals/contact-points/) + + diff --git a/docs/sources/upgrade-guide/upgrade-v11.2/index.md b/docs/sources/upgrade-guide/upgrade-v11.2/index.md index cedf970bfb3..d8e161897dc 100644 --- a/docs/sources/upgrade-guide/upgrade-v11.2/index.md +++ b/docs/sources/upgrade-guide/upgrade-v11.2/index.md @@ -19,51 +19,55 @@ weight: 1000 {{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="" >}} -## Ensure that your data source UIDs are following the correct standard +## Technical notes -We have had a standard ways to define UIDs for Grafana objects for years (at least [since Grafana 5](https://github.com/grafana/grafana/issues/7883)). While all of our internal code is complying to this format, we did not yet had strict enforcement of this format in REST APIs and provisioning paths that allow creation and update of data sources. +### Grafana data source UID format enforcement -In Grafana `11.1` we [introduced](https://github.com/grafana/grafana/pull/86598) a warning that is sent to Grafana server logs every time a data source instance is being created or updated using an invalid UID format. +**Ensure that your data source UIDs follow the correct standard** -In Grafana `11.2` we [added](https://github.com/grafana/grafana/pull/89363/files) a new feature flag called `failWrongDSUID` that is turned off by default. When enabled, the REST APIs and provisioning will start rejecting and requests to create or update datasource instances that have a wrong UID. +We've had a standard ways to define UIDs for Grafana objects for years (at least [since Grafana v5](https://github.com/grafana/grafana/issues/7883)). While all of our internal code complies to this format, we didn't yet have strict enforcement of this format in REST APIs and provisioning paths that allow the creation and update of data sources. -In Grafana `11.5`, we are going to turn feature flag `failWrongDSUID` on by default, but there will still be an option to turn it off. +In Grafana v11.1, we [introduced](https://github.com/grafana/grafana/pull/86598) a warning that is sent to Grafana server logs every time a data source instance is being created or updated using an invalid UID format. -In Grafana `12`, this will be the default behavior and will not be configurable. +In Grafana v11.2, we [added](https://github.com/grafana/grafana/pull/89363/files) a new feature flag called `failWrongDSUID` that is turned off by default. When enabled, the REST APIs and provisioning start rejecting any requests to create or update data source instances that have an incorrect UID. -### Correct UID format +In Grafana v11.5, we're going to turn feature flag `failWrongDSUID` on by default, but there will still be an option to turn it off. -You can find the exact regex definition [here](https://github.com/grafana/grafana/blob/c92f5169d1c83508beb777f71a93336179fe426e/pkg/util/shortid_generator.go#L32-L45). +In Grafana v12, this will be the default behavior and will not be configurable. + +#### Correct UID format + +You can find the exact regex definition [in the grafana repository](https://github.com/grafana/grafana/blob/c92f5169d1c83508beb777f71a93336179fe426e/pkg/util/shortid_generator.go#L32-L45). A data source UID can only contain: -- latin characters (`a-Z`) -- numbers (`0-9`) -- dash symbols (`-`) +- Latin characters (`a-Z`) +- Numbers (`0-9`) +- Dash symbols (`-`) -### How can I know if I am affected? +#### How do I know if I'm affected? -- You can fetch all your data sources via `/api/datasources` API ([docs](https://grafana.com/docs/grafana/latest/developers/http_api/data_source/#get-all-data-sources)). Look into `uid` fields comparing it to the correct format. Below you'll find a script that could help, but please note it is missing authentication that you would [have to add yourself](https://grafana.com/docs/grafana/latest/developers/http_api/#authenticating-api-requests). +- You can fetch all your data sources using the `/api/datasources` API. Review the `uid` fields, comparing them to the correct format, as shown [in the docs](https://grafana.com/docs/grafana/latest/developers/http_api/data_source/#get-all-data-sources). Following is a script that can help, but note that it's missing authentication that you [have to add yourself](https://grafana.com/docs/grafana/latest/developers/http_api/#authenticating-api-requests): ``` curl http://localhost:3000/api/datasources | jq '.[] | select((.uid | test("^[a-zA-Z0-9\\-_]+$") | not) or (.uid | length > 40)) | {id, uid, name, type}' ``` -- Alternatively, you can check the server logs for the `Invalid datasource uid` ([reference](https://github.com/grafana/grafana/blob/68751ed3107c4d15d33f34b15183ee276611785c/pkg/services/datasources/service/store.go#L429)) +- Alternatively, you can check the server logs for the `Invalid datasource uid` [error](https://github.com/grafana/grafana/blob/68751ed3107c4d15d33f34b15183ee276611785c/pkg/services/datasources/service/store.go#L429). -### What can I do if I am affected? +#### What do I do if I'm affected? -You will need to create a new data source with the correct UID and update your dashboards and alert rules to use it. +You'll need to create a new data source with the correct UID and update your dashboards and alert rules to use it. -### How can I update my dashboards to use the new or updated data source? +#### How do I update my dashboards to use the new or updated data source? -1. Go to the dashboard using this data source and update it by selecting the new or updated data source from the picker below your panel. -2. Update the dashboard's JSON model directly via search and replace. Navigate to [dashboard json model](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/view-dashboard-json-model/) and carefully replace all the instances of old `uid` with the newly created `uid`. +1. Go to the dashboard using the data source and update it by selecting the new or updated data source from the picker below your panel. +1. Update the dashboard's JSON model directly using search and replace. -{{< figure src="/media/docs/grafana/screenshot-grafana-11-datasource-uid-enforcement.png" alt="Updating JSON Model of a Dashboard">}} + Navigate to [dashboard json model](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/view-dashboard-json-model/) and carefully replace all the instances of old `uid` with the newly created `uid`. -### How can I update my alert rules to use the new or updated data source? + {{< figure src="/media/docs/grafana/screenshot-grafana-11-datasource-uid-enforcement.png" alt="Updating JSON Model of a Dashboard">}} -Open the alert rule you want to adjust and search for the data source that is being used for the query/alert condition. From there, select the new data source from the dropdown menu and save the alert rule. +#### How do I update my alert rules to use the new or updated data source? -## Technical notes +Open the alert rule you want to adjust and search for the data source that is being used for the query/alert condition. From there, select the new data source from the drop-down list and save the alert rule. diff --git a/docs/sources/upgrade-guide/upgrade-v9.5/index.md b/docs/sources/upgrade-guide/upgrade-v9.5/index.md index 7203aa87541..d177ca70693 100644 --- a/docs/sources/upgrade-guide/upgrade-v9.5/index.md +++ b/docs/sources/upgrade-guide/upgrade-v9.5/index.md @@ -36,4 +36,4 @@ We are deprecating the dashboard previews feature and will remove it in Grafana ### Migrate your API keys to service accounts -We are upgrading Grafana [API keys]({{< relref "../../administration/api-keys" >}}) to service accounts. Service accounts are a superset of API keys that include token rotation and compatibility with [Role-based access control]({{< relref "../../administration/roles-and-permissions/access-control" >}}). In a future release, we'll automatically migrate all existing API keys to service accounts. All of your existing tokens will continue to work; they will simply be migrated to service accounts. You can preempt this change by migrating your existing API keys to service accounts using Grafana's UI or API. Learn how to do this in the [API keys documentation]({{< relref "../../administration/api-keys#migrate-api-keys-to-grafana-service-accounts" >}}). +We are upgrading Grafana [API keys](/docs/grafana//administration/service-accounts/migrate-api-keys/) to service accounts. Service accounts are a superset of API keys that include token rotation and compatibility with [Role-based access control]({{< relref "../../administration/roles-and-permissions/access-control" >}}). In a future release, we'll automatically migrate all existing API keys to service accounts. All of your existing tokens will continue to work; they will simply be migrated to service accounts. You can preempt this change by migrating your existing API keys to service accounts using Grafana's UI or API. Learn how to do this in the [API keys documentation](/docs/grafana//administration/service-accounts/migrate-api-keys/). diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx index dbfd00c36c4..201fa2305cd 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx @@ -1,25 +1,22 @@ import { PluginPage, usePluginLinks } from '@grafana/runtime'; +import { Stack } from '@grafana/ui'; +import { ActionButton } from '../components/ActionButton'; import { testIds } from '../testIds'; export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1'; export function AddedLinks() { - const { links, isLoading } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID }); + const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID }); return ( -
                                                                          - {isLoading ? ( -
                                                                          Loading...
                                                                          - ) : ( - links.map(({ id, title, path, onClick }) => ( - - {title} - - )) - )} -
                                                                          + +
                                                                          +

                                                                          Link extensions defined with addLink and retrieved using usePluginLinks

                                                                          + +
                                                                          +
                                                                          ); } diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx index ae1a2efe4d9..4b4dda98d1b 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx @@ -4,7 +4,6 @@ import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks'; import { testIds } from '../../testIds'; import { App } from './components/App'; -import pluginJson from './plugin.json'; export const plugin = new AppPlugin<{}>() .setRootPage(App) @@ -24,5 +23,11 @@ export const plugin = new AppPlugin<{}>() title: 'Basic link', description: '...', targets: [LINKS_EXTENSION_POINT_ID], - path: `/a/${pluginJson.id}/`, + path: '/a/grafana-extensionexample1-app/', + }) + .addLink({ + title: 'Go to A', + description: 'Navigating to pluging A', + targets: [LINKS_EXTENSION_POINT_ID], + path: '/a/grafana-extensionexample1-app/', }); diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx index 7c68c104597..071370cf592 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx @@ -1,5 +1,6 @@ import { AppPlugin } from '@grafana/data'; +import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks'; import { testIds } from '../../testIds'; import { App } from './components/App'; @@ -30,4 +31,15 @@ export const plugin = new AppPlugin<{}>() component: ({ name }: { name: string }) => (
                                                                          Hello {name}!
                                                                          ), + }) + .addLink({ + title: 'Open from B', + description: 'Open a modal from plugin B', + targets: [LINKS_EXTENSION_POINT_ID], + onClick: (_, { openModal }) => { + openModal({ + title: 'Modal from app B', + body: () =>
                                                                          From plugin B
                                                                          , + }); + }, }); diff --git a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts index 29a9c0b0726..debed95957b 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts @@ -36,5 +36,6 @@ export const testIds = { }, addedLinksPage: { container: 'data-testid pg-added-links-container', + section1: 'use-plugin-links', }, }; diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts index cfeef4bfcdf..df5f64796d4 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts @@ -3,8 +3,28 @@ import { test, expect } from '@grafana/plugin-e2e'; import pluginJson from '../plugin.json'; import { testIds } from '../testIds'; -test('path link', async ({ page }) => { +test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { await page.goto(`/a/${pluginJson.id}/added-links`); - await page.getByTestId(testIds.addedLinksPage.container).getByText('Basic link').click(); - await expect(page.getByTestId(testIds.appA.container)).toHaveText('Hello Grafana!'); + const section = await page.getByTestId(testIds.addedLinksPage.section1); + await section.getByTestId(testIds.actions.button).click(); + await page.getByTestId(testIds.container).getByText('Go to A').click(); + await page.getByTestId(testIds.modal.open).click(); + await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); +}); + +test('should extend main app with link extension from app B', async ({ page }) => { + await page.goto(`/a/${pluginJson.id}/added-links`); + const section = await page.getByTestId(testIds.addedLinksPage.section1); + await section.getByTestId(testIds.actions.button).click(); + await page.getByTestId(testIds.container).getByText('Open from B').click(); + await expect(page.getByTestId(testIds.appB.modal)).toBeVisible(); +}); + +test('should extend main app with basic link extension from app A', async ({ page }) => { + await page.goto(`/a/${pluginJson.id}/added-links`); + const section = await page.getByTestId(testIds.addedLinksPage.section1); + await section.getByTestId(testIds.actions.button).click(); + await page.getByTestId(testIds.container).getByText('Basic link').click(); + await page.getByTestId(testIds.modal.open).click(); + await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); }); diff --git a/package.json b/package.json index 97b907660c9..a6d5207cf8b 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "devDependencies": { "@babel/core": "7.25.2", "@babel/preset-env": "7.25.4", - "@babel/runtime": "7.25.4", + "@babel/runtime": "7.25.6", "@betterer/betterer": "5.4.0", "@betterer/cli": "5.4.0", "@betterer/eslint": "5.4.0", @@ -122,7 +122,7 @@ "@types/lodash": "4.17.7", "@types/logfmt": "^1.2.3", "@types/lucene": "^2", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/node-forge": "^1", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", "@types/pluralize": "^0.0.33", @@ -175,7 +175,7 @@ "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "28.8.0", + "eslint-plugin-jest": "28.8.1", "eslint-plugin-jest-dom": "^5.4.0", "eslint-plugin-jsdoc": "48.11.0", "eslint-plugin-jsx-a11y": "6.9.0", @@ -268,7 +268,7 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "^5.10.1", + "@grafana/scenes": "^5.11.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -403,7 +403,7 @@ "systemjs-cjs-extra": "0.2.1", "tether-drop": "https://github.com/torkelo/drop", "tinycolor2": "1.6.0", - "tslib": "2.6.3", + "tslib": "2.7.0", "tween-functions": "^1.2.0", "type-fest": "^4.18.2", "uplot": "1.6.30", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 5c6591bcc11..6c3f8d197d7 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -56,7 +56,7 @@ "rxjs": "7.8.1", "string-hash": "^1.1.3", "tinycolor2": "1.6.0", - "tslib": "2.6.3", + "tslib": "2.7.0", "uplot": "1.6.30", "xss": "^1.0.14" }, @@ -66,7 +66,7 @@ "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/papaparse": "5.3.14", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 2db23e03fca..4d7e6ed1201 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -555,8 +555,9 @@ export { type PluginExtensionDataSourceConfigContext, type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, - type PluginExposedComponentConfig, - type PluginAddedComponentConfig, + type PluginExtensionExposedComponentConfig, + type PluginExtensionAddedComponentConfig, + type PluginExtensionAddedLinkConfig, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 87756072998..6ada93ab099 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -5,11 +5,10 @@ import { NavModel } from './navModel'; import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin'; import { type PluginExtensionLinkConfig, - PluginExtensionTypes, PluginExtensionComponentConfig, - PluginExposedComponentConfig, - PluginExtensionConfig, - PluginAddedComponentConfig, + PluginExtensionExposedComponentConfig, + PluginExtensionAddedComponentConfig, + PluginExtensionAddedLinkConfig, } from './pluginExtensions'; /** @@ -58,9 +57,9 @@ export interface AppPluginMeta extends PluginMeta } export class AppPlugin extends GrafanaPlugin> { - private _exposedComponentConfigs: PluginExposedComponentConfig[] = []; - private _addedComponentConfigs: PluginAddedComponentConfig[] = []; - private _extensionConfigs: PluginExtensionConfig[] = []; + private _exposedComponentConfigs: PluginExtensionExposedComponentConfig[] = []; + private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = []; + private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = []; // Content under: /a/${plugin-id}/* root?: ComponentType>; @@ -110,38 +109,24 @@ export class AppPlugin extends GrafanaPlugin( - extensionConfig: { targets: string | string[] } & Omit< - PluginExtensionLinkConfig, - 'type' | 'extensionPointId' - > - ) { - const { targets, ...extension } = extensionConfig; - const targetsArray = Array.isArray(targets) ? targets : [targets]; - - targetsArray.forEach((target) => { - this._extensionConfigs.push({ - ...extension, - extensionPointId: target, - type: PluginExtensionTypes.link, - } as PluginExtensionLinkConfig); - }); + addLink(linkConfig: PluginExtensionAddedLinkConfig) { + this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig); return this; } - addComponent(addedComponentConfig: PluginAddedComponentConfig) { - this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig); + addComponent(addedComponentConfig: PluginExtensionAddedComponentConfig) { + this._addedComponentConfigs.push(addedComponentConfig as PluginExtensionAddedComponentConfig); return this; } - exposeComponent(componentConfig: PluginExposedComponentConfig) { - this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig); + exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) { + this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig); return this; } diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 8609dcff2a1..4d5b332350e 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -142,6 +142,7 @@ export interface DataSourcePluginMeta extends PluginMet unlicensed?: boolean; backend?: boolean; isBackend?: boolean; + multiValueFilterOperators?: boolean; } interface PluginMetaQueryOptions { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f0933f11f05..4b458ca1e3d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -202,6 +202,7 @@ export interface FeatureToggles { backgroundPluginInstaller?: boolean; dataplaneAggregator?: boolean; adhocFilterOneOf?: boolean; + newFiltersUI?: boolean; lokiSendDashboardPanelNames?: boolean; singleTopNav?: boolean; exploreLogsShardSplitting?: boolean; diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index 3aba9310821..d96f22190bd 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -137,6 +137,7 @@ export enum FieldConfigProperty { Unit = 'unit', Min = 'min', Max = 'max', + FieldMinMax = 'fieldMinMax', Decimals = 'decimals', DisplayName = 'displayName', NoValue = 'noValue', diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index f5dd20c62aa..bdb3e06b100 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -23,7 +23,6 @@ type PluginExtensionBase = { description: string; pluginId: string; }; - export type PluginExtensionLink = PluginExtensionBase & { type: PluginExtensionTypes.link; path?: string; @@ -41,61 +40,20 @@ export type PluginExtension = PluginExtensionLink | PluginExtensionComponent; // Objects used for registering extensions (in app plugins) // -------------------------------------------------------- -export type PluginExtensionLinkConfig = { - type: PluginExtensionTypes.link; + +type PluginExtensionConfigBase = { + /** + * The title of the link extension + */ title: string; - description: string; - - // A URL path that will be used as the href for the rendered link extension - // (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page) - path?: string; - - // A function that will be called when the link is clicked - // (It is called with the original event object) - onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; /** - * The unique identifier of the Extension Point - * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum) + * A short description */ - extensionPointId: string; - - // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context - configure?: (context?: Readonly) => - | Partial<{ - title: string; - description: string; - path: string; - onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; - icon: IconName; - category: string; - }> - | undefined; - - // (Optional) A icon that can be displayed in the ui for the extension option. - icon?: IconName; - - // (Optional) A category to be used when grouping the options in the ui - category?: string; -}; - -export type PluginExtensionComponentConfig = { - type: PluginExtensionTypes.component; - title: string; description: string; - - // The React component that will be rendered as the extension - // (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.) - component: React.ComponentType; - - /** - * The unique identifier of the Extension Point - * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum) - */ - extensionPointId: string; }; -export type PluginAddedComponentConfig = { +export type PluginExtensionAddedComponentConfig = PluginExtensionConfigBase & { /** * The target extension points where the component will be added */ @@ -117,23 +75,54 @@ export type PluginAddedComponentConfig = { component: React.ComponentType; }; -export type PluginExposedComponentConfig = { +export type PluginAddedLinksConfigureFunc = (context?: Readonly) => + | Partial<{ + title: string; + description: string; + path: string; + onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + icon: IconName; + category: string; + }> + | undefined; + +export type PluginExtensionAddedLinkConfig = PluginExtensionConfigBase & { + /** + * The target extension points where the link will be added + */ + targets: string | string[]; + + /** A URL path that will be used as the href for the rendered link extension + * (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page) + */ + path?: string; + + /** A URL path that will be used as the href for the rendered link extension + * (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page) + * path?: string; + * + * A function that will be called when the link is clicked + * (It is called with the original event object) + */ + onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + + // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context + configure?: PluginAddedLinksConfigureFunc; + + // (Optional) A icon that can be displayed in the ui for the extension option. + icon?: IconName; + + // (Optional) A category to be used when grouping the options in the ui + category?: string; +}; + +export type PluginExtensionExposedComponentConfig = PluginExtensionConfigBase & { /** * The unique identifier of the component * Shoud be in the format of `//`. e.g. `myorg-todo-app/todo-list/v1` */ id: string; - /** - * The title of the component - */ - title: string; - - /** - * A short description of the component - */ - description: string; - /** * The React component that will be exposed to other plugins */ @@ -212,3 +201,61 @@ type Dashboard = { title: string; tags: string[]; }; + +// deprecated types + +/** @deprecated - use PluginAddedComponentConfig instead */ +export type PluginExtensionLinkConfig = { + type: PluginExtensionTypes.link; + title: string; + description: string; + + // A URL path that will be used as the href for the rendered link extension + // (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page) + path?: string; + + // A function that will be called when the link is clicked + // (It is called with the original event object) + onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + + /** + * The unique identifier of the Extension Point + * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum) + */ + extensionPointId: string; + + // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context + configure?: (context?: Readonly) => + | Partial<{ + title: string; + description: string; + path: string; + onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + icon: IconName; + category: string; + }> + | undefined; + + // (Optional) A icon that can be displayed in the ui for the extension option. + icon?: IconName; + + // (Optional) A category to be used when grouping the options in the ui + category?: string; +}; + +/** @deprecated - use PluginAddedLinkConfig instead */ +export type PluginExtensionComponentConfig = { + type: PluginExtensionTypes.component; + title: string; + description: string; + + // The React component that will be rendered as the extension + // (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.) + component: React.ComponentType; + + /** + * The unique identifier of the Extension Point + * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum) + */ + extensionPointId: string; +}; diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 14a1cd38fc8..b8d0de9092a 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -53,6 +53,7 @@ export interface AdHocVariableFilter { key: string; operator: string; value: string; + values?: string[]; /** @deprecated */ condition?: string; } diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts index 8537fed6c6b..826538555d6 100644 --- a/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts @@ -287,19 +287,19 @@ describe('clock', () => { describe('size greater than or equal 1 hour', () => { it('default', () => { const str = toClock(7199999); - expect(formattedValueToString(str)).toBe('01h:59m:59s:999ms'); + expect(formattedValueToString(str)).toBe('1h:59m:59s:999ms'); }); it('decimals equals 0', () => { const str = toClock(7199999, 0); - expect(formattedValueToString(str)).toBe('01h'); + expect(formattedValueToString(str)).toBe('1h'); }); it('decimals equals 1', () => { const str = toClock(7199999, 1); - expect(formattedValueToString(str)).toBe('01h:59m'); + expect(formattedValueToString(str)).toBe('1h:59m'); }); it('decimals equals 2', () => { const str = toClock(7199999, 2); - expect(formattedValueToString(str)).toBe('01h:59m:59s'); + expect(formattedValueToString(str)).toBe('1h:59m:59s'); }); }); describe('size greater than or equal 1 day', () => { @@ -320,6 +320,24 @@ describe('clock', () => { expect(formattedValueToString(str)).toBe('24h:59m:59s'); }); }); + describe('size greater than or equal 100 hours', () => { + it('default', () => { + const str = toClock(363599999); + expect(formattedValueToString(str)).toBe('100h:59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(363599999, 0); + expect(formattedValueToString(str)).toBe('100h'); + }); + it('decimals equals 1', () => { + const str = toClock(363599999, 1); + expect(formattedValueToString(str)).toBe('100h:59m'); + }); + it('decimals equals 2', () => { + const str = toClock(363599999, 2); + expect(formattedValueToString(str)).toBe('100h:59m:59s'); + }); + }); }); describe('to nanoseconds', () => { diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts index 62980e7dc2f..2d2db131ebc 100644 --- a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts @@ -275,7 +275,7 @@ export function toClock(size: number, decimals?: DecimalCount): FormattedValue { let format = 'mm\\m:ss\\s:SSS\\m\\s'; - const hours = `${('0' + Math.floor(duration(size, 'milliseconds').asHours())).slice(-2)}h`; + const hours = `${Math.floor(duration(size, 'milliseconds').asHours())}h`; if (decimals === 0) { format = ''; diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 5736415dfd9..93c93582786 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@rollup/plugin-node-resolve": "15.2.3", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "esbuild": "0.20.2", "rimraf": "5.0.7", "rollup": "2.79.1", @@ -50,7 +50,7 @@ }, "dependencies": { "@grafana/tsconfig": "^2.0.0", - "tslib": "2.6.3", + "tslib": "2.7.0", "typescript": "5.5.4" } } diff --git a/packages/grafana-eslint-rules/package.json b/packages/grafana-eslint-rules/package.json index 04a8bb48b6a..5ac20942895 100644 --- a/packages/grafana-eslint-rules/package.json +++ b/packages/grafana-eslint-rules/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@typescript-eslint/types": "^6.0.0", "eslint": "8.57.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "private": true } diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index a7a746ac1e8..70b54a6f5ab 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -53,7 +53,7 @@ "react-use": "17.5.1", "react-virtualized-auto-sizer": "1.0.24", "tinycolor2": "1.6.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@babel/core": "7.25.2", @@ -68,7 +68,7 @@ "@types/d3": "^7", "@types/jest": "^29.5.4", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-virtualized-auto-sizer": "1.0.4", "@types/tinycolor2": "1.4.6", diff --git a/packages/grafana-icons/package.json b/packages/grafana-icons/package.json index 6eab36ca137..195276ec300 100644 --- a/packages/grafana-icons/package.json +++ b/packages/grafana-icons/package.json @@ -45,7 +45,7 @@ "@svgr/plugin-prettier": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "@types/babel__core": "^7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "esbuild": "0.20.2", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index 37146dab8c0..e77535ffac9 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -27,7 +27,7 @@ "react-select": "5.8.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", @@ -36,7 +36,7 @@ "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/systemjs": "6.13.5", "@types/testing-library__jest-dom": "5.14.9", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 844750719bc..f59482e57c7 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -4,7 +4,7 @@ "private": true, "version": "11.3.0-pre", "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 7492e11f651..87ca2c1148a 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -70,7 +70,7 @@ "react-window": "1.8.10", "rxjs": "7.8.1", "semver": "7.6.3", - "tslib": "2.6.3", + "tslib": "2.7.0", "uuid": "9.0.1", "whatwg-fetch": "3.6.20" }, @@ -92,7 +92,7 @@ "@types/jest": "29.5.12", "@types/jquery": "3.5.30", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/pluralize": "^0.0.33", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", @@ -110,7 +110,7 @@ "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "28.8.0", + "eslint-plugin-jest": "28.8.1", "eslint-plugin-jsdoc": "48.11.0", "eslint-plugin-jsx-a11y": "6.9.0", "eslint-plugin-lodash": "7.4.0", diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 7c3dee6a3ea..637e701507d 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -603,7 +603,7 @@ export class PrometheusDatasource return this.languageProvider.getLabelKeys().map((k) => ({ value: k, text: k })); } - const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({ + const labelFilters: QueryBuilderLabelFilter[] = options.filters.map(remapOneOf).map((f) => ({ label: f.key, value: f.value, op: f.operator, @@ -620,7 +620,7 @@ export class PrometheusDatasource // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality async getTagValues(options: DataSourceGetTagValuesOptions) { - const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({ + const labelFilters: QueryBuilderLabelFilter[] = options.filters.map(remapOneOf).map((f) => ({ label: f.key, value: f.value, op: f.operator, @@ -822,7 +822,7 @@ export class PrometheusDatasource return []; } - return filters.map((f) => ({ + return filters.map(remapOneOf).map((f) => ({ ...f, value: this.templateSrv.replace(f.value, {}, this.interpolateQueryExpr), operator: scopeFilterOperatorMap[f.operator], @@ -834,7 +834,7 @@ export class PrometheusDatasource return expr; } - const finalQuery = filters.reduce((acc, filter) => { + const finalQuery = filters.map(remapOneOf).reduce((acc, filter) => { const { key, operator } = filter; let { value } = filter; if (operator === '=~' || operator === '!~') { @@ -1001,3 +1001,19 @@ export function prometheusRegularEscape(value: T) { export function prometheusSpecialRegexEscape(value: T) { return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value; } + +export function remapOneOf(filter: AdHocVariableFilter) { + let { operator, value, values } = filter; + if (operator === '=|') { + operator = '=~'; + value = values?.map(prometheusRegularEscape).join('|') ?? ''; + } else if (operator === '!=|') { + operator = '!~'; + value = values?.map(prometheusRegularEscape).join('|') ?? ''; + } + return { + ...filter, + operator, + value, + }; +} diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 7fd58d6f783..23b649d8c66 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -45,7 +45,7 @@ "history": "4.10.1", "lodash": "4.17.21", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index e1415c47a9e..8f9aaa6281f 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -26,11 +26,11 @@ export { usePluginExtensions, usePluginLinkExtensions, usePluginComponentExtensions, - usePluginLinks, } from './pluginExtensions/usePluginExtensions'; export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; +export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index 664661dcaf6..cf6d48d7e6b 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -40,6 +40,17 @@ export type UsePluginComponentsResult = { isLoading: boolean; }; +export type UsePluginLinksOptions = { + extensionPointId: string; + context?: object | Record; + limitPerPlugin?: number; +}; + +export type UsePluginLinksResult = { + isLoading: boolean; + links: PluginExtensionLink[]; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts index 439d8163ccf..f195707efc4 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts @@ -25,20 +25,6 @@ export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePlu return singleton(options); } -export function usePluginLinks(options: GetPluginExtensionsOptions): { - links: PluginExtensionLink[]; - isLoading: boolean; -} { - const { extensions, isLoading } = usePluginExtensions(options); - - return useMemo(() => { - return { - links: extensions.filter(isPluginExtensionLink), - isLoading, - }; - }, [extensions, isLoading]); -} - /** * @deprecated Use usePluginLinks() instead. */ diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts new file mode 100644 index 00000000000..d90d88cf4f7 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts @@ -0,0 +1,20 @@ +import { UsePluginLinksOptions, UsePluginLinksResult } from './getPluginExtensions'; + +export type UsePluginLinks = (options: UsePluginLinksOptions) => UsePluginLinksResult; + +let singleton: UsePluginLinks | undefined; + +export function setPluginLinksHook(hook: UsePluginLinks): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setPluginLinksHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginLinks(options: UsePluginLinksOptions): UsePluginLinksResult { + if (!singleton) { + throw new Error('setPluginLinksHook(options) can only be used after the Grafana instance has started.'); + } + return singleton(options); +} diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 5d6a90dd2e5..6509135bcce 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -48,6 +48,6 @@ "typescript": "5.5.4" }, "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" } } diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 018d316c9e3..bf0ae0494ac 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -30,7 +30,7 @@ "react-virtualized-auto-sizer": "1.0.24", "rxjs": "7.8.1", "sql-formatter-plus": "^1.3.6", - "tslib": "2.6.3", + "tslib": "2.7.0", "uuid": "9.0.1" }, "devDependencies": { @@ -42,7 +42,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/react-virtualized-auto-sizer": "1.0.4", diff --git a/packages/grafana-sql/src/components/configuration/NumberInput.tsx b/packages/grafana-sql/src/components/configuration/NumberInput.tsx index e71ce679311..492f889852e 100644 --- a/packages/grafana-sql/src/components/configuration/NumberInput.tsx +++ b/packages/grafana-sql/src/components/configuration/NumberInput.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Input } from '@grafana/ui/src/components/Input/Input'; +import { Input } from '@grafana/ui'; type NumberInputProps = { value: number; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 35196b97c59..9693c2c1c0f 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -109,7 +109,7 @@ "slate-plain-serializer": "0.7.13", "slate-react": "0.22.10", "tinycolor2": "1.6.0", - "tslib": "2.6.3", + "tslib": "2.7.0", "uplot": "1.6.30", "uuid": "9.0.1" }, @@ -145,7 +145,7 @@ "@types/is-hotkey": "0.1.10", "@types/jest": "29.5.12", "@types/mock-raf": "1.0.6", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-color": "3.0.12", diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx index e93dcdf4128..a0bbf86f2ff 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx @@ -97,9 +97,9 @@ const ManyOptionsStory: StoryFn = ({ numberOfOptions, ...arg loading={isLoading} options={options} value={value} - onChange={(val) => { - setValue(val?.value || null); - action('onChange')(val); + onChange={(opt) => { + setValue(opt?.value || null); + action('onChange')(opt); }} /> ); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index 761adc97c13..b91cde4a618 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -79,7 +79,7 @@ describe('Combobox', () => { }); it('clears selected value', async () => { - render(); + render(); expect(screen.queryByDisplayValue('Option 2')).toBeInTheDocument(); const input = screen.getByRole('combobox'); diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index f3739086a24..3fe19ab9ac7 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -23,10 +23,12 @@ interface ComboboxProps onChange: (val: Option | null) => void; value: Value | null; options: Option[]; + isClearable?: boolean; + createCustomValue?: boolean; } function itemToString(item: Option | null) { - return item?.label ?? ''; + return item?.label ?? item?.value?.toString() ?? ''; } function itemFilter(inputValue: string) { @@ -51,13 +53,44 @@ const INDEX_WIDTH_CALCULATION = 100; // A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated. const WIDTH_MULTIPLIER = 7.3; -export const Combobox = ({ options, onChange, value, id, ...restProps }: ComboboxProps) => { +export const Combobox = ({ + options, + onChange, + value, + isClearable = false, + createCustomValue = false, + id, + ...restProps +}: ComboboxProps) => { const [items, setItems] = useState(options); - const selectedItemIndex = useMemo( - () => options.findIndex((option) => option.value === value) || null, - [options, value] - ); - const selectedItem = selectedItemIndex ? options[selectedItemIndex] : null; + + const selectedItemIndex = useMemo(() => { + if (value === null) { + return null; + } + + const index = options.findIndex((option) => option.value === value); + if (index === -1) { + return null; + } + + return index; + }, [options, value]); + + const selectedItem = useMemo(() => { + if (selectedItemIndex) { + return options[selectedItemIndex]; + } + + // Custom value + if (value !== null) { + return { + label: value.toString(), + value, + }; + } + return null; + }, [selectedItemIndex, options, value]); const inputRef = useRef(null); const floatingRef = useRef(null); @@ -82,18 +115,34 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo isOpen, highlightedIndex, setInputValue, - selectItem, openMenu, closeMenu, + selectItem, } = useCombobox({ inputId: id, items, itemToString, selectedItem, + onSelectedItemChange: ({ selectedItem, inputValue }) => { + onChange(selectedItem); + }, defaultHighlightedIndex: selectedItemIndex ?? undefined, scrollIntoView: () => {}, onInputValueChange: ({ inputValue }) => { - setItems(options.filter(itemFilter(inputValue))); + const filteredItems = options.filter(itemFilter(inputValue)); + if (createCustomValue && inputValue && filteredItems.findIndex((opt) => opt.label === inputValue) === -1) { + setItems([ + ...filteredItems, + { + label: inputValue, + value: inputValue, + description: t('combobox.custom-value.create', 'Create custom value'), + }, + ]); + return; + } else { + setItems(filteredItems); + } }, onIsOpenChange: ({ isOpen }) => { // Default to displaying all values when opening @@ -102,9 +151,6 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo return; } }, - onSelectedItemChange: ({ selectedItem }) => { - onChange(selectedItem); - }, onHighlightedIndexChange: ({ highlightedIndex, type }) => { if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { rowVirtualizer.scrollToIndex(highlightedIndex); @@ -113,8 +159,8 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo }); const onBlur = useCallback(() => { - setInputValue(selectedItem?.label ?? ''); - }, [selectedItem, setInputValue]); + setInputValue(selectedItem?.label ?? value?.toString() ?? ''); + }, [selectedItem, setInputValue, value]); // the order of middleware is important! const middleware = [ @@ -147,7 +193,7 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo - {!!value && value === selectedItem?.value && ( + {!!value && value === selectedItem?.value && isClearable && ( { return (
                                                                        • = 2 +ORDER BY t.id ASC +LIMIT 1; diff --git a/pkg/registry/apis/identity/legacy/testdata/postgres--user_teams_query-team_1_members_page_1.sql b/pkg/registry/apis/identity/legacy/testdata/postgres--user_teams_query-team_1_members_page_1.sql new file mode 100755 index 00000000000..a685ac3d937 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/postgres--user_teams_query-team_1_members_page_1.sql @@ -0,0 +1,8 @@ +SELECT t.id as team_id, t.uid as team_uid, t.name as team_name, tm.permission +FROM "grafana"."user" u +INNER JOIN "grafana"."team_member" tm on u.id = tm.user_id +INNER JOIN "grafana"."team"t on tm.team_id = t.id +WHERE u.uid = 'user-1' +AND t.org_id = 1 +ORDER BY t.id ASC +LIMIT 1; diff --git a/pkg/registry/apis/identity/legacy/testdata/postgres--user_teams_query-team_1_members_page_2.sql b/pkg/registry/apis/identity/legacy/testdata/postgres--user_teams_query-team_1_members_page_2.sql new file mode 100755 index 00000000000..e79621400e1 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/postgres--user_teams_query-team_1_members_page_2.sql @@ -0,0 +1,9 @@ +SELECT t.id as team_id, t.uid as team_uid, t.name as team_name, tm.permission +FROM "grafana"."user" u +INNER JOIN "grafana"."team_member" tm on u.id = tm.user_id +INNER JOIN "grafana"."team"t on tm.team_id = t.id +WHERE u.uid = 'user-1' +AND t.org_id = 1 + AND t.id >= 2 +ORDER BY t.id ASC +LIMIT 1; diff --git a/pkg/registry/apis/identity/legacy/testdata/sqlite--user_teams_query-team_1_members_page_1.sql b/pkg/registry/apis/identity/legacy/testdata/sqlite--user_teams_query-team_1_members_page_1.sql new file mode 100755 index 00000000000..a685ac3d937 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/sqlite--user_teams_query-team_1_members_page_1.sql @@ -0,0 +1,8 @@ +SELECT t.id as team_id, t.uid as team_uid, t.name as team_name, tm.permission +FROM "grafana"."user" u +INNER JOIN "grafana"."team_member" tm on u.id = tm.user_id +INNER JOIN "grafana"."team"t on tm.team_id = t.id +WHERE u.uid = 'user-1' +AND t.org_id = 1 +ORDER BY t.id ASC +LIMIT 1; diff --git a/pkg/registry/apis/identity/legacy/testdata/sqlite--user_teams_query-team_1_members_page_2.sql b/pkg/registry/apis/identity/legacy/testdata/sqlite--user_teams_query-team_1_members_page_2.sql new file mode 100755 index 00000000000..e79621400e1 --- /dev/null +++ b/pkg/registry/apis/identity/legacy/testdata/sqlite--user_teams_query-team_1_members_page_2.sql @@ -0,0 +1,9 @@ +SELECT t.id as team_id, t.uid as team_uid, t.name as team_name, tm.permission +FROM "grafana"."user" u +INNER JOIN "grafana"."team_member" tm on u.id = tm.user_id +INNER JOIN "grafana"."team"t on tm.team_id = t.id +WHERE u.uid = 'user-1' +AND t.org_id = 1 + AND t.id >= 2 +ORDER BY t.id ASC +LIMIT 1; diff --git a/pkg/registry/apis/identity/legacy/user.go b/pkg/registry/apis/identity/legacy/user.go index 7f5a8494940..ba5f71312fe 100644 --- a/pkg/registry/apis/identity/legacy/user.go +++ b/pkg/registry/apis/identity/legacy/user.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/authlib/claims" "github.com/grafana/grafana/pkg/registry/apis/identity/common" + "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" @@ -109,3 +110,95 @@ func (s *legacySQLStore) queryUsers(ctx context.Context, sql *legacysql.LegacyDa return res, err } + +type ListUserTeamsQuery struct { + UserUID string + OrgID int64 + Pagination common.Pagination +} + +type ListUserTeamsResult struct { + Continue int64 + Items []UserTeam +} + +type UserTeam struct { + ID int64 + UID string + Name string + Permission team.PermissionType +} + +var sqlQueryUserTeamsTemplate = mustTemplate("user_teams_query.sql") + +func newListUserTeams(sql *legacysql.LegacyDatabaseHelper, q *ListUserTeamsQuery) listUserTeamsQuery { + return listUserTeamsQuery{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + UserTable: sql.Table("user"), + TeamTable: sql.Table("team"), + TeamMemberTable: sql.Table("team_member"), + Query: q, + } +} + +type listUserTeamsQuery struct { + sqltemplate.SQLTemplate + Query *ListUserTeamsQuery + UserTable string + TeamTable string + TeamMemberTable string +} + +func (r listUserTeamsQuery) Validate() error { + return nil +} + +func (s *legacySQLStore) ListUserTeams(ctx context.Context, ns claims.NamespaceInfo, query ListUserTeamsQuery) (*ListUserTeamsResult, error) { + query.Pagination.Limit += 1 + query.OrgID = ns.OrgID + if query.OrgID == 0 { + return nil, fmt.Errorf("expected non zero org id") + } + + sql, err := s.sql(ctx) + if err != nil { + return nil, err + } + + req := newListUserTeams(sql, &query) + q, err := sqltemplate.Execute(sqlQueryUserTeamsTemplate, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeamsTemplate.Name(), err) + } + + rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + + if err != nil { + return nil, err + } + + res := &ListUserTeamsResult{} + var lastID int64 + for rows.Next() { + t := UserTeam{} + err := rows.Scan(&t.ID, &t.UID, &t.Name, &t.Permission) + if err != nil { + return nil, err + } + + lastID = t.ID + res.Items = append(res.Items, t) + if len(res.Items) > int(query.Pagination.Limit)-1 { + res.Continue = lastID + res.Items = res.Items[0 : len(res.Items)-1] + break + } + } + + return res, err +} diff --git a/pkg/registry/apis/identity/legacy/user_teams_query.sql b/pkg/registry/apis/identity/legacy/user_teams_query.sql new file mode 100644 index 00000000000..6925999962a --- /dev/null +++ b/pkg/registry/apis/identity/legacy/user_teams_query.sql @@ -0,0 +1,11 @@ +SELECT t.id as team_id, t.uid as team_uid, t.name as team_name, tm.permission +FROM {{ .Ident .UserTable }} u +INNER JOIN {{ .Ident .TeamMemberTable }} tm on u.id = tm.user_id +INNER JOIN {{ .Ident .TeamTable }}t on tm.team_id = t.id +WHERE u.uid = {{ .Arg .Query.UserUID }} +AND t.org_id = {{ .Arg .Query.OrgID }} +{{- if .Query.Pagination.Continue }} + AND t.id >= {{ .Arg .Query.Pagination.Continue }} +{{- end }} +ORDER BY t.id ASC +LIMIT {{ .Arg .Query.Pagination.Limit }}; diff --git a/pkg/registry/apis/identity/register.go b/pkg/registry/apis/identity/register.go index 2bc2b5b0a20..94287ecc6fb 100644 --- a/pkg/registry/apis/identity/register.go +++ b/pkg/registry/apis/identity/register.go @@ -89,7 +89,7 @@ func (b *IdentityAPIBuilder) GetAPIGroupInfo( userResource := identityv0.UserResourceInfo storage[userResource.StoragePath()] = user.NewLegacyStore(b.Store) - storage[userResource.StoragePath("teams")] = team.NewLegacyUserTeamsStore(b.Store) + storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.Store) serviceaccountResource := identityv0.ServiceAccountResourceInfo storage[serviceaccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.Store) @@ -100,7 +100,7 @@ func (b *IdentityAPIBuilder) GetAPIGroupInfo( } // The display endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter - storage["display"] = user.NewLegacyDisplayStore(b.Store) + storage["display"] = user.NewLegacyDisplayREST(b.Store) apiGroupInfo.VersionedResourcesStorageMap[identityv0.VERSION] = storage return &apiGroupInfo, nil diff --git a/pkg/registry/apis/identity/team/rest_members.go b/pkg/registry/apis/identity/team/rest_members.go index 2279c489235..e18eaa20146 100644 --- a/pkg/registry/apis/identity/team/rest_members.go +++ b/pkg/registry/apis/identity/team/rest_members.go @@ -62,8 +62,6 @@ func (s *LegacyTeamMemberREST) Connect(ctx context.Context, name string, options } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = common.PaginationFromListQuery(r.URL.Query()) - res, err := s.store.ListTeamMembers(ctx, ns, legacy.ListTeamMembersQuery{ UID: name, Pagination: common.PaginationFromListQuery(r.URL.Query()), diff --git a/pkg/registry/apis/identity/team/store.go b/pkg/registry/apis/identity/team/store.go index 06ef7769936..a18b4a9c082 100644 --- a/pkg/registry/apis/identity/team/store.go +++ b/pkg/registry/apis/identity/team/store.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/identity/common" "github.com/grafana/grafana/pkg/registry/apis/identity/legacy" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/team" ) var ( @@ -130,28 +129,3 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO } return nil, resource.NewNotFound(name) } - -func asTeam(team *team.Team, ns string) (*identityv0.Team, error) { - item := &identityv0.Team{ - ObjectMeta: metav1.ObjectMeta{ - Name: team.UID, - Namespace: ns, - CreationTimestamp: metav1.NewTime(team.Created), - ResourceVersion: strconv.FormatInt(team.Updated.UnixMilli(), 10), - }, - Spec: identityv0.TeamSpec{ - Title: team.Name, - Email: team.Email, - }, - } - meta, err := utils.MetaAccessor(item) - if err != nil { - return nil, err - } - meta.SetUpdatedTimestamp(&team.Updated) - meta.SetOriginInfo(&utils.ResourceOriginInfo{ - Name: "SQL", - Path: strconv.FormatInt(team.ID, 10), - }) - return item, nil -} diff --git a/pkg/registry/apis/identity/team/store_binding.go b/pkg/registry/apis/identity/team/store_binding.go index 6c1abc8593b..f3499ff82a3 100644 --- a/pkg/registry/apis/identity/team/store_binding.go +++ b/pkg/registry/apis/identity/team/store_binding.go @@ -150,7 +150,7 @@ func mapToSubjects(members []legacy.TeamMember) []identityv0.TeamSubject { for _, m := range members { out = append(out, identityv0.TeamSubject{ Name: m.MemberID(), - Permission: mapPermisson(m.Permission), + Permission: common.MapTeamPermission(m.Permission), }) } return out diff --git a/pkg/registry/apis/identity/team/store_user_team.go b/pkg/registry/apis/identity/team/store_user_team.go deleted file mode 100644 index 2d9128a5e33..00000000000 --- a/pkg/registry/apis/identity/team/store_user_team.go +++ /dev/null @@ -1,88 +0,0 @@ -package team - -import ( - "context" - "net/http" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" - - identityv0 "github.com/grafana/grafana/pkg/apis/identity/v0alpha1" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/registry/apis/identity/legacy" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" -) - -var ( - _ rest.Storage = (*LegacyUserTeamsStore)(nil) - _ rest.SingularNameProvider = (*LegacyUserTeamsStore)(nil) - _ rest.Connecter = (*LegacyUserTeamsStore)(nil) - _ rest.Scoper = (*LegacyUserTeamsStore)(nil) - _ rest.StorageMetadata = (*LegacyUserTeamsStore)(nil) -) - -func NewLegacyUserTeamsStore(store legacy.LegacyIdentityStore) *LegacyUserTeamsStore { - return &LegacyUserTeamsStore{ - logger: log.New("user teams"), - store: store, - } -} - -type LegacyUserTeamsStore struct { - logger log.Logger - store legacy.LegacyIdentityStore -} - -func (r *LegacyUserTeamsStore) New() runtime.Object { - return &identityv0.TeamList{} -} - -func (r *LegacyUserTeamsStore) Destroy() {} - -func (r *LegacyUserTeamsStore) NamespaceScoped() bool { - return true -} - -func (r *LegacyUserTeamsStore) GetSingularName() string { - return "TeamList" -} - -func (r *LegacyUserTeamsStore) ProducesMIMETypes(verb string) []string { - return []string{"application/json"} -} - -func (r *LegacyUserTeamsStore) ProducesObject(verb string) interface{} { - return &identityv0.TeamList{} -} - -func (r *LegacyUserTeamsStore) ConnectMethods() []string { - return []string{"GET"} -} - -func (r *LegacyUserTeamsStore) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" // true means you can use the trailing path as a variable -} - -func (r *LegacyUserTeamsStore) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) { - ns, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - teams, err := r.store.GetUserTeams(ctx, ns, name) - if err != nil { - return nil, err - } - - return http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - list := &identityv0.TeamList{} - for _, team := range teams { - t, err := asTeam(&team, ns.Value) - if err != nil { - responder.Error(err) - return - } - list.Items = append(list.Items, *t) - } - responder.Object(200, list) - }), nil -} diff --git a/pkg/registry/apis/identity/user/display_store.go b/pkg/registry/apis/identity/user/rest_display.go similarity index 79% rename from pkg/registry/apis/identity/user/display_store.go rename to pkg/registry/apis/identity/user/rest_display.go index 76a7c780890..c5d5ecf0202 100644 --- a/pkg/registry/apis/identity/user/display_store.go +++ b/pkg/registry/apis/identity/user/rest_display.go @@ -18,57 +18,57 @@ import ( "k8s.io/apiserver/pkg/registry/rest" ) -type LegacyDisplayStore struct { +type LegacyDisplayREST struct { store legacy.LegacyIdentityStore } var ( - _ rest.Storage = (*LegacyDisplayStore)(nil) - _ rest.SingularNameProvider = (*LegacyDisplayStore)(nil) - _ rest.Connecter = (*LegacyDisplayStore)(nil) - _ rest.Scoper = (*LegacyDisplayStore)(nil) - _ rest.StorageMetadata = (*LegacyDisplayStore)(nil) + _ rest.Storage = (*LegacyDisplayREST)(nil) + _ rest.SingularNameProvider = (*LegacyDisplayREST)(nil) + _ rest.Connecter = (*LegacyDisplayREST)(nil) + _ rest.Scoper = (*LegacyDisplayREST)(nil) + _ rest.StorageMetadata = (*LegacyDisplayREST)(nil) ) -func NewLegacyDisplayStore(store legacy.LegacyIdentityStore) *LegacyDisplayStore { - return &LegacyDisplayStore{store} +func NewLegacyDisplayREST(store legacy.LegacyIdentityStore) *LegacyDisplayREST { + return &LegacyDisplayREST{store} } -func (r *LegacyDisplayStore) New() runtime.Object { +func (r *LegacyDisplayREST) New() runtime.Object { return &identity.IdentityDisplayResults{} } -func (r *LegacyDisplayStore) Destroy() {} +func (r *LegacyDisplayREST) Destroy() {} -func (r *LegacyDisplayStore) NamespaceScoped() bool { +func (r *LegacyDisplayREST) NamespaceScoped() bool { return true } -func (r *LegacyDisplayStore) GetSingularName() string { +func (r *LegacyDisplayREST) GetSingularName() string { // not actually used anywhere, but required by SingularNameProvider return "identitydisplay" } -func (r *LegacyDisplayStore) ProducesMIMETypes(verb string) []string { +func (r *LegacyDisplayREST) ProducesMIMETypes(verb string) []string { return []string{"application/json"} } -func (r *LegacyDisplayStore) ProducesObject(verb string) any { +func (r *LegacyDisplayREST) ProducesObject(verb string) any { return &identity.IdentityDisplayResults{} } -func (r *LegacyDisplayStore) ConnectMethods() []string { +func (r *LegacyDisplayREST) ConnectMethods() []string { return []string{"GET"} } -func (r *LegacyDisplayStore) NewConnectOptions() (runtime.Object, bool, string) { +func (r *LegacyDisplayREST) NewConnectOptions() (runtime.Object, bool, string) { return nil, false, "" // true means you can use the trailing path as a variable } // This will always have an empty app url var fakeCfgForGravatar = &setting.Cfg{} -func (r *LegacyDisplayStore) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) { +func (r *LegacyDisplayREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) { // See: /pkg/services/apiserver/builder/helper.go#L34 // The name is set with a rewriter hack if name != "name" { diff --git a/pkg/registry/apis/identity/user/rest_user_team.go b/pkg/registry/apis/identity/user/rest_user_team.go new file mode 100644 index 00000000000..5235dcc3592 --- /dev/null +++ b/pkg/registry/apis/identity/user/rest_user_team.go @@ -0,0 +1,101 @@ +package user + +import ( + "context" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + identityv0 "github.com/grafana/grafana/pkg/apis/identity/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/identity/common" + "github.com/grafana/grafana/pkg/registry/apis/identity/legacy" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" +) + +var ( + _ rest.Storage = (*LegacyUserTeamREST)(nil) + _ rest.Scoper = (*LegacyUserTeamREST)(nil) + _ rest.StorageMetadata = (*LegacyUserTeamREST)(nil) + _ rest.Connecter = (*LegacyUserTeamREST)(nil) +) + +func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore) *LegacyUserTeamREST { + return &LegacyUserTeamREST{store} +} + +type LegacyUserTeamREST struct { + store legacy.LegacyIdentityStore +} + +// New implements rest.Storage. +func (s *LegacyUserTeamREST) New() runtime.Object { + return &identityv0.UserTeamList{} +} + +// Destroy implements rest.Storage. +func (s *LegacyUserTeamREST) Destroy() {} + +// NamespaceScoped implements rest.Scoper. +func (s *LegacyUserTeamREST) NamespaceScoped() bool { + return true +} + +// ProducesMIMETypes implements rest.StorageMetadata. +func (s *LegacyUserTeamREST) ProducesMIMETypes(verb string) []string { + return []string{"application/json"} +} + +// ProducesObject implements rest.StorageMetadata. +func (s *LegacyUserTeamREST) ProducesObject(verb string) interface{} { + return s.New() +} + +// Connect implements rest.Connecter. +func (s *LegacyUserTeamREST) Connect(ctx context.Context, name string, options runtime.Object, responder rest.Responder) (http.Handler, error) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + res, err := s.store.ListUserTeams(ctx, ns, legacy.ListUserTeamsQuery{ + UserUID: name, + Pagination: common.PaginationFromListQuery(r.URL.Query()), + }) + if err != nil { + responder.Error(err) + return + } + + list := &identityv0.UserTeamList{Items: make([]identityv0.UserTeam, 0, len(res.Items))} + + for _, m := range res.Items { + list.Items = append(list.Items, mapToUserTeam(m)) + } + + list.ListMeta.Continue = common.OptionalFormatInt(res.Continue) + + responder.Object(http.StatusOK, list) + }), nil +} + +// NewConnectOptions implements rest.Connecter. +func (s *LegacyUserTeamREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" +} + +// ConnectMethods implements rest.Connecter. +func (s *LegacyUserTeamREST) ConnectMethods() []string { + return []string{http.MethodGet} +} + +func mapToUserTeam(t legacy.UserTeam) identityv0.UserTeam { + return identityv0.UserTeam{ + Title: t.Name, + TeamRef: identityv0.TeamRef{ + Name: t.UID, + }, + Permission: common.MapTeamPermission(t.Permission), + } +} diff --git a/pkg/services/apiserver/README.md b/pkg/services/apiserver/README.md index 7a65d16e9e3..4a3949a7d25 100644 --- a/pkg/services/apiserver/README.md +++ b/pkg/services/apiserver/README.md @@ -62,8 +62,11 @@ For kubectl to work, grafana needs to run over https. To simplify development, app_mode = development [feature_toggles] -grafanaAPIServerEnsureKubectlAccess = true +grafanaAPIServerEnsureKubectlAccess = true kubernetesPlaylists = true + +[unified_storage.playlists.playlist.grafana.app] +DualWriterMode = 2 ``` This will create a development kubeconfig and start a parallel ssl listener. It can be registered by @@ -90,4 +93,4 @@ The folder structure aims to follow the patterns established in standard (https: * [pkg/apis](/pkg/apis) - where API resource types are defined. this is based on the structure of the [sample-apiserver](https://github.com/kubernetes/sample-apiserver/tree/master/pkg/apis) * [hack/update-codegen.sh](/hack#kubernetes-hack-alert) - this script is used to run [k8s codegen](https://github.com/kubernetes/code-generator/), which generates the code that is used by the API server to handle the types defined in `pkg/apis`. it is based on the [update-codegen.sh from sample-apiserver](https://github.com/kubernetes/sample-apiserver/blob/master/hack/update-codegen.sh) * [pkg/registry/apis](/pkg/registry/apis) - where all of the types in `pkg/apis` are registered with the API server by implementing the [builder](/pkg/services/apiserver/builder/common.go#L18) interface. this pattern is unique to grafana, and is needed to support using wire dependencies in legacy storage implementations. this is separated from `pkg/apis` to avoid issues with k8s codegen. -* [pkg/cmd/grafana/apiserver](/pkg/cmd/grafana/apiserver) - this is where the apiserver is configured for the `grafana apiserver` CLI command, which can be used to launch standalone API servers. this will eventually be merged with the config in `pkg/services/apiserver` to reduce duplication. \ No newline at end of file +* [pkg/cmd/grafana/apiserver](/pkg/cmd/grafana/apiserver) - this is where the apiserver is configured for the `grafana apiserver` CLI command, which can be used to launch standalone API servers. this will eventually be merged with the config in `pkg/services/apiserver` to reduce duplication. diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index 2b8067f55cc..afbb81680fe 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -165,7 +165,11 @@ func InstallAPIs( // Get the option from custom.ini/command line // when missing this will default to mode zero (legacy only) - mode := storageOpts.DualWriterDesiredModes[key] + var mode = grafanarest.DualWriterMode(0) + resourceConfig, resourceExists := storageOpts.UnifiedStorageConfig[key] + if resourceExists { + mode = resourceConfig.DualWriterMode + } // TODO: inherited context from main Grafana process ctx := context.Background() diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 3682390466f..79ecd961fb1 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -6,8 +6,6 @@ import ( "path/filepath" "strconv" - playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" @@ -55,20 +53,18 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o o.StorageOptions.StorageType = options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeLegacy))) o.StorageOptions.DataPath = apiserverCfg.Key("storage_path").MustString(filepath.Join(cfg.DataPath, "grafana-apiserver")) o.StorageOptions.Address = apiserverCfg.Key("address").MustString(o.StorageOptions.Address) - o.StorageOptions.DualWriterDesiredModes = map[string]grafanarest.DualWriterMode{ - // TODO: use the new config from HGAPI after https://github.com/grafana/hosted-grafana/pull/5707 - playlist.GROUPRESOURCE: 2, - } + + // unified storage configs look like + // [unified_storage..] + // config = + unifiedStorageCfg := cfg.UnifiedStorage + o.StorageOptions.UnifiedStorageConfig = unifiedStorageCfg o.StorageOptions.DualWriterDataSyncJobEnabled = map[string]bool{ // TODO: This will be enabled later, when we get a dedicated config section for unified_storage // playlist.RESOURCE + "." + playlist.GROUP: true, } - // TODO: ensure backwards compatibility with production - // remove this after changing the unified_storage_mode key format in HGAPI - o.StorageOptions.DualWriterDesiredModes[playlist.RESOURCE+"."+playlist.GROUP] = o.StorageOptions.DualWriterDesiredModes[playlist.GROUPRESOURCE] - o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess) o.ExtraOptions.ExternalAddress = host o.ExtraOptions.APIURL = apiURL diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index 6fbeedc00ed..ddbe2cfc78d 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -8,7 +8,8 @@ import ( genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/apiserver/pkg/server/options" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) type StorageType string @@ -26,7 +27,7 @@ type StorageOptions struct { StorageType StorageType DataPath string Address string - DualWriterDesiredModes map[string]grafanarest.DualWriterMode + UnifiedStorageConfig map[string]setting.UnifiedStorageConfig DualWriterDataSyncJobEnabled map[string]bool } @@ -62,3 +63,21 @@ func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfi // TODO: move storage setup here return nil } + +// EnforceFeatureToggleAfterMode1 makes sure there is a feature toggle set for resources with DualWriterMode > 1. +// This is needed to ensure that we use the K8s client before enabling dual writing. +func (o *StorageOptions) EnforceFeatureToggleAfterMode1(features featuremgmt.FeatureToggles) error { + if o.StorageType != StorageTypeLegacy { + for rg, s := range o.UnifiedStorageConfig { + if s.DualWriterMode > 1 { + switch rg { + case "playlists.playlist.grafana.app": + if !features.IsEnabledGlobally(featuremgmt.FlagKubernetesPlaylists) { + return fmt.Errorf("feature toggle FlagKubernetesPlaylists to be set") + } + } + } + } + } + return nil +} diff --git a/pkg/services/apiserver/options/storage_test.go b/pkg/services/apiserver/options/storage_test.go new file mode 100644 index 00000000000..72e052eeb03 --- /dev/null +++ b/pkg/services/apiserver/options/storage_test.go @@ -0,0 +1,64 @@ +package options + +import ( + "testing" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" +) + +func TestStorageOptions_CheckFeatureToggle(t *testing.T) { + tests := []struct { + name string + StorageType StorageType + UnifiedStorageConfig map[string]setting.UnifiedStorageConfig + features any + wantErr bool + }{ + { + name: "with legacy storage", + StorageType: StorageTypeLegacy, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{"playlists.playlist.grafana.app": {DualWriterMode: 2}}, + features: featuremgmt.WithFeatures(), + }, + { + name: "with unified storage and without config for resource", + StorageType: StorageTypeUnified, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{}, + features: featuremgmt.WithFeatures(), + }, + { + name: "with unified storage, mode > 1 and with toggle for resource", + StorageType: StorageTypeUnified, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{"playlists.playlist.grafana.app": {DualWriterMode: 2}}, + features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesPlaylists), + }, + { + name: "with unified storage, mode > 1 and without toggle for resource", + StorageType: StorageTypeUnified, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{"playlists.playlist.grafana.app": {DualWriterMode: 2}}, + features: featuremgmt.WithFeatures(), + wantErr: true, + }, + { + name: "with unified storage and mode = 1", + StorageType: StorageTypeUnified, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{"playlists.playlist.grafana.app": {DualWriterMode: 1}}, + features: featuremgmt.WithFeatures(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &StorageOptions{ + StorageType: tt.StorageType, + UnifiedStorageConfig: tt.UnifiedStorageConfig, + } + err := o.EnforceFeatureToggleAfterMode1(tt.features.(featuremgmt.FeatureToggles)) + if tt.wantErr { + return + } + assert.NoError(t, err) + }) + } +} diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 7e6d0f86bf4..37d07470c40 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -263,6 +263,14 @@ func (s *service) start(ctx context.Context) error { return errs[0] } + // This will check that required feature toggles are enabled for more advanced storage modes + // Any required preconditions should be hardcoded here + if o.StorageOptions != nil { + if err := o.StorageOptions.EnforceFeatureToggleAfterMode1(s.features); err != nil { + return err + } + } + serverConfig := genericapiserver.NewRecommendedConfig(Codecs) if err := o.ApplyTo(serverConfig); err != nil { return err diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d6c8d621af0..a0c513ccc49 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1391,6 +1391,12 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaDashboardsSquad, }, + { + Name: "newFiltersUI", + Description: "Enables new combobox style UI for the Ad hoc filters variable in scenes architecture", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + }, { Name: "lokiSendDashboardPanelNames", Description: "Send dashboard and panel names to Loki when querying", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9dfc93c661f..dab6db0193c 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -183,6 +183,7 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa backgroundPluginInstaller,experimental,@grafana/plugins-platform-backend,false,true,false dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false adhocFilterOneOf,experimental,@grafana/dashboards-squad,false,false,false +newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false singleTopNav,experimental,@grafana/grafana-frontend-platform,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 4b5c4ac2d81..09a5c150cc6 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -743,6 +743,10 @@ const ( // Exposes a new 'one of' operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter. FlagAdhocFilterOneOf = "adhocFilterOneOf" + // FlagNewFiltersUI + // Enables new combobox style UI for the Ad hoc filters variable in scenes architecture + FlagNewFiltersUI = "newFiltersUI" + // FlagLokiSendDashboardPanelNames // Send dashboard and panel names to Loki when querying FlagLokiSendDashboardPanelNames = "lokiSendDashboardPanelNames" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0178520d186..3a724d7c90b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1883,6 +1883,18 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "newFiltersUI", + "resourceVersion": "1724228641625", + "creationTimestamp": "2024-08-21T08:24:01Z" + }, + "spec": { + "description": "Enables new combobox style UI for the Ad hoc filters variable in scenes architecture", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "newFolderPicker", diff --git a/pkg/services/ngalert/schedule/alert_rule.go b/pkg/services/ngalert/schedule/alert_rule.go index d9c93d5ee14..aa3218e7ad8 100644 --- a/pkg/services/ngalert/schedule/alert_rule.go +++ b/pkg/services/ngalert/schedule/alert_rule.go @@ -426,7 +426,7 @@ func (a *alertRule) evaluate(ctx context.Context, e *Evaluation, span trace.Span span.SetStatus(codes.Error, "rule evaluation failed") span.RecordError(err) } else { - logger.Debug("Alert rule evaluated", "results", results, "duration", dur) + logger.Debug("Alert rule evaluated", "results", len(results), "duration", dur) span.AddEvent("rule evaluated", trace.WithAttributes( attribute.Int64("results", int64(len(results))), )) diff --git a/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go index c36ab6620ee..409621f133a 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go @@ -10,28 +10,39 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/instrumentationutils" plog "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/pluginrequestmeta" ) // NewLoggerMiddleware creates a new plugins.ClientMiddleware that will // log requests. -func NewLoggerMiddleware(logger plog.Logger) plugins.ClientMiddleware { +func NewLoggerMiddleware(logger plog.Logger, pluginRegistry registry.Service) plugins.ClientMiddleware { return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client { return &LoggerMiddleware{ baseMiddleware: baseMiddleware{ next: next, }, - logger: logger, + logger: logger, + pluginRegistry: pluginRegistry, } }) } type LoggerMiddleware struct { baseMiddleware - logger plog.Logger + logger plog.Logger + pluginRegistry registry.Service } -func (m *LoggerMiddleware) logRequest(ctx context.Context, fn func(ctx context.Context) (instrumentationutils.RequestStatus, error)) error { +func (m *LoggerMiddleware) pluginTarget(ctx context.Context, pCtx backend.PluginContext) string { + p, exists := m.pluginRegistry.Plugin(ctx, pCtx.PluginID, pCtx.PluginVersion) + if !exists { + return "" + } + return string(p.Target()) +} + +func (m *LoggerMiddleware) logRequest(ctx context.Context, pCtx backend.PluginContext, fn func(ctx context.Context) (instrumentationutils.RequestStatus, error)) error { start := time.Now() timeBeforePluginRequest := log.TimeSinceStart(ctx, start) @@ -41,6 +52,7 @@ func (m *LoggerMiddleware) logRequest(ctx context.Context, fn func(ctx context.C "duration", time.Since(start), "eventName", "grafana-data-egress", "time_before_plugin_request", timeBeforePluginRequest, + "target", m.pluginTarget(ctx, pCtx), } if err != nil { logParams = append(logParams, "error", err) @@ -64,7 +76,7 @@ func (m *LoggerMiddleware) QueryData(ctx context.Context, req *backend.QueryData } var resp *backend.QueryDataResponse - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.QueryData(ctx, req) @@ -96,7 +108,7 @@ func (m *LoggerMiddleware) CallResource(ctx context.Context, req *backend.CallRe return m.next.CallResource(ctx, req, sender) } - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { innerErr := m.next.CallResource(ctx, req, sender) return instrumentationutils.RequestStatusFromError(innerErr), innerErr }) @@ -110,7 +122,7 @@ func (m *LoggerMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHe } var resp *backend.CheckHealthResult - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.CheckHealth(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr @@ -125,7 +137,7 @@ func (m *LoggerMiddleware) CollectMetrics(ctx context.Context, req *backend.Coll } var resp *backend.CollectMetricsResult - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.CollectMetrics(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr @@ -140,7 +152,7 @@ func (m *LoggerMiddleware) SubscribeStream(ctx context.Context, req *backend.Sub } var resp *backend.SubscribeStreamResponse - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.SubscribeStream(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr @@ -155,7 +167,7 @@ func (m *LoggerMiddleware) PublishStream(ctx context.Context, req *backend.Publi } var resp *backend.PublishStreamResponse - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.PublishStream(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr @@ -169,7 +181,7 @@ func (m *LoggerMiddleware) RunStream(ctx context.Context, req *backend.RunStream return m.next.RunStream(ctx, req, sender) } - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { innerErr := m.next.RunStream(ctx, req, sender) return instrumentationutils.RequestStatusFromError(innerErr), innerErr }) @@ -183,7 +195,7 @@ func (m *LoggerMiddleware) ValidateAdmission(ctx context.Context, req *backend.A } var resp *backend.ValidationResponse - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.ValidateAdmission(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr @@ -198,7 +210,7 @@ func (m *LoggerMiddleware) MutateAdmission(ctx context.Context, req *backend.Adm } var resp *backend.MutationResponse - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.MutateAdmission(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr @@ -213,7 +225,7 @@ func (m *LoggerMiddleware) ConvertObjects(ctx context.Context, req *backend.Conv } var resp *backend.ConversionResponse - err := m.logRequest(ctx, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { + err := m.logRequest(ctx, req.PluginContext, func(ctx context.Context) (instrumentationutils.RequestStatus, error) { var innerErr error resp, innerErr = m.next.ConvertObjects(ctx, req) return instrumentationutils.RequestStatusFromError(innerErr), innerErr diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index e411e6c8552..cc1ec4c80cf 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -175,7 +175,7 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken } if cfg.PluginLogBackendRequests { - middlewares = append(middlewares, clientmiddleware.NewLoggerMiddleware(log.New("plugin.instrumentation"))) + middlewares = append(middlewares, clientmiddleware.NewLoggerMiddleware(log.New("plugin.instrumentation"), registry)) } skipCookiesNames := []string{cfg.LoginCookieName} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 13cdf4b5458..86330a869ff 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -235,13 +235,18 @@ func (ps *ProvisioningServiceImpl) ProvisionPlugins(ctx context.Context) error { } func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error { + err := ps.setDashboardProvisioner() + if err != nil { + return fmt.Errorf("%v: %w", "Failed to create provisioner", err) + } + ps.mutex.Lock() defer ps.mutex.Unlock() ps.cancelPolling() ps.dashboardProvisioner.CleanUpOrphanedDashboards(ctx) - err := ps.dashboardProvisioner.Provision(ctx) + err = ps.dashboardProvisioner.Provision(ctx) if err != nil { // If we fail to provision with the new provisioner, the mutex will unlock and the polling will restart with the // old provisioner as we did not switch them yet. diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index c2e9e08fbaa..de84250512a 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -108,6 +108,19 @@ func TestProvisioningServiceImpl(t *testing.T) { assert.True(t, errors.Is(serviceTest.serviceError, provisioningErr)) }) + t.Run("Should set dashboard provisioner when provisioning dashboards", func(t *testing.T) { + // The first dashboard provisioner instantiation takes place when + // setDashboardProvisioner() is called in setup(t). + serviceTest := setup(t) + // The second dashboard provisioner instantiation takes place when + // Run(ctx) is executed. + serviceTest.startService() + + serviceTest.cancel() + serviceTest.waitForStop() + + assert.Equal(t, 2, serviceTest.dashboardProvisionerInstantiations) + }) } type serviceTestStruct struct { @@ -121,6 +134,8 @@ type serviceTestStruct struct { startService func() cancel func() + dashboardProvisionerInstantiations int + mock *dashboards.ProvisionerMock service *ProvisioningServiceImpl } @@ -141,6 +156,7 @@ func setup(t *testing.T) *serviceTestStruct { service, err := newProvisioningServiceImpl( func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service) (dashboards.DashboardProvisioner, error) { + serviceTest.dashboardProvisionerInstantiations++ return serviceTest.mock, nil }, nil, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 04612d220ac..375c03ff6da 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/osutil" @@ -523,6 +524,13 @@ type Cfg struct { //Short Links ShortLinkExpiration int + + // Unified Storage + UnifiedStorage map[string]UnifiedStorageConfig +} + +type UnifiedStorageConfig struct { + DualWriterMode rest.DualWriterMode } type InstallPlugin struct { @@ -1324,6 +1332,9 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.ScopesListScopesURL = scopesSection.Key("list_scopes_endpoint").MustString("") cfg.ScopesListDashboardsURL = scopesSection.Key("list_dashboards_endpoint").MustString("") + // read unifed storage config + cfg.setUnifiedStorageConfig() + return nil } diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go new file mode 100644 index 00000000000..d26dc6c3c06 --- /dev/null +++ b/pkg/setting/setting_unified_storage.go @@ -0,0 +1,31 @@ +package setting + +import ( + "strings" + + "github.com/grafana/grafana/pkg/apiserver/rest" +) + +// read storage configs from ini file. They look like: +// [unified_storage..] +// = +// e.g. +// [unified_storage.playlists.playlist.grafana.app] +// dualWriterMode = 2 +func (cfg *Cfg) setUnifiedStorageConfig() { + storageConfig := make(map[string]UnifiedStorageConfig) + sections := cfg.Raw.Sections() + for _, section := range sections { + sectionName := section.Name() + if !strings.HasPrefix(sectionName, "unified_storage.") { + continue + } + // the resource name is the part after the first dot + resourceName := strings.SplitAfterN(sectionName, ".", 2)[1] + + // parse dualWriter modes from the section + dualWriterMode := section.Key("dualWriterMode").MustInt(0) + storageConfig[resourceName] = UnifiedStorageConfig{DualWriterMode: rest.DualWriterMode(dualWriterMode)} + } + cfg.UnifiedStorage = storageConfig +} diff --git a/pkg/setting/setting_unified_storage_test.go b/pkg/setting/setting_unified_storage_test.go new file mode 100644 index 00000000000..bb46ad9a18b --- /dev/null +++ b/pkg/setting/setting_unified_storage_test.go @@ -0,0 +1,28 @@ +package setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCfg_setUnifiedStorageConfig(t *testing.T) { + t.Run("read unified_storage configs", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{HomePath: "../../", Config: "../../conf/defaults.ini"}) + assert.NoError(t, err) + + s, err := cfg.Raw.NewSection("unified_storage.playlists.playlist.grafana.app") + assert.NoError(t, err) + + _, err = s.NewKey("dualWriterMode", "2") + assert.NoError(t, err) + + cfg.setUnifiedStorageConfig() + + value, exists := cfg.UnifiedStorage["playlists.playlist.grafana.app"] + + assert.Equal(t, exists, true) + assert.Equal(t, value, UnifiedStorageConfig{DualWriterMode: 2}) + }) +} diff --git a/pkg/tests/apis/identity/identity_test.go b/pkg/tests/apis/identity/identity_test.go index a98858e8ea3..d945c27f8d5 100644 --- a/pkg/tests/apis/identity/identity_test.go +++ b/pkg/tests/apis/identity/identity_test.go @@ -69,7 +69,6 @@ func TestIntegrationIdentity(t *testing.T) { rsp, err := teamClient.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) found := teamClient.SanitizeJSONList(rsp, "name") - // fmt.Printf("%s", found) require.JSONEq(t, `{ "items": [ { @@ -87,7 +86,7 @@ func TestIntegrationIdentity(t *testing.T) { }, "spec": { "email": "staff@Org1", - "name": "staff" + "title": "staff" } } ] diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index 6840ecc946e..a362e99433e 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -18,6 +18,7 @@ import ( grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -92,12 +93,14 @@ func TestIntegrationPlaylist(t *testing.T) { AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "file", // write the files to disk + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode0, + }, + }, EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode0, - }, })) }) @@ -106,12 +109,14 @@ func TestIntegrationPlaylist(t *testing.T) { AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "file", // write the files to disk + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode1, + }, + }, EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode1, - }, })) }) @@ -120,12 +125,14 @@ func TestIntegrationPlaylist(t *testing.T) { AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "file", // write the files to disk + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode2, + }, + }, EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode2, - }, })) }) @@ -134,12 +141,14 @@ func TestIntegrationPlaylist(t *testing.T) { AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "file", // write the files to disk + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode3, + }, + }, EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode3, - }, })) }) @@ -151,8 +160,10 @@ func TestIntegrationPlaylist(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode0, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode0, + }, }, })) }) @@ -162,12 +173,7 @@ func TestIntegrationPlaylist(t *testing.T) { AppModeProduction: false, // required for unified storage DisableAnonymous: true, APIServerStorageType: "unified", // use the entity api tables - EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written - }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode1, - }, + EnableFeatureToggles: []string{}, })) }) @@ -179,8 +185,10 @@ func TestIntegrationPlaylist(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode2, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode2, + }, }, })) }) @@ -193,8 +201,10 @@ func TestIntegrationPlaylist(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode3, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode3, + }, }, })) }) @@ -207,12 +217,14 @@ func TestIntegrationPlaylist(t *testing.T) { AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "etcd", // requires etcd running on localhost:2379 + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode0, + }, + }, EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode0, - }, }) // Clear the collection before starting (etcd) @@ -237,8 +249,10 @@ func TestIntegrationPlaylist(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode1, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode1, + }, }, }) @@ -264,8 +278,10 @@ func TestIntegrationPlaylist(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode2, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode2, + }, }, }) @@ -291,8 +307,10 @@ func TestIntegrationPlaylist(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, - DualWriterDesiredModes: map[string]grafanarest.DualWriterMode{ - playlistv0alpha1.GROUPRESOURCE: grafanarest.Mode3, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + playlistv0alpha1.GROUPRESOURCE: { + DualWriterMode: grafanarest.Mode3, + }, }, }) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index b0e28808962..37fce966a82 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -17,7 +17,6 @@ import ( "gopkg.in/ini.v1" "github.com/grafana/grafana/pkg/api" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/fs" @@ -390,12 +389,11 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = grafanaComSection.NewKey("api_url", o.GrafanaComAPIURL) require.NoError(t, err) } - - if o.DualWriterDesiredModes != nil { - unifiedStorageMode, err := getOrCreateSection("unified_storage_mode") - require.NoError(t, err) - for k, v := range o.DualWriterDesiredModes { - _, err = unifiedStorageMode.NewKey(k, fmt.Sprint(v)) + if o.UnifiedStorageConfig != nil { + for k, v := range o.UnifiedStorageConfig { + section, err := getOrCreateSection(fmt.Sprintf("unified_storage.%s", k)) + require.NoError(t, err) + _, err = section.NewKey("dualWriterMode", fmt.Sprintf("%d", v.DualWriterMode)) require.NoError(t, err) } } @@ -446,7 +444,7 @@ type GrafanaOpts struct { QueryRetries int64 APIServerStorageType string GrafanaComAPIURL string - DualWriterDesiredModes map[string]grafanarest.DualWriterMode + UnifiedStorageConfig map[string]setting.UnifiedStorageConfig } func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) *user.User { diff --git a/pkg/tsdb/cloudwatch/log_actions.go b/pkg/tsdb/cloudwatch/log_actions.go index d4097f4fa85..28616531d5b 100644 --- a/pkg/tsdb/cloudwatch/log_actions.go +++ b/pkg/tsdb/cloudwatch/log_actions.go @@ -25,6 +25,7 @@ import ( const ( limitExceededException = "LimitExceededException" + throttlingException = "ThrottlingException" defaultEventLimit = int64(10) defaultLogGroupLimit = int64(50) logIdentifierInternal = "__log__grafana_internal__" @@ -233,6 +234,9 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c if errors.As(err, &awsErr) && awsErr.Code() == "LimitExceededException" { e.logger.FromContext(ctx).Debug("ExecuteStartQuery limit exceeded", "err", awsErr) err = &AWSError{Code: limitExceededException, Message: err.Error()} + } else if errors.As(err, &awsErr) && awsErr.Code() == "ThrottlingException" { + e.logger.FromContext(ctx).Debug("ExecuteStartQuery rate exceeded", "err", awsErr) + err = &AWSError{Code: throttlingException, Message: err.Error()} } err = errorsource.DownstreamError(err, false) } diff --git a/public/api-merged.json b/public/api-merged.json index f6ccf78a8d0..8d6476bd4d8 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -2184,7 +2184,7 @@ }, "/auth/keys": { "get": { - "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/service-accounts/migrate-api-keys/.", "tags": [ "api_keys" ], @@ -2234,7 +2234,7 @@ }, "/auth/keys/{id}": { "delete": { - "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/service-accounts/migrate-api-keys/.", "tags": [ "api_keys" ], diff --git a/public/app/app.ts b/public/app/app.ts index 1d164a5baef..6e6141e0d86 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -41,6 +41,7 @@ import { setPluginComponentsHook, setCurrentUser, setChromeHeaderHeightHook, + setPluginLinksHook, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -83,14 +84,12 @@ import { initGrafanaLive } from './features/live'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { DatasourceSrv } from './features/plugins/datasource_srv'; -import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations'; import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions'; -import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './features/plugins/extensions/registry/AddedComponentsRegistry'; -import { ExposedComponentsRegistry } from './features/plugins/extensions/registry/ExposedComponentsRegistry'; +import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; +import { createUsePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { preloadPlugins } from './features/plugins/pluginPreloader'; import { QueryRunner } from './features/query/state/QueryRunner'; @@ -213,17 +212,7 @@ export class GrafanaApp { initWindowRuntime(); // Initialize plugin extensions - const pluginExtensionsRegistries = { - extensionsRegistry: new ReactivePluginExtensionsRegistry(), - addedComponentsRegistry: new AddedComponentsRegistry(), - exposedComponentsRegistry: new ExposedComponentsRegistry(), - }; - pluginExtensionsRegistries.extensionsRegistry.register({ - pluginId: 'grafana', - extensionConfigs: getCoreExtensionConfigurations(), - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); + const pluginExtensionsRegistries = setupPluginExtensionRegistries(); if (contextSrv.user.orgRole !== '') { // The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init. @@ -236,18 +225,9 @@ export class GrafanaApp { await preloadPlugins(awaitedAppPlugins, pluginExtensionsRegistries, 'frontend_awaited_plugins_preload'); } - setPluginExtensionGetter( - createPluginExtensionsGetter( - pluginExtensionsRegistries.extensionsRegistry, - pluginExtensionsRegistries.addedComponentsRegistry - ) - ); - setPluginExtensionsHook( - createUsePluginExtensions( - pluginExtensionsRegistries.extensionsRegistry, - pluginExtensionsRegistries.addedComponentsRegistry - ) - ); + setPluginLinksHook(createUsePluginLinks(pluginExtensionsRegistries.addedLinksRegistry)); + setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionsRegistries)); + setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionsRegistries)); setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry)); setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry)); diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx index f03f3907093..c91beb88ae0 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx @@ -1,12 +1,10 @@ -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Router } from 'react-router-dom'; -import { TestProvider } from 'test/helpers/TestProvider'; -import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; +import { render } from 'test/test-utils'; import { NavModelItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { locationService } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; import { MegaMenu } from './MegaMenu'; @@ -33,16 +31,8 @@ const setup = () => { }, ]; - const grafanaContext = getGrafanaContextMock(); - grafanaContext.chrome.setMegaMenuOpen(true); - - return render( - - - {}} /> - - - ); + const store = configureStore({ navBarTree }); + return render( {}} />, { store }); }; describe('MegaMenu', () => { diff --git a/public/app/core/navigation/GrafanaRoute.test.tsx b/public/app/core/navigation/GrafanaRoute.test.tsx index 301ccdae935..a8623765080 100644 --- a/public/app/core/navigation/GrafanaRoute.test.tsx +++ b/public/app/core/navigation/GrafanaRoute.test.tsx @@ -1,8 +1,6 @@ -import { render, screen } from '@testing-library/react'; -import { History, Location } from 'history'; +import { screen } from '@testing-library/react'; import { lazy, ComponentType } from 'react'; -import { match } from 'react-router-dom'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render } from 'test/test-utils'; import { setEchoSrv } from '@grafana/runtime'; @@ -11,11 +9,34 @@ import { Echo } from '../services/echo/Echo'; import { GrafanaRoute, Props } from './GrafanaRoute'; import { GrafanaRouteComponentProps } from './types'; +const mockLocation = { + search: '?query=hello&test=asd', + pathname: '', + state: undefined, + hash: '', +}; function setup(overrides: Partial) { const props: Props = { - location: { search: '?query=hello&test=asd' } as Location, - history: {} as History, - match: {} as match, + location: mockLocation, + history: { + length: 0, + action: 'PUSH', + location: mockLocation, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + listen: jest.fn(), + createHref: jest.fn(), + }, + match: { + params: {}, + isExact: false, + path: '', + url: '', + }, route: { path: '/', component: () =>
                                                                          , @@ -23,11 +44,7 @@ function setup(overrides: Partial) { ...overrides, }; - render( - - - - ); + render(); } describe('GrafanaRoute', () => { diff --git a/public/app/core/navigation/testRoutes.tsx b/public/app/core/navigation/testRoutes.tsx deleted file mode 100644 index 391d4dac3af..00000000000 --- a/public/app/core/navigation/testRoutes.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Link, NavLink } from 'react-router-dom'; - -import { RouterDebugger } from './RouterDebugger'; -import { RouteDescriptor } from './types'; - -export const testRoutes: RouteDescriptor[] = [ - { - path: '/test1', - component: () => ( - <> -

                                                                          Test1

                                                                          - Test2 link - Test2 navlink - - ), - }, - { - path: '/test2', - component: () => ( - <> -

                                                                          Test2

                                                                          - Test1 link - Test1 navlink - - ), - }, - { - path: '/router-debug', - component: RouterDebugger, - }, -]; diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 7e6fa953002..7b6e3b87052 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -7,14 +7,14 @@ import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints')); const EditContactPoint = SafeDynamicImport(() => import('./components/contact-points/EditContactPoint')); -const NewContactPoint = SafeDynamicImport(() => import('./components/contact-points/NewContactPoint')); +const NewReceiverView = SafeDynamicImport(() => import('./components/receivers/NewReceiverView')); const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/components/GlobalConfig')); const ContactPoints = (): JSX.Element => ( - + diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index 48e64bdf218..e80a87fa000 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -113,6 +113,9 @@ export const alertingApi = createApi({ 'GrafanaSlo', 'RuleGroup', 'RuleNamespace', + 'ContactPoint', + 'ContactPointsStatus', + 'Receiver', ], endpoints: () => ({}), }); diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index a979d952108..fa93bfd0e2d 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -47,6 +47,20 @@ interface AlertmanagerAlertsFilter { matchers?: Matcher[]; } +/** + * List of tags corresponding to entities that are implicitly provided by an alert manager configuration. + * + * i.e. "things that should be fetched fresh if the AM config has changed" + */ +export const ALERTMANAGER_PROVIDED_ENTITY_TAGS = [ + 'AlertingConfiguration', + 'AlertmanagerConfiguration', + 'AlertmanagerConnectionStatus', + 'ContactPoint', + 'ContactPointsStatus', + 'Receiver', +] as const; + // Based on https://github.com/prometheus/alertmanager/blob/main/api/v2/openapi.yaml export const alertmanagerApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ @@ -120,7 +134,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ data: config, showSuccessAlert: false, }), - invalidatesTags: ['AlertingConfiguration', 'AlertmanagerConfiguration', 'AlertmanagerConnectionStatus'], + invalidatesTags: [...ALERTMANAGER_PROVIDED_ENTITY_TAGS], }), getAlertmanagerConfigurationHistory: build.query({ @@ -248,7 +262,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ data: config, showSuccessAlert: false, }), - invalidatesTags: ['AlertmanagerConfiguration'], + invalidatesTags: ['AlertmanagerConfiguration', 'ContactPoint', 'ContactPointsStatus', 'Receiver'], }), // Grafana Managed Alertmanager only @@ -274,12 +288,13 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ }), })); }, + providesTags: ['ContactPointsStatus'], }), // Grafana Managed Alertmanager only // TODO: Remove as part of migration to k8s API for receivers getContactPointsList: build.query({ query: () => ({ url: '/api/v1/notifications/receivers' }), - providesTags: ['AlertmanagerConfiguration'], + providesTags: ['ContactPoint'], }), getMuteTimingList: build.query({ query: () => ({ url: '/api/v1/notifications/time-intervals' }), diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index a18e1aa9294..a2c7faa9382 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -1,9 +1,15 @@ import { lastValueFrom } from 'rxjs'; import { getBackendSrv } from '@grafana/runtime'; +import { logInfo } from 'app/features/alerting/unified/Analytics'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { RuleGroup, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting'; -import { PromRuleGroupDTO, PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { + PromAlertingRuleState, + PromRuleGroupDTO, + PromRulesResponse, + PromRuleType, +} from 'app/types/unified-alerting-dto'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; @@ -88,6 +94,16 @@ export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName: groups.forEach((group) => { group.rules.forEach((rule) => { rule.query = rule.query || ''; + if (rule.type === PromRuleType.Alerting) { + // There's a possibility that a custom/unexpected datasource might response with + // `type: alerting` but no state + // In this case, we fall back to `Inactive` state so that elsewhere in the UI we don't fail/have to handle the edge case + // and log a message so we can identify how frequently this might be happening + if (!rule.state) { + logInfo('prom rule with type=alerting is missing a state', { dataSourceName, ruleName: rule.name }); + rule.state = PromAlertingRuleState.Inactive; + } + } }); if (!nsMap[group.file]) { nsMap[group.file] = { diff --git a/public/app/features/alerting/unified/api/receiversApi.ts b/public/app/features/alerting/unified/api/receiversApi.ts index 1b9166ae96c..3f94a38f26e 100644 --- a/public/app/features/alerting/unified/api/receiversApi.ts +++ b/public/app/features/alerting/unified/api/receiversApi.ts @@ -1,3 +1,5 @@ +/** @deprecated To be deleted - use alertingApiServer API instead */ + import { ContactPointsState } from 'app/types'; import { CONTACT_POINTS_STATE_INTERVAL_MS } from '../utils/constants'; diff --git a/public/app/features/alerting/unified/api/receiversK8sApi.ts b/public/app/features/alerting/unified/api/receiversK8sApi.ts new file mode 100644 index 00000000000..568077eb09f --- /dev/null +++ b/public/app/features/alerting/unified/api/receiversK8sApi.ts @@ -0,0 +1,22 @@ +import { generatedReceiversApi } from 'app/features/alerting/unified/openapi/receiversApi.gen'; + +export const receiversApi = generatedReceiversApi.enhanceEndpoints({ + endpoints: { + createNamespacedReceiver: { + invalidatesTags: ['Receiver', 'ContactPoint', 'ContactPointsStatus', 'AlertmanagerConfiguration'], + }, + // Add the content-type header, as otherwise the inner workings of + // backend_srv will not realise the body is supposed to be JSON + // and will incorrectly serialise the body as URLSearchParams + deleteNamespacedReceiver: (endpoint) => { + const originalQuery = endpoint.query; + endpoint.query = (...args) => { + const baseQuery = originalQuery!(...args); + baseQuery.headers = { + 'content-type': 'application/json', + }; + return baseQuery; + }; + }, + }, +}); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx index 60d2663d918..dbe35ffa19d 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoint.tsx @@ -7,6 +7,9 @@ import { Icon, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { PrimaryText } from 'app/features/alerting/unified/components/common/TextVariants'; import { ContactPointHeader } from 'app/features/alerting/unified/components/contact-points/ContactPointHeader'; +import { useDeleteContactPointModal } from 'app/features/alerting/unified/components/contact-points/components/Modals'; +import { useDeleteContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; +import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; import { GrafanaNotifierType, NotifierStatus } from 'app/types/alerting'; @@ -16,26 +19,19 @@ import { ReceiverMetadataBadge } from '../receivers/grafanaAppReceivers/Receiver import { ReceiverPluginMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata'; import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY, RECEIVER_STATUS_KEY } from './constants'; -import { getReceiverDescription, ReceiverConfigWithMetadata, RouteReference } from './utils'; +import { ContactPointWithMetadata, getReceiverDescription, ReceiverConfigWithMetadata } from './utils'; interface ContactPointProps { - name: string; disabled?: boolean; - provisioned?: boolean; - receivers: ReceiverConfigWithMetadata[]; - policies?: RouteReference[]; - onDelete: (name: string) => void; + contactPoint: ContactPointWithMetadata; } -export const ContactPoint = ({ - name, - disabled = false, - provisioned = false, - receivers, - policies = [], - onDelete, -}: ContactPointProps) => { +export const ContactPoint = ({ disabled = false, contactPoint }: ContactPointProps) => { + const { grafana_managed_receiver_configs: receivers } = contactPoint; const styles = useStyles2(getStyles); + const { selectedAlertmanager } = useAlertmanager(); + const handleDelete = useDeleteContactPoint({ alertmanager: selectedAlertmanager! }); + const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(handleDelete); // TODO probably not the best way to figure out if we want to show either only the summary or full metadata for the receivers? const showFullMetadata = receivers.some((receiver) => Boolean(receiver[RECEIVER_META_KEY])); @@ -44,11 +40,14 @@ export const ContactPoint = ({
                                                                          + showDeleteModal({ + name: contactPointToDelete.id || contactPointToDelete.name, + resourceVersion: contactPointToDelete.metadata?.resourceVersion, + }) + } /> {showFullMetadata ? (
                                                                          @@ -58,7 +57,6 @@ export const ContactPoint = ({ const sendingResolved = !Boolean(receiver.disableResolveMessage); const pluginMetadata = receiver[RECEIVER_PLUGIN_META_KEY]; const key = metadata.name + index; - return ( )} + {DeleteModal}
                                                                          ); }; diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx index 730f2520fa8..7bb4f9c0908 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -6,6 +6,7 @@ import { Dropdown, LinkButton, Menu, Stack, Text, TextLink, Tooltip, useStyles2 import { t } from 'app/core/internationalization'; import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap'; import { useExportContactPoint } from 'app/features/alerting/unified/components/contact-points/useExportContactPoint'; +import { PROVENANCE_ANNOTATION } from 'app/features/alerting/unified/utils/k8s/constants'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { createRelativeUrl } from '../../utils/url'; @@ -14,18 +15,16 @@ import { ProvisioningBadge } from '../Provisioning'; import { Spacer } from '../Spacer'; import { UnusedContactPointBadge } from './components/UnusedBadge'; -import { RouteReference } from './utils'; +import { ContactPointWithMetadata } from './utils'; interface ContactPointHeaderProps { - name: string; + contactPoint: ContactPointWithMetadata; disabled?: boolean; - provisioned?: boolean; - policies?: RouteReference[]; - onDelete: (name: string) => void; + onDelete: (contactPoint: ContactPointWithMetadata) => void; } -export const ContactPointHeader = (props: ContactPointHeaderProps) => { - const { name, disabled = false, provisioned = false, policies = [], onDelete } = props; +export const ContactPointHeader = ({ contactPoint, disabled = false, onDelete }: ContactPointHeaderProps) => { + const { name, id, provisioned, policies = [] } = contactPoint; const styles = useStyles2(getStyles); const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint); @@ -76,7 +75,7 @@ export const ContactPointHeader = (props: ContactPointHeaderProps) => { icon="trash-alt" destructive disabled={disabled || !canDelete} - onClick={() => onDelete(name)} + onClick={() => onDelete(contactPoint)} /> ); @@ -86,6 +85,10 @@ export const ContactPointHeader = (props: ContactPointHeaderProps) => { count: numberOfPolicies, }); + // TOOD: Tidy up/consolidate logic for working out id for contact point. This requires some unravelling of + // existing types so its clearer where the ID has come from + const urlId = id || name; + return (
                                                                          @@ -104,7 +107,9 @@ export const ContactPointHeader = (props: ContactPointHeaderProps) => { {referencedByPoliciesText} )} - {provisioned && } + {provisioned && ( + + )} {!isReferencedByAnyPolicy && } { disabled={disabled} aria-label={`${canEdit ? 'edit' : 'view'}-action`} data-testid={`${canEdit ? 'edit' : 'view'}-action`} - href={`/alerting/notifications/receivers/${encodeURIComponent(name)}/edit`} + href={`/alerting/notifications/receivers/${encodeURIComponent(urlId)}/edit`} > {canEdit ? 'Edit' : 'View'} {menuActions.length > 0 && ( {menuActions}}> - + )} diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index 80ac51e2501..6faa94018e0 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -1,10 +1,9 @@ -import userEvent from '@testing-library/user-event'; import { MemoryHistoryBuildOptions } from 'history'; -import { noop } from 'lodash'; import { ComponentProps, ReactNode } from 'react'; -import { render, screen, waitFor, waitForElementToBeRemoved } from 'test/test-utils'; +import { render, screen, userEvent, waitFor, waitForElementToBeRemoved } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; @@ -12,7 +11,7 @@ import { setupMswServer } from '../../mockApi'; import { grantUserPermissions, mockDataSource } from '../../mocks'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { setupDataSources } from '../../testSetup/datasources'; -import { DataSourceType } from '../../utils/datasource'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { ContactPoint } from './ContactPoint'; import ContactPointsPageContents from './ContactPoints'; @@ -20,7 +19,7 @@ import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimi import setupVanillaAlertmanagerFlavoredServer, { VANILLA_ALERTMANAGER_DATASOURCE_UID, } from './__mocks__/vanillaAlertmanagerServer'; -import { RouteReference } from './utils'; +import { ContactPointWithMetadata, RouteReference } from './utils'; /** * There are lots of ways in which we test our pages and components. Here's my opinionated approach to testing them. @@ -52,6 +51,24 @@ const renderWithProvider = ( { historyOptions } ); +const basicContactPoint: ContactPointWithMetadata = { + name: 'my-contact-point', + id: 'foo', + grafana_managed_receiver_configs: [], +}; + +const attemptDeleteContactPoint = async (name: string) => { + const user = userEvent.setup(); + const moreActions = await screen.findByRole('button', { name: `More actions for contact point "${name}"` }); + await user.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + await user.click(deleteButton); + + await screen.findByRole('heading', { name: /delete contact point/i }); + return user.click(await screen.findByRole('button', { name: /delete contact point/i })); +}; + describe('contact points', () => { describe('Contact points with Grafana managed alertmanager', () => { beforeEach(() => { @@ -124,7 +141,7 @@ describe('contact points', () => { it('should disable certain actions if the user has no write permissions', async () => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead]); - renderWithProvider(); + const { user } = renderWithProvider(); // wait for loading to be done await waitForElementToBeRemoved(screen.queryByText('Loading...')); @@ -145,34 +162,33 @@ describe('contact points', () => { // check if all of the delete buttons are disabled for await (const button of moreButtons) { - await userEvent.click(button); + await user.click(button); const deleteButton = screen.queryByRole('menuitem', { name: 'delete' }); expect(deleteButton).toBeDisabled(); // click outside the menu to close it otherwise we can't interact with the rest of the page - await userEvent.click(document.body); + await user.click(document.body); } // check buttons in Notification Templates const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' }); - await userEvent.click(notificationTemplatesTab); + await user.click(notificationTemplatesTab); expect(screen.getByRole('link', { name: 'Add notification template' })).toHaveAttribute('aria-disabled', 'true'); }); - it('should call delete when clicked and not disabled', async () => { - const onDelete = jest.fn(); - renderWithProvider(); + it('allows deleting when not disabled', async () => { + renderWithProvider( + , + { initialEntries: ['/?tab=contact_points'] }, + { alertmanagerSourceName: GRAFANA_RULES_SOURCE_NAME } + ); - const moreActions = screen.getByRole('button', { name: /More/ }); - await userEvent.click(moreActions); + await attemptDeleteContactPoint('lotsa-emails'); - const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); - await userEvent.click(deleteButton); - - expect(onDelete).toHaveBeenCalledWith('my-contact-point'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('should disable edit button', async () => { - renderWithProvider(); + renderWithProvider(); const moreActions = screen.getByRole('button', { name: /More/ }); expect(moreActions).toBeEnabled(); @@ -182,7 +198,7 @@ describe('contact points', () => { }); it('should disable buttons when provisioned', async () => { - renderWithProvider(); + const { user } = renderWithProvider(); expect(screen.getByText(/provisioned/i)).toBeInTheDocument(); @@ -194,7 +210,7 @@ describe('contact points', () => { const moreActions = screen.getByRole('button', { name: /More/ }); expect(moreActions).toBeEnabled(); - await userEvent.click(moreActions); + await user.click(moreActions); const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); expect(deleteButton).toBeDisabled(); @@ -210,12 +226,12 @@ describe('contact points', () => { }, ]; - renderWithProvider(); + const { user } = renderWithProvider(); expect(screen.getByRole('link', { name: /1 notification policy/ })).toBeInTheDocument(); const moreActions = screen.getByRole('button', { name: /More/ }); - await userEvent.click(moreActions); + await user.click(moreActions); const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); expect(deleteButton).toBeDisabled(); @@ -231,20 +247,20 @@ describe('contact points', () => { }, ]; - renderWithProvider(); + const { user } = renderWithProvider(); const moreActions = screen.getByRole('button', { name: /More/ }); - await userEvent.click(moreActions); + await user.click(moreActions); const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); expect(deleteButton).toBeEnabled(); }); it('should be able to search', async () => { - renderWithProvider(); + const { user } = renderWithProvider(); const searchInput = await screen.findByRole('textbox', { name: 'search contact points' }); - await userEvent.type(searchInput, 'slack'); + await user.type(searchInput, 'slack'); expect(searchInput).toHaveValue('slack'); expect(await screen.findByText('Slack with multiple channels')).toBeInTheDocument(); @@ -254,7 +270,7 @@ describe('contact points', () => { // ⚠️ for some reason, the query params are preserved for all tests so don't forget to clear the input const clearButton = screen.getByRole('button', { name: 'clear' }); - await userEvent.click(clearButton); + await user.click(clearButton); expect(searchInput).toHaveValue(''); }); }); @@ -332,7 +348,7 @@ describe('contact points', () => { }); it("should not allow any editing because it's not supported", async () => { - renderWithProvider(, undefined, { + const { user } = renderWithProvider(, undefined, { alertmanagerSourceName: VANILLA_ALERTMANAGER_DATASOURCE_UID, }); @@ -347,8 +363,45 @@ describe('contact points', () => { // check buttons in Notification Templates const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' }); - await userEvent.click(notificationTemplatesTab); + await user.click(notificationTemplatesTab); expect(screen.queryByRole('link', { name: 'Add notification template' })).not.toBeInTheDocument(); }); }); + + describe('alertingApiServer enabled', () => { + beforeEach(() => { + config.featureToggles.alertingApiServer = true; + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + }); + + const renderGrafanaContactPoints = () => + renderWithProvider( + , + { initialEntries: ['/?tab=contact_points'] }, + { alertmanagerSourceName: GRAFANA_RULES_SOURCE_NAME } + ); + + it('renders list view correctly', async () => { + renderGrafanaContactPoints(); + // Check for a specific contact point that we expect to exist in the mock AM config/k8s response + expect(await screen.findByRole('heading', { name: 'lotsa-emails' })).toBeInTheDocument(); + }); + + it('allows deleting', async () => { + renderGrafanaContactPoints(); + + await attemptDeleteContactPoint('lotsa-emails'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('does not allow deletion of provisioned contact points', async () => { + renderGrafanaContactPoints(); + + return expect(attemptDeleteContactPoint('provisioned-contact-point')).rejects.toBeTruthy(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index 06ff3cd0ae6..3ffd22dd237 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -25,11 +25,10 @@ import { ContactPoint } from './ContactPoint'; import { NotificationTemplates } from './NotificationTemplates'; import { ContactPointsFilter } from './components/ContactPointsFilter'; import { GlobalConfigAlert } from './components/GlobalConfigAlert'; -import { useDeleteContactPointModal } from './components/Modals'; -import { useContactPointsWithStatus, useDeleteContactPoint } from './useContactPoints'; +import { useContactPointsWithStatus } from './useContactPoints'; import { useContactPointsSearch } from './useContactPointsSearch'; import { ALL_CONTACT_POINTS, useExportContactPoint } from './useExportContactPoint'; -import { ContactPointWithMetadata, isProvisioned } from './utils'; +import { ContactPointWithMetadata } from './utils'; export enum ActiveTab { ContactPoints = 'contact_points', @@ -48,7 +47,6 @@ const ContactPointsTab = () => { fetchStatuses: true, }); - const { deleteTrigger, updateAlertmanagerState } = useDeleteContactPoint(selectedAlertmanager!); const [addContactPointSupported, addContactPointAllowed] = useAlertmanagerAbility( AlertmanagerAction.CreateContactPoint ); @@ -56,7 +54,6 @@ const ContactPointsTab = () => { AlertmanagerAction.ExportContactPoint ); - const [DeleteModal, showDeleteModal] = useDeleteContactPointModal(deleteTrigger, updateAlertmanagerState.isLoading); const [ExportDrawer, showExportDrawer] = useExportContactPoint(); const search = queryParams.get('search'); @@ -102,16 +99,9 @@ const ContactPointsTab = () => { )} - showDeleteModal(name)} - disabled={updateAlertmanagerState.isLoading} - /> + {/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */} {!isGrafanaManagedAlertmanager && } - {DeleteModal} {ExportDrawer} ); @@ -204,7 +194,6 @@ interface ContactPointsListProps { contactPoints: ContactPointWithMetadata[]; search?: string | null; disabled?: boolean; - onDelete: (name: string) => void; pageSize?: number; } @@ -213,7 +202,6 @@ const ContactPointsList = ({ disabled = false, search, pageSize = DEFAULT_PAGE_SIZE, - onDelete, }: ContactPointsListProps) => { const searchResults = useContactPointsSearch(contactPoints, search); const { page, pageItems, numberOfPages, onPageChange } = usePagination(searchResults, 1, pageSize); @@ -221,21 +209,8 @@ const ContactPointsList = ({ return ( <> {pageItems.map((contactPoint, index) => { - const provisioned = isProvisioned(contactPoint); - const policies = contactPoint.policies ?? []; const key = `${contactPoint.name}-${index}`; - - return ( - - ); + return ; })} diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx index a21ec2c47eb..f85c3eeeef0 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx @@ -1,9 +1,9 @@ import { RouteChildrenProps } from 'react-router-dom'; -import { Alert } from '@grafana/ui'; -import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; +import { useGetContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; +import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; -import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { EditReceiverView } from '../receivers/EditReceiverView'; @@ -11,36 +11,35 @@ type Props = RouteChildrenProps<{ name: string }>; const EditContactPoint = ({ match }: Props) => { const { selectedAlertmanager } = useAlertmanager(); - const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); - const contactPointName = match?.params.name; - if (!contactPointName) { - return ; - } + const contactPointName = decodeURIComponent(match?.params.name!); + const { + isLoading, + error, + data: contactPoint, + } = useGetContactPoint({ name: contactPointName, alertmanager: selectedAlertmanager! }); - if (isLoading && !data) { - return 'loading...'; + if (isLoading) { + return ; } if (error) { return ( - {String(error)} + {stringifyErrorLike(error)} ); } - if (!data) { - return null; + if (!contactPoint) { + return ( + + {'Sorry, this contact point does not seem to exist.'} + + ); } - return ( - - ); + return ; }; export default EditContactPoint; diff --git a/public/app/features/alerting/unified/components/contact-points/NewContactPoint.test.tsx b/public/app/features/alerting/unified/components/contact-points/NewContactPoint.test.tsx deleted file mode 100644 index e9f8bc9820d..00000000000 --- a/public/app/features/alerting/unified/components/contact-points/NewContactPoint.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { render, screen, waitFor, userEvent } from 'test/test-utils'; -import { byLabelText, byPlaceholderText, byRole, byTestId } from 'testing-library-selector'; - -import { AccessControlAction } from 'app/types'; - -import { setupMswServer } from '../../mockApi'; -import { grantUserPermissions } from '../../mocks'; -import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; - -import NewContactPoint from './NewContactPoint'; -import { setupSaveEndpointMock, setupTestEndpointMock } from './__mocks__/grafanaManagedServer'; - -import 'core-js/stable/structured-clone'; - -const server = setupMswServer(); -const user = userEvent.setup(); - -beforeEach(() => { - grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]); -}); - -it('should be able to test and save a receiver', async () => { - const testMock = setupTestEndpointMock(server); - const saveMock = setupSaveEndpointMock(server); - - render( - - - - ); - - // wait for loading to be done - // type in a name for the new receiver - await user.type(await ui.inputs.name.find(), 'my new receiver'); - - // enter some email - const email = ui.inputs.email.addresses.get(); - await user.clear(email); - await user.type(email, 'tester@grafana.com'); - - // try to test the contact point - await user.click(await ui.testContactPointButton.find()); - - expect(await ui.testContactPointModal.find()).toBeInTheDocument(); - - await user.click(ui.customContactPointOption.get()); - - // enter custom annotations and labels - await user.type(screen.getByPlaceholderText('Enter a description...'), 'Test contact point'); - await user.type(ui.contactPointLabelKey(0).get(), 'foo'); - await user.type(ui.contactPointLabelValue(0).get(), 'bar'); - - // click test - await user.click(ui.testContactPoint.get()); - - // we shouldn't be testing implementation details but when the request is successful - // it can't seem to assert on the success toast - await waitFor(() => { - expect(testMock).toHaveBeenCalled(); - }); - expect(testMock.mock.lastCall).toMatchSnapshot(); - - await user.click(ui.saveContactButton.get()); - await waitFor(() => { - expect(saveMock).toHaveBeenCalled(); - }); - expect(saveMock.mock.lastCall).toMatchSnapshot(); -}); - -const ui = { - saveContactButton: byRole('button', { name: /save contact point/i }), - newContactPointIntegrationButton: byRole('button', { name: /add contact point integration/i }), - testContactPointButton: byRole('button', { name: /Test/ }), - testContactPointModal: byRole('heading', { name: /test contact point/i }), - customContactPointOption: byRole('radio', { name: /custom/i }), - contactPointAnnotationSelect: (idx: number) => byTestId(`annotation-key-${idx}`), - contactPointAnnotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), - contactPointLabelKey: (idx: number) => byTestId(`label-key-${idx}`), - contactPointLabelValue: (idx: number) => byTestId(`label-value-${idx}`), - testContactPoint: byRole('button', { name: /send test notification/i }), - cancelButton: byTestId('cancel-button'), - - channelFormContainer: byTestId('item-container'), - - inputs: { - name: byPlaceholderText('Name'), - email: { - addresses: byLabelText(/Addresses/), - toEmails: byLabelText(/To/), - }, - hipchat: { - url: byLabelText('Hip Chat Url'), - apiKey: byLabelText('API Key'), - }, - slack: { - webhookURL: byLabelText(/Webhook URL/i), - }, - webhook: { - URL: byLabelText(/The endpoint to send HTTP POST requests to/i), - }, - }, -}; diff --git a/public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx deleted file mode 100644 index 64e496c1fc0..00000000000 --- a/public/app/features/alerting/unified/components/contact-points/NewContactPoint.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Alert } from '@grafana/ui'; - -import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig'; -import { useAlertmanager } from '../../state/AlertmanagerContext'; -import { NewReceiverView } from '../receivers/NewReceiverView'; - -const NewContactPoint = () => { - const { selectedAlertmanager } = useAlertmanager(); - const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); - - if (isLoading && !data) { - return 'loading...'; - } - - if (error) { - return ( - - {String(error)} - - ); - } - - if (!data) { - return null; - } - - return ; -}; - -export default NewContactPoint; diff --git a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts b/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts deleted file mode 100644 index fdcd38efc47..00000000000 --- a/public/app/features/alerting/unified/components/contact-points/__mocks__/grafanaManagedServer.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { SetupServer } from 'msw/node'; - -/** @deprecated */ -export const setupTestEndpointMock = (server: SetupServer) => { - const mock = jest.fn(); - - server.use( - http.post( - '/api/alertmanager/grafana/config/api/v1/receivers/test', - async ({ request }) => { - const requestBody = await request.json(); - mock(requestBody); - - return HttpResponse.json({}); - }, - { - once: true, - } - ) - ); - - return mock; -}; - -/** @deprecated */ -export const setupSaveEndpointMock = (server: SetupServer) => { - const mock = jest.fn(); - - server.use( - http.post( - '/api/alertmanager/grafana/config/api/v1/alerts', - async ({ request }) => { - const requestBody = await request.json(); - mock(requestBody); - - return HttpResponse.json({}); - }, - { - once: true, - } - ) - ); - - return mock; -}; diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap index 445f90eb10e..14aa20dad9a 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap +++ b/public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap @@ -29,6 +29,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "grafana-default-email", "name": "grafana-default-email", "policies": [ { @@ -38,6 +39,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], + "provisioned": false, }, { "grafana_managed_receiver_configs": [ @@ -64,8 +66,10 @@ exports[`useContactPoints should return contact points with status 1`] = ` Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "lotsa-emails", "name": "lotsa-emails", "policies": [], + "provisioned": false, }, { "grafana_managed_receiver_configs": [ @@ -87,8 +91,10 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], + "id": "OnCall Conctact point", "name": "OnCall Conctact point", "policies": [], + "provisioned": false, }, { "grafana_managed_receiver_configs": [ @@ -116,6 +122,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "provisioned-contact-point", "name": "provisioned-contact-point", "policies": [ { @@ -125,6 +132,7 @@ exports[`useContactPoints should return contact points with status 1`] = ` }, }, ], + "provisioned": true, }, { "grafana_managed_receiver_configs": [ @@ -175,8 +183,10 @@ exports[`useContactPoints should return contact points with status 1`] = ` Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "Slack with multiple channels", "name": "Slack with multiple channels", "policies": [], + "provisioned": false, }, ], "error": undefined, @@ -213,6 +223,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "grafana-default-email", "name": "grafana-default-email", "policies": [ { @@ -222,6 +233,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], + "provisioned": false, }, { "grafana_managed_receiver_configs": [ @@ -248,8 +260,10 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "lotsa-emails", "name": "lotsa-emails", "policies": [], + "provisioned": false, }, { "grafana_managed_receiver_configs": [ @@ -274,8 +288,10 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], + "id": "OnCall Conctact point", "name": "OnCall Conctact point", "policies": [], + "provisioned": false, }, { "grafana_managed_receiver_configs": [ @@ -303,6 +319,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "provisioned-contact-point", "name": "provisioned-contact-point", "policies": [ { @@ -312,6 +329,7 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag }, }, ], + "provisioned": true, }, { "grafana_managed_receiver_configs": [ @@ -362,8 +380,10 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag Symbol(receiver_plugin_metadata): undefined, }, ], + "id": "Slack with multiple channels", "name": "Slack with multiple channels", "policies": [], + "provisioned": false, }, ], "error": undefined, diff --git a/public/app/features/alerting/unified/components/contact-points/components/Modals.tsx b/public/app/features/alerting/unified/components/contact-points/components/Modals.tsx index ca395fe9be8..d1d91762662 100644 --- a/public/app/features/alerting/unified/components/contact-points/components/Modals.tsx +++ b/public/app/features/alerting/unified/components/contact-points/components/Modals.tsx @@ -4,40 +4,41 @@ import { Button, Modal, ModalProps } from '@grafana/ui'; import { stringifyErrorLike } from '../../../utils/misc'; -type ModalHook = [JSX.Element, (item: T) => void, () => void]; - /** * This hook controls the delete modal for contact points, showing loading and error states when appropriate */ export const useDeleteContactPointModal = ( - handleDelete: (name: string) => Promise, - isLoading: boolean -): ModalHook => { + handleDelete: ({ name, resourceVersion }: { name: string; resourceVersion?: string }) => Promise +) => { const [showModal, setShowModal] = useState(false); - const [contactPoint, setContactPoint] = useState(); + const [contactPoint, setContactPoint] = useState<{ name: string; resourceVersion?: string }>(); const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); const handleDismiss = useCallback(() => { if (isLoading) { return; } - setContactPoint(undefined); setShowModal(false); setError(undefined); }, [isLoading]); - const handleShow = useCallback((name: string) => { - setContactPoint(name); + const handleShow = useCallback(({ name, resourceVersion }: { name: string; resourceVersion?: string }) => { + setContactPoint({ name, resourceVersion }); setShowModal(true); setError(undefined); }, []); const handleSubmit = useCallback(() => { if (contactPoint) { + setIsLoading(true); handleDelete(contactPoint) .then(() => setShowModal(false)) - .catch(setError); + .catch(setError) + .finally(() => { + setIsLoading(false); + }); } }, [handleDelete, contactPoint]); @@ -69,7 +70,7 @@ export const useDeleteContactPointModal = ( ); }, [error, handleDismiss, handleSubmit, isLoading, showModal]); - return [modalElement, handleShow, handleDismiss]; + return [modalElement, handleShow, handleDismiss] as const; }; interface ErrorModalProps extends Pick { @@ -84,10 +85,8 @@ const ErrorModal = ({ isOpen, onDismiss, error }: ErrorModalProps) => ( title={'Something went wrong'} >

                                                                          Failed to update your configuration:

                                                                          -

                                                                          -

                                                                          -        {stringifyErrorLike(error)}
                                                                          -      
                                                                          -

                                                                          +
                                                                          +      {stringifyErrorLike(error)}
                                                                          +    
                                                                          ); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx index e56c7fd44fb..c7dab986d29 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.test.tsx @@ -65,10 +65,32 @@ describe('useContactPoints', () => { expect(snapshot).toMatchSnapshot(); }); - it('returns matching responses with and without alertingApiServer', async () => { + it('returns ~matching responses with and without alertingApiServer', async () => { + // Compare the responses between the two implementations, but do not consider: + // - ID: k8s API will return id properties, but the AM config will fall back to the name of the contact point. + // These will be different, so we don't want to compare them + // - Metadata: k8s API includes metadata, AM config does not + const snapshotAmConfig = await getHookResponse(false); const snapshotAlertingApiServer = await getHookResponse(true); - expect(snapshotAmConfig).toEqual(snapshotAlertingApiServer); + + const amContactPoints = snapshotAmConfig.contactPoints.map((receiver) => { + const { id, ...rest } = receiver; + return rest; + }); + + const k8sContactPoints = snapshotAlertingApiServer.contactPoints.map((receiver) => { + const { id, metadata, ...rest } = receiver; + return rest; + }); + + expect({ + ...snapshotAmConfig, + contactPoints: amContactPoints, + }).toEqual({ + ...snapshotAlertingApiServer, + contactPoints: k8sContactPoints, + }); }); describe('when having oncall plugin installed and no alert manager config data', () => { diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx index ce95cda1c65..ef7bb63bbff 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx @@ -7,14 +7,29 @@ import { produce } from 'immer'; import { remove } from 'lodash'; import { useMemo } from 'react'; +import { alertingApi } from 'app/features/alerting/unified/api/alertingApi'; +import { receiversApi } from 'app/features/alerting/unified/api/receiversK8sApi'; +import { useOnCallIntegration } from 'app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver, generatedReceiversApi, } from 'app/features/alerting/unified/openapi/receiversApi.gen'; +import { updateAlertManagerConfigAction } from 'app/features/alerting/unified/state/actions'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { getK8sNamespace, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; +import { + getK8sNamespace, + isK8sEntityProvisioned, + shouldUseK8sApi, +} from 'app/features/alerting/unified/utils/k8s/utils'; +import { updateConfigWithReceiver } from 'app/features/alerting/unified/utils/receiver-form'; +import { + GrafanaManagedContactPoint, + GrafanaManagedReceiverConfig, + Receiver, +} from 'app/plugins/datasource/alertmanager/types'; +import { useDispatch } from 'app/types'; import { alertmanagerApi } from '../../api/alertmanagerApi'; import { onCallApi } from '../../api/onCallApi'; @@ -42,7 +57,13 @@ const { useUpdateAlertmanagerConfigurationMutation, } = alertmanagerApi; const { useGrafanaOnCallIntegrationsQuery } = onCallApi; -const { useListNamespacedReceiverQuery } = generatedReceiversApi; +const { + useListNamespacedReceiverQuery, + useReadNamespacedReceiverQuery, + useDeleteNamespacedReceiverMutation, + useCreateNamespacedReceiverMutation, + useReplaceNamespacedReceiverMutation, +} = receiversApi; const defaultOptions = { refetchOnFocus: true, @@ -71,19 +92,26 @@ const useOnCallIntegrations = ({ skip }: Skippable = {}) => { type K8sReceiver = ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; -// TODO: Make this typed as returning `GrafanaManagedContactPoint` - we can't yet do this as the schema thinks -// its returning integration settings as a `string` rather than `Record` -const parseK8sReceiver = (item: K8sReceiver) => { - return { name: item.spec.title, grafana_managed_receiver_configs: item.spec.integrations }; +const parseK8sReceiver = (item: K8sReceiver): GrafanaManagedContactPoint => { + return { + id: item.metadata.uid || item.spec.title, + name: item.spec.title, + provisioned: isK8sEntityProvisioned(item), + grafana_managed_receiver_configs: item.spec.integrations, + metadata: item.metadata, + }; }; const useK8sContactPoints = (...[hookParams, queryOptions]: Parameters) => { return useListNamespacedReceiverQuery(hookParams, { ...queryOptions, - selectFromResult: ({ data, ...rest }) => { + selectFromResult: (result) => { + const data = result.data?.items.map((item) => parseK8sReceiver(item)); + return { - ...rest, - data: data?.items.map((item) => parseK8sReceiver(item)), + ...result, + data, + currentData: data, }; }, }); @@ -97,7 +125,21 @@ const useFetchGrafanaContactPoints = ({ skip }: Skippable = {}) => { const namespace = getK8sNamespace(); const useK8sApi = shouldUseK8sApi(GRAFANA_RULES_SOURCE_NAME); - const grafanaResponse = useGetContactPointsListQuery(undefined, { skip: skip || useK8sApi }); + const grafanaResponse = useGetContactPointsListQuery(undefined, { + skip: skip || useK8sApi, + selectFromResult: (result) => { + const data = result.data?.map((item) => ({ + ...item, + provisioned: item.grafana_managed_receiver_configs?.some((item) => item.provenance), + })); + + return { + ...result, + data, + currentData: data, + }; + }, + }); const k8sResponse = useK8sContactPoints({ namespace }, { skip: skip || !useK8sApi }); return useK8sApi ? k8sResponse : grafanaResponse; @@ -115,7 +157,7 @@ type GrafanaFetchOptions = { }; /** - * Fetch contact points from separate endpoint (i.e. not the Alertmanager config) and combine with + * Fetch list of contact points from separate endpoint (i.e. not the Alertmanager config) and combine with * OnCall integrations and any additional metadata from list of notifiers * (e.g. hydrate with additional names/descriptions) */ @@ -173,6 +215,71 @@ export const useGrafanaContactPoints = ({ ]); }; +/** + * Fetch single contact point via the alertmanager config + */ +const useGetAlertmanagerContactPoint = ( + { alertmanager, name }: BaseAlertmanagerArgs & { name: string }, + queryOptions?: Parameters[1] +) => { + return useGetAlertmanagerConfigurationQuery(alertmanager, { + ...queryOptions, + selectFromResult: (result) => { + const matchedContactPoint = result.data?.alertmanager_config.receivers?.find( + (receiver) => receiver.name === name + ); + return { + ...result, + data: matchedContactPoint, + currentData: matchedContactPoint, + }; + }, + }); +}; + +/** + * Fetch single contact point via the k8s API, or the alertmanager config + */ +const useGetGrafanaContactPoint = ( + { name }: { name: string }, + queryOptions?: Parameters[1] +) => { + const namespace = getK8sNamespace(); + const useK8sApi = shouldUseK8sApi(GRAFANA_RULES_SOURCE_NAME); + + const k8sResponse = useReadNamespacedReceiverQuery( + { namespace, name }, + { + ...queryOptions, + selectFromResult: (result) => { + const data = result.data ? parseK8sReceiver(result.data) : undefined; + return { + ...result, + data, + currentData: data, + }; + }, + skip: queryOptions?.skip || !useK8sApi, + } + ); + + const grafanaResponse = useGetAlertmanagerContactPoint( + { alertmanager: GRAFANA_RULES_SOURCE_NAME, name }, + { skip: queryOptions?.skip || useK8sApi } + ); + + return useK8sApi ? k8sResponse : grafanaResponse; +}; + +export const useGetContactPoint = ({ alertmanager, name }: { alertmanager: string; name: string }) => { + const isGrafana = alertmanager === GRAFANA_RULES_SOURCE_NAME; + + const grafanaResponse = useGetGrafanaContactPoint({ name }, { skip: !isGrafana }); + const alertmanagerResponse = useGetAlertmanagerContactPoint({ alertmanager, name }, { skip: isGrafana }); + + return isGrafana ? grafanaResponse : alertmanagerResponse; +}; + export function useContactPointsWithStatus({ alertmanager, fetchStatuses, @@ -203,30 +310,184 @@ export function useContactPointsWithStatus({ return isGrafanaAlertmanager ? grafanaResponse : alertmanagerConfigResponse; } -export function useDeleteContactPoint(selectedAlertmanager: string) { +export function useDeleteContactPoint({ alertmanager }: BaseAlertmanagerArgs) { const [fetchAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery(); - const [updateAlertManager, updateAlertmanagerState] = useUpdateAlertmanagerConfigurationMutation(); + const [updateAlertManager] = useUpdateAlertmanagerConfigurationMutation(); + const [deleteReceiver] = useDeleteNamespacedReceiverMutation(); - const deleteTrigger = (contactPointName: string) => { - return fetchAlertmanagerConfig(selectedAlertmanager).then(({ data }) => { - if (!data) { - return; - } + const useK8sApi = shouldUseK8sApi(alertmanager); - const newConfig = produce(data, (draft) => { - remove(draft?.alertmanager_config?.receivers ?? [], (receiver) => receiver.name === contactPointName); - return draft; - }); - - return updateAlertManager({ - selectedAlertmanager, - config: newConfig, + return async ({ name, resourceVersion }: { name: string; resourceVersion?: string }) => { + if (useK8sApi) { + const namespace = getK8sNamespace(); + return deleteReceiver({ + name, + namespace, + ioK8SApimachineryPkgApisMetaV1DeleteOptions: { preconditions: { resourceVersion } }, }).unwrap(); - }); - }; + } + const config = await fetchAlertmanagerConfig(alertmanager).unwrap(); - return { - deleteTrigger, - updateAlertmanagerState, + const newConfig = produce(config, (draft) => { + remove(draft?.alertmanager_config?.receivers ?? [], (receiver) => receiver.name === name); + return draft; + }); + + return updateAlertManager({ + selectedAlertmanager: alertmanager, + config: newConfig, + }).unwrap(); }; } + +const mapIntegrationSettings = (integration: GrafanaManagedReceiverConfig): GrafanaManagedReceiverConfig => { + return { + ...integration, + settings: { ...integration.settings, ...integration.secureSettings }, + }; +}; +const grafanaContactPointToK8sReceiver = ( + contactPoint: GrafanaManagedContactPoint, + id?: string, + resourceVersion?: string +): K8sReceiver => { + return { + metadata: { + ...(id && { name: id }), + resourceVersion, + }, + spec: { + title: contactPoint.name, + integrations: (contactPoint.grafana_managed_receiver_configs || []).map(mapIntegrationSettings), + }, + }; +}; + +type ContactPointOperationArgs = { + contactPoint: Receiver; +}; + +type CreateContactPointArgs = ContactPointOperationArgs; +type UpdateContactPointArgs = ContactPointOperationArgs & { + /** ID of existing contact point to update - used when updating via k8s API */ + id?: string; + resourceVersion?: string; + /** Name of the existing contact point - used for checking uniqueness of name when not using k8s API*/ + originalName?: string; +}; + +export const useCreateContactPoint = ({ alertmanager }: BaseAlertmanagerArgs) => { + const isGrafanaAlertmanager = alertmanager === GRAFANA_RULES_SOURCE_NAME; + const useK8sApi = shouldUseK8sApi(alertmanager); + + const { createOnCallIntegrations } = useOnCallIntegration(); + const [getAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery(); + const [createGrafanaContactPoint] = useCreateNamespacedReceiverMutation(); + const dispatch = useDispatch(); + + return async ({ contactPoint }: CreateContactPointArgs) => { + const receiverWithPotentialOnCall = isGrafanaAlertmanager + ? await createOnCallIntegrations(contactPoint) + : contactPoint; + + if (useK8sApi) { + const namespace = getK8sNamespace(); + + const contactPointToUse = grafanaContactPointToK8sReceiver(receiverWithPotentialOnCall); + + return createGrafanaContactPoint({ + namespace, + comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver: contactPointToUse, + }).unwrap(); + } + + const config = await getAlertmanagerConfig(alertmanager).unwrap(); + const newConfig = updateConfigWithReceiver(config, receiverWithPotentialOnCall); + + return await dispatch( + updateAlertManagerConfigAction({ + newConfig: newConfig, + oldConfig: config, + alertManagerSourceName: alertmanager, + }) + ) + .unwrap() + .then(() => { + dispatch(alertingApi.util.invalidateTags(['AlertmanagerConfiguration', 'ContactPoint', 'ContactPointsStatus'])); + dispatch(generatedReceiversApi.util.invalidateTags(['Receiver'])); + }); + }; +}; + +export const useUpdateContactPoint = ({ alertmanager }: BaseAlertmanagerArgs) => { + const isGrafanaAlertmanager = alertmanager === GRAFANA_RULES_SOURCE_NAME; + const useK8sApi = shouldUseK8sApi(alertmanager); + + const { createOnCallIntegrations } = useOnCallIntegration(); + const [getAlertmanagerConfig] = useLazyGetAlertmanagerConfigurationQuery(); + const [replaceGrafanaContactPoint] = useReplaceNamespacedReceiverMutation(); + + const dispatch = useDispatch(); + + return async ({ contactPoint, id, originalName, resourceVersion }: UpdateContactPointArgs) => { + const receiverWithPotentialOnCall = isGrafanaAlertmanager + ? await createOnCallIntegrations(contactPoint) + : contactPoint; + + if (useK8sApi && id) { + const namespace = getK8sNamespace(); + + const contactPointToUse = grafanaContactPointToK8sReceiver(receiverWithPotentialOnCall, id, resourceVersion); + + return replaceGrafanaContactPoint({ + name: id, + namespace, + comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver: contactPointToUse, + }).unwrap(); + } + + const config = await getAlertmanagerConfig(alertmanager).unwrap(); + const newConfig = updateConfigWithReceiver(config, receiverWithPotentialOnCall, originalName); + + return dispatch( + updateAlertManagerConfigAction({ + newConfig: newConfig, + oldConfig: config, + alertManagerSourceName: alertmanager, + }) + ) + .unwrap() + .then(() => { + dispatch(alertingApi.util.invalidateTags(['AlertmanagerConfiguration', 'ContactPoint', 'ContactPointsStatus'])); + dispatch(generatedReceiversApi.util.invalidateTags(['Receiver'])); + }); + }; +}; + +export const useValidateContactPoint = ({ alertmanager }: BaseAlertmanagerArgs) => { + const useK8sApi = shouldUseK8sApi(alertmanager); + + const [getConfig] = useLazyGetAlertmanagerConfigurationQuery(); + + // If we're using the kubernetes API, then we let the API response handle the validation instead + // as we don't expect to be able to fetch the intervals via the AM config + if (useK8sApi) { + return () => undefined; + } + + return async (value: string, existingValue?: string) => { + // If we've been given an existing value, and the name has not changed, + // we can skip validation + // (as we don't want to incorrectly flag the existing name as matching itself) + if (existingValue && value === existingValue) { + return; + } + return getConfig(alertmanager) + .unwrap() + .then((config) => { + const { alertmanager_config } = config; + const duplicated = Boolean(alertmanager_config.receivers?.find((contactPoint) => contactPoint.name === value)); + return duplicated ? `Contact point already exists with name "${value}"` : undefined; + }); + }; +}; diff --git a/public/app/features/alerting/unified/components/contact-points/utils.test.ts b/public/app/features/alerting/unified/components/contact-points/utils.test.ts index bd25cb886ee..e8ded92bf74 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.test.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.test.ts @@ -1,5 +1,3 @@ -import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types'; - import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from './constants'; @@ -7,34 +5,9 @@ import { ReceiverConfigWithMetadata, getReceiverDescription, isAutoGeneratedPolicy, - isProvisioned, summarizeEmailAddresses, } from './utils'; -describe('isProvisioned', () => { - it('should return true when at least one receiver is provisioned', () => { - const contactPoint: GrafanaManagedContactPoint = { - name: 'my-contact-point', - grafana_managed_receiver_configs: [ - { name: 'email', provenance: 'api', type: 'email', disableResolveMessage: false, settings: {} }, - ], - }; - - expect(isProvisioned(contactPoint)).toBe(true); - }); - - it('should return false when no receiver was provisioned', () => { - const contactPoint: GrafanaManagedContactPoint = { - name: 'my-contact-point', - grafana_managed_receiver_configs: [ - { name: 'email', provenance: undefined, type: 'email', disableResolveMessage: false, settings: {} }, - ], - }; - - expect(isProvisioned(contactPoint)).toBe(false); - }); -}); - describe('isAutoGeneratedPolicy', () => { it('should return false when not enabled', () => { expect(isAutoGeneratedPolicy({})).toBe(false); diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 566e1f34763..7288d9785cd 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -22,13 +22,6 @@ import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY, RECEIVER_STATUS_KEY } from const AUTOGENERATED_RECEIVER_POLICY_MATCHER_KEY = '__grafana_receiver__'; -export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { - // for some reason the provenance is on the receiver and not the entire contact point - const provenance = contactPoint.grafana_managed_receiver_configs?.find((receiver) => receiver.provenance)?.provenance; - - return Boolean(provenance); -} - // TODO we should really add some type information to these receiver settings... export function getReceiverDescription(receiver: ReceiverConfigWithMetadata): ReactNode | undefined { if (!receiver.settings) { @@ -98,6 +91,7 @@ export interface ReceiverConfigWithMetadata extends GrafanaManagedReceiverConfig } export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { + id: string; policies?: RouteReference[]; // now is optional as we don't have the data from the read-only endpoint grafana_managed_receiver_configs: ReceiverConfigWithMetadata[]; } @@ -134,8 +128,11 @@ export function enhanceContactPointsWithMetadata({ const receivers = extractReceivers(contactPoint); const statusForReceiver = status.find((status) => status.name === contactPoint.name); + const id = 'id' in contactPoint && contactPoint.id ? contactPoint.id : contactPoint.name; + return { ...contactPoint, + id, policies: alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? []), grafana_managed_receiver_configs: receivers.map((receiver, index) => { diff --git a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx index 6101e5943a4..377d331b0cf 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx @@ -9,8 +9,12 @@ import { } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; -import { getK8sNamespace, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; +import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { + getK8sNamespace, + isK8sEntityProvisioned, + shouldUseK8sApi, +} from 'app/features/alerting/unified/utils/k8s/utils'; import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; import { useAsync } from '../../hooks/useAsync'; @@ -48,7 +52,7 @@ const parseK8sTimeInterval: (item: TimeIntervalV0Alpha1) => MuteTiming = (item) ...spec, id: spec.name, metadata, - provisioned: metadata.annotations?.[PROVENANCE_ANNOTATION] !== PROVENANCE_NONE, + provisioned: isK8sEntityProvisioned(item), }; }; diff --git a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx index 636e2944cbb..89bccca70b5 100644 --- a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx +++ b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx @@ -1,5 +1,4 @@ -import { Alert } from '@grafana/ui'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -8,41 +7,25 @@ import { CloudReceiverForm } from './form/CloudReceiverForm'; import { GrafanaReceiverForm } from './form/GrafanaReceiverForm'; interface Props { - receiverName: string; - config: AlertManagerCortexConfig; - alertManagerSourceName: string; + alertmanagerName: string; + contactPoint: GrafanaManagedContactPoint | Receiver; } -export const EditReceiverView = ({ config, receiverName, alertManagerSourceName }: Props) => { +export const EditReceiverView = ({ contactPoint, alertmanagerName }: Props) => { const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint); - const receiver = config.alertmanager_config.receivers?.find(({ name }) => name === receiverName); - if (!receiver) { - return ( - - Sorry, this receiver does not seem to exist. - - ); - } - const readOnly = !editSupported || !editAllowed; - if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) { - return ( - - ); + if (alertmanagerName === GRAFANA_RULES_SOURCE_NAME) { + console.log(contactPoint); + return ; } else { return ( ); } diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx new file mode 100644 index 00000000000..fa6c20423ba --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx @@ -0,0 +1,141 @@ +import 'core-js/stable/structured-clone'; +import { Route } from 'react-router'; +import { render, screen } from 'test/test-utils'; +import { byLabelText, byPlaceholderText, byRole, byTestId } from 'testing-library-selector'; + +import { config } from '@grafana/runtime'; +import { makeGrafanaAlertmanagerConfigUpdateFail } from 'app/features/alerting/unified/mocks/server/configure'; +import { captureRequests } from 'app/features/alerting/unified/mocks/server/events'; +import { AccessControlAction } from 'app/types'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; + +import NewReceiverView from './NewReceiverView'; + +setupMswServer(); + +const renderForm = () => + render( + + + + + + redirected + + , + { + historyOptions: { initialEntries: ['/alerting/notifications/new'] }, + } + ); + +beforeEach(() => { + grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]); +}); + +describe('alerting API server enabled', () => { + beforeEach(() => { + config.featureToggles.alertingApiServer = true; + }); + + it('can create a receiver', async () => { + const { user } = renderForm(); + + expect(await ui.inputs.name.find()).toBeEnabled(); + + await user.type(await ui.inputs.name.find(), 'my new receiver'); + + // enter some email + const email = ui.inputs.email.addresses.get(); + await user.clear(email); + await user.type(email, 'tester@grafana.com'); + + await user.click(ui.saveContactButton.get()); + + expect(await screen.findByText(/redirected/i)).toBeInTheDocument(); + }); +}); + +describe('alerting API server disabled', () => { + beforeEach(() => { + config.featureToggles.alertingApiServer = false; + }); + it('should be able to test and save a receiver', async () => { + const capture = captureRequests(); + + const { user } = renderForm(); + + // wait for loading to be done + // type in a name for the new receiver + await user.type(await ui.inputs.name.find(), 'my new receiver'); + + // enter some email + const email = ui.inputs.email.addresses.get(); + await user.clear(email); + await user.type(email, 'tester@grafana.com'); + + // try to test the contact point + await user.click(await ui.testContactPointButton.find()); + + expect(await ui.testContactPointModal.find()).toBeInTheDocument(); + + await user.click(ui.customContactPointOption.get()); + + // enter custom annotations and labels + await user.type(screen.getByPlaceholderText('Enter a description...'), 'Test contact point'); + await user.type(ui.contactPointLabelKey(0).get(), 'foo'); + await user.type(ui.contactPointLabelValue(0).get(), 'bar'); + + // click test + await user.click(ui.testContactPoint.get()); + + // we shouldn't be testing implementation details but when the request is successful + // it can't seem to assert on the success toast + await user.click(ui.saveContactButton.get()); + + const requests = await capture; + const testRequest = requests.find((r) => r.url.endsWith('/config/api/v1/receivers/test')); + const saveRequest = requests.find( + (r) => r.url.endsWith('/api/alertmanager/grafana/config/api/v1/alerts') && r.method === 'POST' + ); + + const testBody = await testRequest?.json(); + const saveBody = await saveRequest?.json(); + + expect([testBody]).toMatchSnapshot(); + expect([saveBody]).toMatchSnapshot(); + }); + + it('does not redirect when creating contact point and API errors', async () => { + makeGrafanaAlertmanagerConfigUpdateFail(); + const { user } = renderForm(); + + await user.type(await ui.inputs.name.find(), 'receiver that should fail'); + const email = ui.inputs.email.addresses.get(); + await user.clear(email); + await user.type(email, 'tester@grafana.com'); + + await user.click(ui.saveContactButton.get()); + + expect(screen.queryByText(/redirected/i)).not.toBeInTheDocument(); + }); +}); + +const ui = { + saveContactButton: byRole('button', { name: /save contact point/i }), + + testContactPointButton: byRole('button', { name: /Test/ }), + testContactPointModal: byRole('heading', { name: /test contact point/i }), + customContactPointOption: byRole('radio', { name: /custom/i }), + contactPointLabelKey: (idx: number) => byTestId(`label-key-${idx}`), + contactPointLabelValue: (idx: number) => byTestId(`label-value-${idx}`), + testContactPoint: byRole('button', { name: /send test notification/i }), + inputs: { + name: byPlaceholderText('Name'), + email: { + addresses: byLabelText(/Addresses/), + }, + }, +}; diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx index 5941336fe35..c84a9492d26 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx @@ -1,19 +1,17 @@ -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { CloudReceiverForm } from './form/CloudReceiverForm'; import { GrafanaReceiverForm } from './form/GrafanaReceiverForm'; -interface Props { - config: AlertManagerCortexConfig; - alertManagerSourceName: string; -} - -export const NewReceiverView = ({ alertManagerSourceName, config }: Props) => { - if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME) { - return ; +const NewReceiverView = () => { + const { selectedAlertmanager } = useAlertmanager(); + if (selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME) { + return ; } else { - return ; + return ; } }; + +export default NewReceiverView; diff --git a/public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap b/public/app/features/alerting/unified/components/receivers/__snapshots__/NewReceiverView.test.tsx.snap similarity index 96% rename from public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap rename to public/app/features/alerting/unified/components/receivers/__snapshots__/NewReceiverView.test.tsx.snap index db54b874b61..999e5aaf80b 100644 --- a/public/app/features/alerting/unified/components/contact-points/__snapshots__/NewContactPoint.test.tsx.snap +++ b/public/app/features/alerting/unified/components/receivers/__snapshots__/NewReceiverView.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should be able to test and save a receiver 1`] = ` +exports[`alerting API server disabled should be able to test and save a receiver 1`] = ` [ { "alert": { @@ -32,7 +32,7 @@ exports[`should be able to test and save a receiver 1`] = ` ] `; -exports[`should be able to test and save a receiver 2`] = ` +exports[`alerting API server disabled should be able to test and save a receiver 2`] = ` [ { "alertmanager_config": { diff --git a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx index cfbdafcc8b7..2809617aac3 100644 --- a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx @@ -1,19 +1,18 @@ import { useMemo } from 'react'; +import { locationService } from '@grafana/runtime'; import { Alert } from '@grafana/ui'; -import { AlertManagerCortexConfig, Receiver } from 'app/plugins/datasource/alertmanager/types'; -import { useDispatch } from 'app/types'; +import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi'; +import { + useCreateContactPoint, + useUpdateContactPoint, +} from 'app/features/alerting/unified/components/contact-points/useContactPoints'; +import { Receiver } from 'app/plugins/datasource/alertmanager/types'; -import { alertmanagerApi } from '../../../api/alertmanagerApi'; -import { updateAlertManagerConfigAction } from '../../../state/actions'; import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form'; import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types'; import { isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource'; -import { - cloudReceiverToFormValues, - formValuesToCloudReceiver, - updateConfigWithReceiver, -} from '../../../utils/receiver-form'; +import { cloudReceiverToFormValues, formValuesToCloudReceiver } from '../../../utils/receiver-form'; import { CloudCommonChannelSettings } from './CloudCommonChannelSettings'; import { ReceiverForm } from './ReceiverForm'; @@ -21,9 +20,9 @@ import { Notifier } from './notifiers'; interface Props { alertManagerSourceName: string; - config: AlertManagerCortexConfig; - existing?: Receiver; + contactPoint?: Receiver; readOnly?: boolean; + editMode?: boolean; } const defaultChannelValues: CloudChannelValues = Object.freeze({ @@ -36,39 +35,37 @@ const defaultChannelValues: CloudChannelValues = Object.freeze({ }); const cloudNotifiers = cloudNotifierTypes.map((n) => ({ dto: n })); +const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi; + +export const CloudReceiverForm = ({ contactPoint, alertManagerSourceName, readOnly = false, editMode }: Props) => { + const { isLoading, data: config } = useGetAlertmanagerConfigurationQuery(alertManagerSourceName); -export const CloudReceiverForm = ({ existing, alertManagerSourceName, config, readOnly = false }: Props) => { - const dispatch = useDispatch(); const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName); + const createContactPoint = useCreateContactPoint({ alertmanager: alertManagerSourceName }); + const updateContactPoint = useUpdateContactPoint({ alertmanager: alertManagerSourceName }); // transform receiver DTO to form values const [existingValue] = useMemo((): [ReceiverFormValues | undefined, CloudChannelMap] => { - if (!existing) { + if (!contactPoint) { return [undefined, {}]; } - return cloudReceiverToFormValues(existing, cloudNotifierTypes); - }, [existing]); + return cloudReceiverToFormValues(contactPoint, cloudNotifierTypes); + }, [contactPoint]); const onSubmit = async (values: ReceiverFormValues) => { const newReceiver = formValuesToCloudReceiver(values, defaultChannelValues); - await dispatch( - updateAlertManagerConfigAction({ - newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name), - oldConfig: config, - alertManagerSourceName, - successMessage: existing ? 'Contact point updated.' : 'Contact point created.', - redirectPath: '/alerting/notifications', - }) - ).then(() => { - dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration'])); - }); + try { + if (editMode) { + await updateContactPoint({ contactPoint: newReceiver, originalName: contactPoint!.name }); + } else { + await createContactPoint({ contactPoint: newReceiver }); + } + locationService.push('/alerting/notifications'); + } catch (error) { + // React form validation will handle this for us + } }; - const takenReceiverNames = useMemo( - () => config.alertmanager_config.receivers?.map(({ name }) => name).filter((name) => name !== existing?.name) ?? [], - [config, existing] - ); - // this basically checks if we can manage the selected alert manager data source, either because it's a Grafana Managed one // or a Mimir-based AlertManager const isManageableAlertManagerDataSource = @@ -82,15 +79,14 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config, re )} + showDefaultRouteWarning={!isLoading && !config?.alertmanager_config.route} isEditable={isManageableAlertManagerDataSource} isTestable={isManageableAlertManagerDataSource} - config={config} onSubmit={onSubmit} initialValues={existingValue} notifiers={cloudNotifiers} alertManagerSourceName={alertManagerSourceName} defaultItem={defaultChannelValues} - takenReceiverNames={takenReceiverNames} commonSettingsComponent={CloudCommonChannelSettings} /> diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx index f4832592872..9cbf672221e 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx @@ -1,5 +1,5 @@ import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { render, waitFor, userEvent } from 'test/test-utils'; +import { render, waitFor } from 'test/test-utils'; import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; import { disablePlugin } from 'app/features/alerting/unified/mocks/server/configure'; @@ -8,11 +8,9 @@ import { setOnCallIntegrations, } from 'app/features/alerting/unified/mocks/server/handlers/plugins/configure-plugins'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; -import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerConfigBuilder, setupMswServer } from '../../../mockApi'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { GrafanaReceiverForm } from './GrafanaReceiverForm'; @@ -35,17 +33,11 @@ const ui = { }; describe('GrafanaReceiverForm', () => { - beforeEach(() => { - clearPluginSettingsCache(); - }); - describe('OnCall contact point', () => { it('OnCall contact point should be disabled if OnCall integration is not enabled', async () => { disablePlugin(SupportedPlugin.OnCall); - const amConfig = getAmCortexConfig((_) => {}); - - render(); + render(); await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); @@ -65,11 +57,7 @@ describe('GrafanaReceiverForm', () => { { display_name: 'apac-oncall', value: 'apac-oncall', integration_url: 'https://apac.oncall.example.com' }, ]); - const amConfig = getAmCortexConfig((_) => {}); - - const user = userEvent.setup(); - - render(); + const { user } = render(); await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); @@ -121,13 +109,7 @@ describe('GrafanaReceiverForm', () => { ) ); - render( - - ); + render(); await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index 32e0652827e..ade39241b34 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -1,8 +1,14 @@ import { useMemo, useState } from 'react'; +import { locationService } from '@grafana/runtime'; import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { - AlertManagerCortexConfig, + useCreateContactPoint, + useUpdateContactPoint, +} from 'app/features/alerting/unified/components/contact-points/useContactPoints'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; +import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, TestReceiversAlert, @@ -10,14 +16,12 @@ import { import { useDispatch } from 'app/types'; import { alertmanagerApi } from '../../../api/alertmanagerApi'; -import { testReceiversAction, updateAlertManagerConfigAction } from '../../../state/actions'; +import { testReceiversAction } from '../../../state/actions'; import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { formChannelValuesToGrafanaChannelConfig, formValuesToGrafanaReceiver, grafanaReceiverToFormValues, - updateConfigWithReceiver, } from '../../../utils/receiver-form'; import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall'; @@ -28,13 +32,6 @@ import { ReceiverForm } from './ReceiverForm'; import { TestContactPointModal } from './TestContactPointModal'; import { Notifier } from './notifiers'; -interface Props { - alertManagerSourceName: string; - config: AlertManagerCortexConfig; - existing?: GrafanaManagedContactPoint; - readOnly?: boolean; -} - const defaultChannelValues: GrafanaChannelValues = Object.freeze({ __id: '', secureSettings: {}, @@ -44,20 +41,28 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({ type: 'email', }); -export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, readOnly = false }: Props) => { +interface Props { + contactPoint?: GrafanaManagedContactPoint; + readOnly?: boolean; + editMode?: boolean; +} + +const { useGrafanaNotifiersQuery } = alertmanagerApi; + +export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }: Props) => { const dispatch = useDispatch(); + const createContactPoint = useCreateContactPoint({ alertmanager: GRAFANA_RULES_SOURCE_NAME }); + const updateContactPoint = useUpdateContactPoint({ alertmanager: GRAFANA_RULES_SOURCE_NAME }); const { onCallNotifierMeta, extendOnCallNotifierFeatures, extendOnCallReceivers, onCallFormValidators, - createOnCallIntegrations, isLoadingOnCallIntegration, hasOnCallError, } = useOnCallIntegration(); - const { useGrafanaNotifiersQuery } = alertmanagerApi; const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery(); const [testChannelValues, setTestChannelValues] = useState(); @@ -67,29 +72,30 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, ReceiverFormValues | undefined, Record, ] => { - if (!existing || isLoadingNotifiers || isLoadingOnCallIntegration) { + if (!contactPoint || isLoadingNotifiers || isLoadingOnCallIntegration) { return [undefined, {}]; } - return grafanaReceiverToFormValues(extendOnCallReceivers(existing), grafanaNotifiers); - }, [existing, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]); + return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint), grafanaNotifiers); + }, [contactPoint, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]); const onSubmit = async (values: ReceiverFormValues) => { const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, grafanaNotifiers); - const receiverWithOnCall = await createOnCallIntegrations(newReceiver); - - const newConfig = updateConfigWithReceiver(config, receiverWithOnCall, existing?.name); - await dispatch( - updateAlertManagerConfigAction({ - newConfig: newConfig, - oldConfig: config, - alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME, - successMessage: existing ? 'Contact point updated.' : 'Contact point created', - redirectPath: '/alerting/notifications', - }) - ).then(() => { - dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration'])); - }); + try { + if (editMode) { + await updateContactPoint({ + contactPoint: newReceiver, + id: contactPoint!.id, + resourceVersion: contactPoint?.metadata?.resourceVersion, + originalName: contactPoint?.name, + }); + } else { + await createContactPoint({ contactPoint: newReceiver }); + } + locationService.push('/alerting/notifications'); + } catch (error) { + // React form validation will handle this for us + } }; const onTestChannel = (values: GrafanaChannelValues) => { @@ -102,7 +108,7 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, const chan = formChannelValuesToGrafanaChannelConfig(testChannelValues, defaultChannelValues, 'test', existing); const payload = { - alertManagerSourceName, + alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME, receivers: [ { name: 'test', @@ -116,17 +122,7 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, } }; - const takenReceiverNames = useMemo( - () => config.alertmanager_config.receivers?.map(({ name }) => name).filter((name) => name !== existing?.name) ?? [], - [config, existing] - ); - - // if any receivers in the contact point have a "provenance", the entire contact point should be readOnly - const hasProvisionedItems = existing - ? (existing.grafana_managed_receiver_configs ?? []).some((item) => Boolean(item.provenance)) - : false; - - const isEditable = !readOnly && !hasProvisionedItems; + const isEditable = !readOnly && !contactPoint?.provisioned; const isTestable = !readOnly; if (isLoadingNotifiers || isLoadingOnCallIntegration) { @@ -134,7 +130,7 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, } const notifiers: Notifier[] = grafanaNotifiers.map((n) => { - if (n.type === 'oncall') { + if (n.type === ReceiverTypes.OnCall) { return { dto: extendOnCallNotifierFeatures(n), meta: onCallNotifierMeta, @@ -143,7 +139,7 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, return { dto: n }; }); - + const disableEditTitle = editMode && shouldUseK8sApi(GRAFANA_RULES_SOURCE_NAME); return ( <> {hasOnCallError && ( @@ -153,19 +149,18 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config, )} - {hasProvisionedItems && } + {contactPoint?.provisioned && } + disableEditTitle={disableEditTitle} isEditable={isEditable} isTestable={isTestable} - config={config} onSubmit={onSubmit} initialValues={existingValue} onTestChannel={onTestChannel} notifiers={notifiers} - alertManagerSourceName={alertManagerSourceName} + alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME} defaultItem={{ ...defaultChannelValues }} - takenReceiverNames={takenReceiverNames} commonSettingsComponent={GrafanaCommonChannelSettings} customValidators={{ [ReceiverTypes.OnCall]: onCallFormValidators }} /> diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index f726c204426..0d1e6b74f15 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -1,14 +1,13 @@ import { css } from '@emotion/css'; -import { useCallback } from 'react'; import * as React from 'react'; -import { FieldErrors, FormProvider, SubmitErrorHandler, useForm, Validate } from 'react-hook-form'; +import { FieldErrors, FormProvider, SubmitErrorHandler, useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useCleanup } from 'app/core/hooks/useCleanup'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { useValidateContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; import { getMessageFromError } from '../../../../../../core/utils/errors'; import { logError } from '../../../Analytics'; @@ -24,36 +23,45 @@ import { Notifier } from './notifiers'; import { normalizeFormValues } from './util'; interface Props { - config: AlertManagerCortexConfig; notifiers: Notifier[]; defaultItem: R; alertManagerSourceName: string; onTestChannel?: (channel: R) => void; onSubmit: (values: ReceiverFormValues) => Promise; - takenReceiverNames: string[]; // will validate that user entered receiver name is not one of these commonSettingsComponent: CommonSettingsComponentType; initialValues?: ReceiverFormValues; isEditable: boolean; isTestable?: boolean; customValidators?: Record['customValidators']>; + /** + * Should we show a warning that there is no default policy set, + * and that contact point being created will be set as the default? + */ + showDefaultRouteWarning?: boolean; + /** + * Should we disable the title editing? Required when we're using the API server, + * as at the time of writing this is not possible + */ + disableEditTitle?: boolean; } export function ReceiverForm({ - config, initialValues, defaultItem, notifiers, alertManagerSourceName, onSubmit, onTestChannel, - takenReceiverNames, commonSettingsComponent, isEditable, isTestable, customValidators, -}: Props): JSX.Element { + showDefaultRouteWarning, + disableEditTitle, +}: Props) { const notifyApp = useAppNotification(); const styles = useStyles2(getStyles); + const validateContactPointName = useValidateContactPoint({ alertmanager: alertManagerSourceName }); // normalize deprecated and new config values const normalizedConfig = normalizeFormValues(initialValues); @@ -84,14 +92,6 @@ export function ReceiverForm({ const { fields, append, remove } = useControlledFieldArray({ name: 'items', formAPI, softDelete: true }); - const validateNameIsAvailable: Validate> = useCallback( - (name: string) => - takenReceiverNames.map((name) => name.trim().toLowerCase()).includes(name.trim().toLowerCase()) - ? 'Another receiver with this name already exists.' - : true, - [takenReceiverNames] - ); - const submitCallback = async (values: ReceiverFormValues) => { try { await onSubmit({ @@ -116,7 +116,7 @@ export function ReceiverForm({ return ( - {!config.alertmanager_config.route && ( + {showDefaultRouteWarning && ( Because there is no default policy configured yet, this contact point will automatically be set as default. @@ -127,11 +127,15 @@ export function ReceiverForm({ { + const existingValue = initialValues?.name; + return validateContactPointName(value, existingValue); + }, })} width={39} placeholder="Name" @@ -198,7 +202,7 @@ export function ReceiverForm({ disabled={isSubmitting} variant="secondary" data-testid="cancel-button" - href={makeAMLink('alerting/notifications', alertManagerSourceName)} + href={makeAMLink('/alerting/notifications', alertManagerSourceName)} > Cancel diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx index 28bc7e3bbf3..3a3a4729d48 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx @@ -35,6 +35,7 @@ export const OptionField: FC = ({ customValidator, }) => { const optionPath = `${pathPrefix}${pathSuffix}`; + const isSecure = pathSuffix === 'secureSettings.'; if (option.element === 'subform') { return ( @@ -75,12 +76,13 @@ export const OptionField: FC = ({ readOnly={readOnly} pathIndex={pathPrefix} customValidator={customValidator} + isSecure={isSecure} /> ); }; -const OptionInput: FC = ({ +const OptionInput: FC = ({ option, invalid, id, @@ -88,11 +90,19 @@ const OptionInput: FC = ({ pathIndex = '', readOnly = false, customValidator, + isSecure, }) => { const styles = useStyles2(getStyles); const { control, register, unregister, getValues, setValue } = useFormContext(); const name = `${pathPrefix}${option.propertyName}`; + useEffect(() => { + // Remove the value of secure fields so it doesn't show the incorrect value when clearing the field + if (isSecure) { + setValue(name, null); + } + }, [isSecure, name, setValue]); + // workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506 useEffect( () => () => { diff --git a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.ts b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.ts index 9ea8bad7700..27adfd1f370 100644 --- a/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.ts +++ b/public/app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/useOnCallIntegration.ts @@ -142,7 +142,8 @@ export function useOnCallIntegration() { return receiver; } - const onCallIntegrations = receiver.grafana_managed_receiver_configs?.filter((c) => c.type === 'oncall') ?? []; + const onCallIntegrations = + receiver.grafana_managed_receiver_configs?.filter((c) => c.type === ReceiverTypes.OnCall) ?? []; const newOnCallIntegrations = onCallIntegrations.filter( (c) => c.settings[OnCallIntegrationSetting.IntegrationType] === OnCallIntegrationType.NewIntegration ); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index ed59a8e9791..992eba0a1a2 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -12,18 +12,21 @@ import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { getCloudRule, getGrafanaRule, + getVanillaPromRule, grantUserPermissions, mockDataSource, mockPluginLinkExtension, + mockPromAlertingRule, } from '../../mocks'; import { grafanaRulerRule } from '../../mocks/grafanaRulerApi'; import { setupDataSources } from '../../testSetup/datasources'; import { Annotation } from '../../utils/constants'; import { DataSourceType } from '../../utils/datasource'; import * as ruleId from '../../utils/rule-id'; +import { stringifyIdentifier } from '../../utils/rule-id'; import { AlertRuleProvider } from './RuleContext'; -import RuleViewer from './RuleViewer'; +import RuleViewer, { ActiveTab } from './RuleViewer'; // metadata and interactive elements const ELEMENTS = { @@ -35,6 +38,12 @@ const ELEMENTS = { evaluationInterval: (interval: string) => byText(`Every ${interval}`), label: ([key, value]: [string, string]) => byRole('listitem', { name: `${key}: ${value}` }), }, + details: { + pendingPeriod: byText(/Pending period/i), + }, + tabs: { + details: byRole('tab', { name: /Details/i }), + }, actions: { edit: byRole('link', { name: 'Edit' }), more: { @@ -259,13 +268,39 @@ describe('RuleViewer', () => { await waitFor(() => expect(ELEMENTS.actions.more.pluginActions.declareIncident.get()).toBeEnabled()); }); }); + + describe('Vanilla Prometheus rule', () => { + const mockRule = getVanillaPromRule({ + name: 'prom test alert', + annotations: { [Annotation.summary]: 'prom summary', [Annotation.runbookURL]: 'https://runbook.example.com' }, + promRule: { + ...mockPromAlertingRule(), + duration: 900, // 15 minutes + }, + }); + + const mockRuleIdentifier = ruleId.fromCombinedRule('prometheus', mockRule); + + it('should render pending period for vanilla Prometheus alert rule', async () => { + renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Details); + + expect(screen.getByText('prom test alert')).toBeInTheDocument(); + + // One summary is rendered by the Title component, and the other by the DetailsTab component + expect(ELEMENTS.metadata.summary(mockRule.annotations[Annotation.summary]).getAll()).toHaveLength(2); + + expect(within(ELEMENTS.details.pendingPeriod.get()).getByText(/15m/i)).toBeInTheDocument(); + }); + }); }); -const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) => { +const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier, tab: ActiveTab = ActiveTab.Query) => { + const path = `/alerting/${identifier.ruleSourceName}/${stringifyIdentifier(identifier)}/view?tab=${tab}`; render( - + , + { historyOptions: { initialEntries: [path] } } ); await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index 5c723326abb..53c74196586 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -7,6 +7,7 @@ import { ClipboardButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui' import { CombinedRule } from 'app/types/unified-alerting'; import { Annotations } from 'app/types/unified-alerting-dto'; +import { usePendingPeriod } from '../../../hooks/rules/usePendingPeriod'; import { isGrafanaRulerRule, isRecordingRulerRule } from '../../../utils/rules'; import { MetaText } from '../../MetaText'; import { Tokenize } from '../../Tokenize'; @@ -26,6 +27,8 @@ const Details = ({ rule }: DetailsProps) => { let ruleType: RuleType; + const pendingPeriod = usePendingPeriod(rule); + if (isGrafanaRulerRule(rule.rulerRule)) { ruleType = RuleType.GrafanaManagedAlertRule; } else if (isRecordingRulerRule(rule.rulerRule)) { @@ -89,10 +92,10 @@ const Details = ({ rule }: DetailsProps) => { )} - {!isRecordingRulerRule(rule.rulerRule) && ( + {pendingPeriod && ( <> Pending period - {rule.rulerRule?.for ?? '0s'} + {pendingPeriod} )} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index 6c8ddb552e1..7c7659a5e32 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -5,8 +5,9 @@ import { Tooltip, useStyles2 } from '@grafana/ui'; import { Time } from 'app/features/explore/Time'; import { CombinedRule } from 'app/types/unified-alerting'; +import { usePendingPeriod } from '../../hooks/rules/usePendingPeriod'; import { useCleanAnnotations } from '../../utils/annotations'; -import { isGrafanaRecordingRule, isRecordingRulerRule } from '../../utils/rules'; +import { isGrafanaRecordingRule } from '../../utils/rules'; import { isNullDate } from '../../utils/time'; import { AlertLabels } from '../AlertLabels'; import { DetailsField } from '../DetailsField'; @@ -64,16 +65,12 @@ interface EvaluationBehaviorSummaryProps { } const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => { - let forDuration: string | undefined; const every = rule.group.interval; const lastEvaluation = rule.promRule?.lastEvaluation; const lastEvaluationDuration = rule.promRule?.evaluationTime; const metric = isGrafanaRecordingRule(rule.rulerRule) ? rule.rulerRule?.grafana_alert.record?.metric : undefined; - // recording rules don't have a for duration - if (!isRecordingRulerRule(rule.rulerRule)) { - forDuration = rule.rulerRule?.for ?? '0s'; - } + const pendingPeriod = usePendingPeriod(rule); return ( <> @@ -88,9 +85,11 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => )} - - {forDuration} - + {pendingPeriod && ( + + {pendingPeriod} + + )} {lastEvaluation && !isNullDate(lastEvaluation) && ( diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx new file mode 100644 index 00000000000..6aa79128c34 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from 'test/test-utils'; + +import { setPluginExtensionsHook } from '@grafana/runtime'; +import { RuleListStateView } from 'app/features/alerting/unified/components/rules/RuleListStateView'; +import { + mockCombinedRule, + mockCombinedRuleGroup, + mockCombinedRuleNamespace, + mockPromAlertingRule, +} from 'app/features/alerting/unified/mocks'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + +const namespaces = [ + mockCombinedRuleNamespace({ + groups: [ + mockCombinedRuleGroup('Group with a missing state', [ + mockCombinedRule({ + name: 'Rule in firing state', + promRule: mockPromAlertingRule({ + state: PromAlertingRuleState.Firing, + }), + }), + mockCombinedRule({ + name: 'Rule in pending state', + promRule: mockPromAlertingRule({ + state: PromAlertingRuleState.Pending, + }), + }), + mockCombinedRule({ + name: 'Rule in inactive state', + promRule: mockPromAlertingRule({ + state: PromAlertingRuleState.Inactive, + }), + }), + mockCombinedRule({ + name: 'Rule with missing prom state', + promRule: mockPromAlertingRule({ + state: undefined, + }), + }), + ]), + ], + }), +]; + +describe('RuleListStateView', () => { + it('renders differing prom rule states correctly and does not crash with missing state', () => { + render(); + + expect(screen.getByText(/firing \(1\)/i)).toBeInTheDocument(); + expect(screen.getByText(/pending \(1\)/i)).toBeInTheDocument(); + expect(screen.getByText(/normal \(1\)/i)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx index 04c16b82a06..d09f998d1a7 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx @@ -28,7 +28,10 @@ export const RuleListStateView = ({ namespaces }: Props) => { namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach((rule) => { - if (rule.promRule && isAlertingRule(rule.promRule)) { + // We might hit edge cases where there type = alerting, but there is no state. + // In this case, we shouldn't try to group these alerts in the state view + // Even though we handle this at the API layer, this is a last catch point for any edge cases + if (rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state) { result[rule.promRule.state].push(rule); } }) diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap index 2374935f47a..d5ff767a76f 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap @@ -1,5 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Moving a Data source managed rule should move a rule in a namespace to another existing namespace 1`] = ` +[ + { + "body": { + "name": "group-1", + "rules": [ + { + "alert": "alert1", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir", + }, +] +`; + exports[`Moving a Data source managed rule should move a rule in an existing group to a new group 1`] = ` [ { @@ -125,7 +170,7 @@ exports[`Moving a Data source managed rule should move a rule in an existing gro ], ], "method": "POST", - "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", }, { "body": "", diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx index 2fda700d4ac..4426b16de92 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx +++ b/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx @@ -184,6 +184,45 @@ describe('Moving a Data source managed rule', () => { expect(serializedRequests).toMatchSnapshot(); }); + it('should move a rule in a namespace to another existing namespace', async () => { + const capture = captureRequests((r) => r.method === 'POST' || r.method === 'DELETE'); + + const ruleToMove = group1.rules[0]; + + const coreRuleGroup = { + dataSourceName: MIMIR_DATASOURCE_UID, + groupName: group1.name, + }; + + const currentRuleGroupID: RuleGroupIdentifier = { + ...coreRuleGroup, + namespaceName: NAMESPACE_1, + }; + + const targetRuleGroupID: RuleGroupIdentifier = { + ...coreRuleGroup, + namespaceName: NAMESPACE_2, + }; + + const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove); + + const { user } = render( + + ); + await user.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + it('should fail if the rule group does not exist', async () => { const groupToUpdate = group1; const ruleToUpdate = groupToUpdate.rules[0]; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts index adc79753748..41a4664c54a 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts +++ b/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts @@ -114,7 +114,7 @@ export function useMoveRuleToRuleGroup() { const result = await upsertRuleGroup({ rulerConfig: targetGroupRulerConfig, - namespace: currentRuleGroup.namespaceName, + namespace: targetRuleGroup.namespaceName, payload: newTargetGroup, notificationOptions: { successMessage }, }).unwrap(); diff --git a/public/app/features/alerting/unified/hooks/rules/usePendingPeriod.tsx b/public/app/features/alerting/unified/hooks/rules/usePendingPeriod.tsx new file mode 100644 index 00000000000..d7ebd337f7c --- /dev/null +++ b/public/app/features/alerting/unified/hooks/rules/usePendingPeriod.tsx @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; + +import { CombinedRule } from 'app/types/unified-alerting'; + +import { getPendingPeriod } from '../../utils/rules'; + +export function usePendingPeriod(rule: CombinedRule): string | undefined { + return useMemo(() => getPendingPeriod(rule), [rule]); +} diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 699252137db..632868a90e0 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -785,6 +785,19 @@ export function getCloudRule(override?: Partial) { }); } +export function getVanillaPromRule(override?: Partial>) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Prometheus', + rulesSource: mockDataSource(), + }, + promRule: mockPromAlertingRule(), + rulerRule: undefined, + ...override, + }); +} + export function mockPluginLinkExtension(extension: Partial): PluginExtensionLink { return { type: PluginExtensionTypes.link, diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index 63b976f2a44..db47b049265 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -4,9 +4,11 @@ import { config } from '@grafana/runtime'; import server, { mockFeatureDiscoveryApi } from 'app/features/alerting/unified/mockApi'; import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks'; import { + ALERTMANAGER_UPDATE_ERROR_RESPONSE, getAlertmanagerConfigHandler, getGrafanaAlertmanagerConfigHandler, grafanaAlertingConfigurationStatusHandler, + updateGrafanaAlertmanagerConfigHandler, } from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers'; import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/handlers/folders'; import { listNamespacedTimeIntervalHandler } from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s'; @@ -126,3 +128,8 @@ export const removePlugin = (pluginId: string) => { export const disablePlugin = (pluginId: SupportedPlugin) => { server.use(getDisabledPluginHandler(pluginId)); }; + +/** Make alertmanager config update fail */ +export const makeGrafanaAlertmanagerConfigUpdateFail = () => { + server.use(updateGrafanaAlertmanagerConfigHandler(ALERTMANAGER_UPDATE_ERROR_RESPONSE)); +}; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts b/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts index 2f1f493015e..37f7f8e1c46 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts @@ -35,7 +35,7 @@ export const getGrafanaAlertmanagerConfigHandler = (config: AlertManagerCortexCo export const getAlertmanagerConfigHandler = (config: AlertManagerCortexConfig = alertmanagerConfigMock) => http.get('/api/alertmanager/:name/config/api/v1/alerts', () => HttpResponse.json(config)); -const alertmanagerUpdateError = HttpResponse.json({ message: 'bad request' }, { status: 400 }); +export const ALERTMANAGER_UPDATE_ERROR_RESPONSE = HttpResponse.json({ message: 'bad request' }, { status: 400 }); /** Perform some basic validation on the config that we expect the backend to also do */ const validateGrafanaAlertmanagerConfig = (config: AlertManagerCortexConfig) => { @@ -46,27 +46,28 @@ const validateGrafanaAlertmanagerConfig = (config: AlertManagerCortexConfig) => const intervalsByName = new Set(intervals.map((interval) => interval.name)); const duplicatedIntervals = intervalsByName.size !== intervals.length; + let routesReferencingMissingMuteTimings = false; + if (route) { - const routesReferencingMissingMuteTimings = Boolean( + routesReferencingMissingMuteTimings = Boolean( route.routes?.find((route) => { return route.mute_time_intervals?.some((name) => !intervalsByName.has(name)); }) ); - - if (routesReferencingMissingMuteTimings) { - return alertmanagerUpdateError; - } } - if (duplicatedIntervals) { - return alertmanagerUpdateError; + if (routesReferencingMissingMuteTimings || duplicatedIntervals) { + return ALERTMANAGER_UPDATE_ERROR_RESPONSE; } return null; }; -const updateGrafanaAlertmanagerConfigHandler = () => +export const updateGrafanaAlertmanagerConfigHandler = (responseOverride?: typeof ALERTMANAGER_UPDATE_ERROR_RESPONSE) => http.post('/api/alertmanager/grafana/config/api/v1/alerts', async ({ request }) => { + if (responseOverride) { + return responseOverride; + } const body: AlertManagerCortexConfig = await request.clone().json(); const potentialError = validateGrafanaAlertmanagerConfig(body); return potentialError ? potentialError : HttpResponse.json({ message: 'configuration created' }); @@ -95,6 +96,12 @@ const getReceiversHandler = () => return HttpResponse.json({ message: 'Not found.' }, { status: 404 }); }); +const testReceiversHandler = () => + http.post('/api/alertmanager/grafana/config/api/v1/receivers/test', () => { + // TODO: scaffold out response as needed by tests + return HttpResponse.json({}); + }); + const getGroupsHandler = () => http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/alerts/groups', () => // TODO: Scaffold out response with better data as required by tests @@ -110,6 +117,7 @@ const handlers = [ updateAlertmanagerConfigHandler(), getGrafanaAlertmanagerTemplatePreview(), getReceiversHandler(), + testReceiversHandler(), getGroupsHandler(), ]; export default handlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts index 157ebddfba0..8983b0171b0 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts @@ -1,3 +1,4 @@ +import { camelCase } from 'lodash'; import { HttpResponse, http } from 'msw'; import alertmanagerConfig from 'app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json'; @@ -16,7 +17,11 @@ const mappedReceivers = return integration.provenance; })?.provenance || PROVENANCE_NONE; return { - metadata: { annotations: { [PROVENANCE_ANNOTATION]: provenance } }, + metadata: { + // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses + uid: camelCase(contactPoint.name), + annotations: { [PROVENANCE_ANNOTATION]: provenance }, + }, spec: { title: contactPoint.name, integrations: contactPoint.grafana_managed_receiver_configs || [], @@ -34,5 +39,31 @@ const listNamespacedReceiverHandler = () => return HttpResponse.json(parsedReceivers); }); -const handlers = [listNamespacedReceiverHandler()]; +const createNamespacedReceiverHandler = () => + http.post<{ namespace: string }>( + `${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers`, + async ({ request }) => { + const body = await request.json(); + return HttpResponse.json(body); + } + ); + +const deleteNamespacedReceiverHandler = () => + http.delete<{ namespace: string; name: string }>( + `${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers/:name`, + ({ params }) => { + const { name } = params; + const matchedReceiver = parsedReceivers.items.find((receiver) => receiver.metadata.uid === name); + if (matchedReceiver) { + return HttpResponse.json(parsedReceivers); + } + return HttpResponse.json({}, { status: 404 }); + } + ); + +const handlers = [ + listNamespacedReceiverHandler(), + createNamespacedReceiverHandler(), + deleteNamespacedReceiverHandler(), +]; export default handlers; diff --git a/public/app/features/alerting/unified/openapi/receiversApi.gen.ts b/public/app/features/alerting/unified/openapi/receiversApi.gen.ts index 56f2d366d63..defe5c50adb 100644 --- a/public/app/features/alerting/unified/openapi/receiversApi.gen.ts +++ b/public/app/features/alerting/unified/openapi/receiversApi.gen.ts @@ -25,6 +25,56 @@ const injectedRtkApi = api }), providesTags: ['Receiver'], }), + createNamespacedReceiver: build.mutation({ + query: (queryArg) => ({ + url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/receivers`, + method: 'POST', + body: queryArg.comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Receiver'], + }), + readNamespacedReceiver: build.query({ + query: (queryArg) => ({ + url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/receivers/${queryArg.name}`, + params: { pretty: queryArg.pretty }, + }), + providesTags: ['Receiver'], + }), + replaceNamespacedReceiver: build.mutation({ + query: (queryArg) => ({ + url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/receivers/${queryArg.name}`, + method: 'PUT', + body: queryArg.comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Receiver'], + }), + deleteNamespacedReceiver: build.mutation({ + query: (queryArg) => ({ + url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/receivers/${queryArg.name}`, + method: 'DELETE', + body: queryArg.ioK8SApimachineryPkgApisMetaV1DeleteOptions, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['Receiver'], + }), }), overrideExisting: false, }); @@ -39,7 +89,7 @@ export type ListNamespacedReceiverApiArg = { /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ allowWatchBookmarks?: boolean; /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". - + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ continue?: string; /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ @@ -47,19 +97,19 @@ export type ListNamespacedReceiverApiArg = { /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ labelSelector?: string; /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. - + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ limit?: number; /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. - + Defaults to unset */ resourceVersion?: string; /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. - + Defaults to unset */ resourceVersionMatch?: string; /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. - + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan is interpreted as "data at least as new as the provided `resourceVersion`" and the bookmark event is send when the state is synced @@ -69,7 +119,7 @@ export type ListNamespacedReceiverApiArg = { when request started being processed. - `resourceVersionMatch` set to any other value or unset Invalid error is returned. - + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ sendInitialEvents?: boolean; /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ @@ -77,6 +127,71 @@ export type ListNamespacedReceiverApiArg = { /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ watch?: boolean; }; +export type CreateNamespacedReceiverApiResponse = /** status 200 OK */ + | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver + | /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver + | /** status 202 Accepted */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; +export type CreateNamespacedReceiverApiArg = { + /** object name and auth scope, such as for teams and projects */ + namespace: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; +}; +export type ReadNamespacedReceiverApiResponse = + /** status 200 OK */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; +export type ReadNamespacedReceiverApiArg = { + /** name of the Receiver */ + name: string; + /** object name and auth scope, such as for teams and projects */ + namespace: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceNamespacedReceiverApiResponse = /** status 200 OK */ + | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver + | /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; +export type ReplaceNamespacedReceiverApiArg = { + /** name of the Receiver */ + name: string; + /** object name and auth scope, such as for teams and projects */ + namespace: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver; +}; +export type DeleteNamespacedReceiverApiResponse = /** status 200 OK */ + | IoK8SApimachineryPkgApisMetaV1Status + | /** status 202 Accepted */ IoK8SApimachineryPkgApisMetaV1Status; +export type DeleteNamespacedReceiverApiArg = { + /** name of the Receiver */ + name: string; + /** object name and auth scope, such as for teams and projects */ + namespace: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + ioK8SApimachineryPkgApisMetaV1DeleteOptions: IoK8SApimachineryPkgApisMetaV1DeleteOptions; +}; export type IoK8SApimachineryPkgApisMetaV1Time = string; export type IoK8SApimachineryPkgApisMetaV1FieldsV1 = object; export type IoK8SApimachineryPkgApisMetaV1ManagedFieldsEntry = { @@ -115,21 +230,21 @@ export type IoK8SApimachineryPkgApisMetaV1ObjectMeta = { [key: string]: string; }; /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. - + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ creationTimestamp?: IoK8SApimachineryPkgApisMetaV1Time; /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ deletionGracePeriodSeconds?: number; /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. - + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ deletionTimestamp?: IoK8SApimachineryPkgApisMetaV1Time; /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ finalizers?: string[]; /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. - + If this field is specified and the generated name exists, the server will return a 409. - + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ generateName?: string; /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ @@ -143,28 +258,29 @@ export type IoK8SApimachineryPkgApisMetaV1ObjectMeta = { /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ name?: string; /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. - + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ namespace?: string; /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ ownerReferences?: IoK8SApimachineryPkgApisMetaV1OwnerReference[]; /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. - + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ resourceVersion?: string; /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ selfLink?: string; /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. - + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; +export type ComGithubGrafanaGrafanaPkgApimachineryApisCommonV0Alpha1Unstructured = { + [key: string]: any; +}; export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Integration = { - SecureFields?: { - [key: string]: boolean; - }; + SecureFields?: ComGithubGrafanaGrafanaPkgApimachineryApisCommonV0Alpha1Unstructured; disableResolveMessage?: boolean; - settings: string; + settings: ComGithubGrafanaGrafanaPkgApimachineryApisCommonV0Alpha1Unstructured; type: string; uid?: string; }; @@ -198,3 +314,69 @@ export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1ReceiverL kind?: string; metadata: IoK8SApimachineryPkgApisMetaV1ListMeta; }; +export type IoK8SApimachineryPkgApisMetaV1StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type IoK8SApimachineryPkgApisMetaV1StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: IoK8SApimachineryPkgApisMetaV1StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type IoK8SApimachineryPkgApisMetaV1Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: IoK8SApimachineryPkgApisMetaV1StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: IoK8SApimachineryPkgApisMetaV1ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type IoK8SApimachineryPkgApisMetaV1Preconditions = { + /** Specifies the target ResourceVersion */ + resourceVersion?: string; + /** Specifies the target UID. */ + uid?: string; +}; +export type IoK8SApimachineryPkgApisMetaV1DeleteOptions = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string[]; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned. */ + preconditions?: IoK8SApimachineryPkgApisMetaV1Preconditions; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; diff --git a/public/app/features/alerting/unified/openapi/timeIntervalsApi.gen.ts b/public/app/features/alerting/unified/openapi/timeIntervalsApi.gen.ts index 42eb90bf6ff..3a01ecf6886 100644 --- a/public/app/features/alerting/unified/openapi/timeIntervalsApi.gen.ts +++ b/public/app/features/alerting/unified/openapi/timeIntervalsApi.gen.ts @@ -77,24 +77,6 @@ const injectedRtkApi = api }), invalidatesTags: ['TimeInterval'], }), - patchNamespacedTimeInterval: build.mutation< - PatchNamespacedTimeIntervalApiResponse, - PatchNamespacedTimeIntervalApiArg - >({ - query: (queryArg) => ({ - url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/timeintervals/${queryArg.name}`, - method: 'PATCH', - body: queryArg.ioK8SApimachineryPkgApisMetaV1Patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['TimeInterval'], - }), }), overrideExisting: false, }); @@ -202,26 +184,6 @@ export type DeleteNamespacedTimeIntervalApiArg = { propagationPolicy?: string; ioK8SApimachineryPkgApisMetaV1DeleteOptions: IoK8SApimachineryPkgApisMetaV1DeleteOptions; }; -export type PatchNamespacedTimeIntervalApiResponse = /** status 200 OK */ - | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval - | /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval; -export type PatchNamespacedTimeIntervalApiArg = { - /** name of the TimeInterval */ - name: string; - /** object name and auth scope, such as for teams and projects */ - namespace: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - ioK8SApimachineryPkgApisMetaV1Patch: IoK8SApimachineryPkgApisMetaV1Patch; -}; export type IoK8SApimachineryPkgApisMetaV1Time = string; export type IoK8SApimachineryPkgApisMetaV1FieldsV1 = object; export type IoK8SApimachineryPkgApisMetaV1ManagedFieldsEntry = { @@ -412,4 +374,3 @@ export type IoK8SApimachineryPkgApisMetaV1DeleteOptions = { /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ propagationPolicy?: string; }; -export type IoK8SApimachineryPkgApisMetaV1Patch = object; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 276c43a0bcc..c7dad4ca3b1 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -331,7 +331,14 @@ export const updateAlertManagerConfigAction = createAsyncThunk { const featureToggleEnabled = config.featureToggles.alertingApiServer; return featureToggleEnabled && alertmanager === GRAFANA_RULES_SOURCE_NAME; }; + +type Entity = { + metadata: { + annotations?: Record; + }; +}; + +/** + * Check the metadata of a kubernetes entity and check if has the necessary annotations + * that denote it as provisioned + */ +export const isK8sEntityProvisioned = (item: Entity) => + item.metadata.annotations?.[PROVENANCE_ANNOTATION] !== PROVENANCE_NONE; diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index 672fd3ac801..bc857d79d25 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -125,38 +125,39 @@ export function formValuesToCloudReceiver( export function updateConfigWithReceiver( config: AlertManagerCortexConfig, receiver: Receiver, - replaceReceiverName?: string + existingReceiverName?: string ): AlertManagerCortexConfig { - const oldReceivers = config.alertmanager_config.receivers ?? []; + const existingReceivers = config.alertmanager_config.receivers ?? []; + + const receiverWasRenamed = existingReceiverName && receiver.name !== existingReceiverName; // sanity check that name is not duplicated - if (receiver.name !== replaceReceiverName && !!oldReceivers.find(({ name }) => name === receiver.name)) { + if (!existingReceiverName && !!existingReceivers.find(({ name }) => name === receiver.name)) { throw new Error(`Duplicate receiver name ${receiver.name}`); } // sanity check that existing receiver exists - if (replaceReceiverName && !oldReceivers.find(({ name }) => name === replaceReceiverName)) { - throw new Error(`Expected receiver ${replaceReceiverName} to exist, but did not find it in the config`); + if (existingReceiverName && !existingReceivers.find(({ name }) => name === existingReceiverName)) { + throw new Error(`Expected receiver ${existingReceiverName} to exist, but did not find it in the config`); } const updated: AlertManagerCortexConfig = { ...config, alertmanager_config: { - // @todo rename receiver on routes as necessary ...config.alertmanager_config, - receivers: replaceReceiverName - ? oldReceivers.map((existingReceiver) => - existingReceiver.name === replaceReceiverName ? receiver : existingReceiver + receivers: existingReceiverName + ? existingReceivers.map((existingReceiver) => + existingReceiver.name === existingReceiverName ? receiver : existingReceiver ) - : [...oldReceivers, receiver], + : [...existingReceivers, receiver], }, }; // if receiver was renamed, rename it in routes as well - if (updated.alertmanager_config.route && replaceReceiverName && receiver.name !== replaceReceiverName) { + if (updated.alertmanager_config.route && receiverWasRenamed) { updated.alertmanager_config.route = renameReceiverInRoute( updated.alertmanager_config.route, - replaceReceiverName, + existingReceiverName, receiver.name ); } diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index fe4aa16b4b4..cc4a7af1af9 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -44,7 +44,7 @@ import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { getRulesSourceName, isGrafanaRulesSource } from './datasource'; import { GRAFANA_ORIGIN_LABEL } from './labels'; import { AsyncRequestState } from './redux'; -import { safeParsePrometheusDuration } from './time'; +import { formatPrometheusDuration, safeParsePrometheusDuration } from './time'; export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule { return typeof rule === 'object' && rule.type === PromRuleType.Alerting; @@ -137,6 +137,26 @@ export function getRuleHealth(health: string): RuleHealth | undefined { } } +export function getPendingPeriod(rule: CombinedRule): string | undefined { + if (isRecordingRulerRule(rule.rulerRule) || isRecordingRule(rule.promRule)) { + return undefined; + } + + // We prefer the for duration from the ruler rule because it is formatted as a duration string + // Prometheus duration is in seconds and we need to format it as a duration string + // Additionally, due to eventual consistency of the Prometheus endpoint the ruler data might be newer + if (isAlertingRulerRule(rule.rulerRule)) { + return rule.rulerRule.for; + } + + if (isAlertingRule(rule.promRule)) { + const durationInMilliseconds = (rule.promRule.duration ?? 0) * 1000; + return formatPrometheusDuration(durationInMilliseconds); + } + + return undefined; +} + export interface RulePluginOrigin { pluginId: string; } diff --git a/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx b/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx index 042e126eafd..d186d4a2613 100644 --- a/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx +++ b/public/app/features/api-keys/MigrateToServiceAccountsCard.tsx @@ -17,7 +17,7 @@ export const MigrateToServiceAccountsCard = ({ onMigrate, apikeysCount, disabled const docsLink = ( diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx index 1b9eeded9e4..7c79f5798cc 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx @@ -4,7 +4,14 @@ import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingSta import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField'; import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry'; import { config, locationService } from '@grafana/runtime'; -import { LocalValueVariable, SceneGridRow, SceneVariableSet, VizPanel, sceneGraph } from '@grafana/scenes'; +import { + CustomVariable, + LocalValueVariable, + SceneGridRow, + SceneVariableSet, + VizPanel, + sceneGraph, +} from '@grafana/scenes'; import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { InspectTab } from 'app/features/inspector/types'; @@ -801,6 +808,26 @@ describe('VizPanelManager', () => { expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); }); + it('Should default to the first variable value if panel is repeated', async () => { + const { scene, panel } = setupTest('panel-10'); + + scene.setState({ + $variables: new SceneVariableSet({ + variables: [ + new CustomVariable({ name: 'custom', query: 'A,B,C', value: ['A', 'B', 'C'], text: ['A', 'B', 'C'] }), + ], + }), + }); + + scene.setState({ editPanel: buildPanelEditScene(panel) }); + + const vizPanelManager = scene.state.editPanel!.state.vizManager; + vizPanelManager.activate(); + + const variable = sceneGraph.lookupVariable('custom', vizPanelManager); + expect(variable?.getValue()).toBe('A'); + }); + describe('Given a panel inside repeated row', () => { it('Should include row variable scope', () => { const { panel } = setupTest('panel-9'); diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx index 3e8721302ca..6878fd5e569 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx @@ -15,6 +15,8 @@ import { import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; import { DeepPartial, + LocalValueVariable, + MultiValueVariable, PanelBuilders, SceneComponentProps, SceneDataTransformer, @@ -23,8 +25,10 @@ import { SceneObjectState, SceneObjectStateChangedEvent, SceneQueryRunner, + SceneVariableSet, SceneVariables, VizPanel, + sceneGraph, } from '@grafana/scenes'; import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; @@ -41,7 +45,7 @@ import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; +import { getDashboardSceneFor, getMultiVariableValues, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; export interface VizPanelManagerState extends SceneObjectState { panel: VizPanel; @@ -97,6 +101,28 @@ export class VizPanelManager extends SceneObjectBase { variables = gridItem.parent.state.$variables.clone(); } + if (repeatOptions.repeat) { + const variable = sceneGraph.lookupVariable(repeatOptions.repeat, gridItem); + + if (variable instanceof MultiValueVariable && variable.state.value.length) { + const { values, texts } = getMultiVariableValues(variable); + + const varWithDefaultValue = new LocalValueVariable({ + name: variable.state.name, + value: values[0], + text: String(texts[0]), + }); + + if (!variables) { + variables = new SceneVariableSet({ + variables: [varWithDefaultValue], + }); + } else { + variables.setState({ variables: [varWithDefaultValue] }); + } + } + } + return new VizPanelManager({ $variables: variables, panel: sourcePanel.clone(), diff --git a/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts b/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts index a439bb2541d..f596a026b25 100644 --- a/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts +++ b/public/app/features/dashboard-scene/panel-edit/testfiles/testDashboard.ts @@ -91,6 +91,102 @@ export const panelWithQueriesOnly = { type: 'timeseries', }; +export const repeatedPanel = { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + repeat: 'custom', + repeatDirection: 'h', + maxPerRow: 4, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisBorderShow: false, + axisCenteredZero: false, + axisColorMode: 'text', + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + insertNulls: false, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 0, + y: 0, + }, + id: 1, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + showLegend: true, + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 1, + }, + ], + title: 'Panel with just queries', + type: 'timeseries', +}; + export const panelWithTransformations = { datasource: { type: 'grafana-testdata-datasource', @@ -636,6 +732,7 @@ export const testDashboard = { panelWithQueriesAndMixedDatasource, row, rowChild, + repeatedPanel, ], refresh: '', schemaVersion: 39, diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx index e56fe084dcb..b09427095b3 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx @@ -1,3 +1,4 @@ +import { VariableRefresh } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { setPluginImportUtils } from '@grafana/runtime'; import { SceneGridLayout, VizPanel } from '@grafana/scenes'; @@ -11,6 +12,12 @@ setPluginImportUtils({ getPanelPluginFromCache: (id: string) => undefined, }); +const mockGetQueryRunnerFor = jest.fn(); +jest.mock('../utils/utils', () => ({ + ...jest.requireActual('../utils/utils'), + getQueryRunnerFor: jest.fn().mockImplementation(() => mockGetQueryRunnerFor()), +})); + describe('PanelRepeaterGridItem', () => { it('Given scene with variable with 2 values', async () => { const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0 }); @@ -40,6 +47,32 @@ describe('PanelRepeaterGridItem', () => { expect(repeater.state.repeatedPanels?.length).toBe(5); }); + it('Should update panels on refresh if variables load on time range change', async () => { + const { scene, repeater } = buildPanelRepeaterScene({ + variableQueryTime: 0, + variableRefresh: VariableRefresh.onTimeRangeChanged, + }); + + const notifyPanelsSpy = jest.spyOn(repeater, 'notifyRepeatedPanelsWaitingForVariables'); + + activateFullSceneTree(scene); + + expect(repeater.state.repeatedPanels?.length).toBe(5); + + expect(notifyPanelsSpy).toHaveBeenCalledTimes(0); + + scene.state.$timeRange?.onRefresh(); + + //make sure notifier is called + expect(notifyPanelsSpy).toHaveBeenCalledTimes(1); + + //make sure getQueryRunner is called for each repeated panel + expect(mockGetQueryRunnerFor).toHaveBeenCalledTimes(5); + + notifyPanelsSpy.mockRestore(); + mockGetQueryRunnerFor.mockClear(); + }); + it('Should display a panel when there are no options', async () => { const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 1, numberOfOptions: 0 }); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index d6d66259527..670a57b9f98 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -7,7 +7,6 @@ import { config } from '@grafana/runtime'; import { VizPanel, SceneObjectBase, - VariableDependencyConfig, SceneGridLayout, SceneVariableSet, SceneComponentProps, @@ -20,10 +19,12 @@ import { VizPanelMenu, VizPanelState, VariableValueSingle, + SceneVariable, + SceneVariableDependencyConfigLike, } from '@grafana/scenes'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; -import { getMultiVariableValues } from '../utils/utils'; +import { getMultiVariableValues, getQueryRunnerFor } from '../utils/utils'; import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer'; import { LibraryVizPanel } from './LibraryVizPanel'; @@ -45,10 +46,7 @@ export class DashboardGridItem extends SceneObjectBase i private _libPanelSubscription: Unsubscribable | undefined; private _prevRepeatValues?: VariableValueSingle[]; - protected _variableDependency = new VariableDependencyConfig(this, { - variableNames: this.state.variableName ? [this.state.variableName] : [], - onVariableUpdateCompleted: this._onVariableUpdateCompleted.bind(this), - }); + protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); public constructor(state: DashboardGridItemState) { super(state); @@ -59,7 +57,7 @@ export class DashboardGridItem extends SceneObjectBase i private _activationHandler() { if (this.state.variableName) { this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); - this._performRepeat(); + this.performRepeat(); } // Subscriptions that handles body updates, i.e. VizPanel -> LibraryVizPanel, AddLibPanelWidget -> LibraryVizPanel @@ -92,23 +90,16 @@ export class DashboardGridItem extends SceneObjectBase i this._libPanelSubscription = panel.subscribeToState((newState) => { if (newState._loadedPanel?.model.repeat) { - this._variableDependency.setVariableNames([newState._loadedPanel.model.repeat]); this.setState({ variableName: newState._loadedPanel.model.repeat, repeatDirection: newState._loadedPanel.model.repeatDirection, maxPerRow: newState._loadedPanel.model.maxPerRow, }); - this._performRepeat(); + this.performRepeat(); } }); } - private _onVariableUpdateCompleted(): void { - if (this.state.variableName) { - this._performRepeat(); - } - } - /** * Uses the current repeat item count to calculate the user intended desired itemHeight */ @@ -134,11 +125,12 @@ export class DashboardGridItem extends SceneObjectBase i } } - private _performRepeat() { + public performRepeat() { if (this.state.body instanceof AddLibraryPanelDrawer) { return; } - if (!this.state.variableName || this._variableDependency.hasDependencyInLoadingState()) { + + if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) { return; } @@ -160,6 +152,9 @@ export class DashboardGridItem extends SceneObjectBase i const { values, texts } = getMultiVariableValues(variable); if (isEqual(this._prevRepeatValues, values)) { + // In some cases, like for variables that depend on time range, the panel query runners are waiting for the top level variable to complete + // So even when there was no change in the variable value (like in this case) we need to notify the query runners that the variable has completed it's update + this.notifyRepeatedPanelsWaitingForVariables(variable); return; } @@ -227,6 +222,15 @@ export class DashboardGridItem extends SceneObjectBase i this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } + public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) { + for (const panel of this.state.repeatedPanels ?? []) { + const queryRunner = getQueryRunnerFor(panel); + if (queryRunner) { + queryRunner.variableDependency?.variableUpdateCompleted(variable, false); + } + } + } + public getMaxPerRow(): number { return this.state.maxPerRow ?? 4; } @@ -278,6 +282,34 @@ export class DashboardGridItem extends SceneObjectBase i }; } +export class DashboardGridItemVariableDependencyHandler implements SceneVariableDependencyConfigLike { + constructor(private _gridItem: DashboardGridItem) {} + + getNames(): Set { + if (this._gridItem.state.variableName) { + return new Set([this._gridItem.state.variableName]); + } + + return new Set(); + } + + hasDependencyOn(name: string): boolean { + return this._gridItem.state.variableName === name; + } + + variableUpdateCompleted(variable: SceneVariable, hasChanged: boolean): void { + if (this._gridItem.state.variableName === variable.state.name) { + /** + * We do not really care if the variable has changed or not as we do an equality check in performRepeat + * And this function needs to be called even when variable valued id not change as performRepeat calls + * notifyRepeatedPanelsWaitingForVariables which is needed to notify panels waiting for variable to complete (even when the value did not change) + * This is for scenarios where the variable used for repeating is depending on time range. + */ + this._gridItem.performRepeat(); + } + } +} + function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow: number, itemHeight: number) { return useMemo(() => { const theme = config.theme2; diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index c1a7172044f..276c82e9d5c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -1,10 +1,11 @@ import { AppEvents } from '@grafana/data'; -import { SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneGridLayout, SceneGridRow, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; import { DashboardGridItem } from './DashboardGridItem'; import { DashboardScene } from './DashboardScene'; +import { RowRepeaterBehavior } from './RowRepeaterBehavior'; import { DashboardRepeatsProcessedEvent } from './types'; describe('DashboardSceneUrlSync', () => { @@ -108,6 +109,35 @@ describe('DashboardSceneUrlSync', () => { scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); expect(scene.state.viewPanelScene?.getUrlKey()).toBe('panel-1-clone-1'); }); + + it('should subscribe and update view panel if panel is in a repeated row', () => { + const scene = buildTestScene(); + + // fake adding row panel + const layout = scene.state.body as SceneGridLayout; + layout.setState({ + children: [ + new SceneGridRow({ + $behaviors: [new RowRepeaterBehavior({ variableName: 'test' })], + children: [ + new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + }), + ], + }), + ], + }); + + scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1' }); + + expect(scene.state.viewPanelScene?.getUrlKey()).toBeUndefined(); + + // Verify it subscribes to DashboardRepeatsProcessedEvent + scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); + expect(scene.state.viewPanelScene?.getUrlKey()).toBe('panel-1'); + }); }); function buildTestScene() { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 16189017918..a169d05a60f 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -18,7 +18,13 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getDashboardSceneFor, getLibraryPanel, isPanelClone } from '../utils/utils'; +import { + findVizPanelByKey, + getDashboardSceneFor, + getLibraryPanel, + isPanelClone, + isWithinUnactivatedRepeatRow, +} from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryVizPanel } from './LibraryVizPanel'; @@ -121,6 +127,11 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { return; } + if (isWithinUnactivatedRepeatRow(panel)) { + this._handleViewRepeatClone(values.viewPanel); + return; + } + update.viewPanelScene = new ViewPanelScene({ panelRef: panel.getRef() }); } else if (viewPanelScene && values.viewPanel === null) { update.viewPanelScene = undefined; diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx index 19878903d7d..bddb841cc14 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx @@ -67,6 +67,42 @@ describe('RowRepeaterBehavior', () => { expect(gridItem.state.body?.state.key).toBe('canvas-1-clone-B1'); }); + it('Should update all rows when a panel is added to a clone', async () => { + const originalRow = grid.state.children[1] as SceneGridRow; + const clone1 = grid.state.children[2] as SceneGridRow; + const clone2 = grid.state.children[3] as SceneGridRow; + + expect(originalRow.state.children.length).toBe(1); + expect(clone1.state.children.length).toBe(1); + expect(clone2.state.children.length).toBe(1); + + clone1.setState({ + children: [ + ...clone1.state.children, + new SceneGridItem({ + x: 0, + y: 16, + width: 24, + height: 5, + key: 'griditem-4', + body: new SceneCanvasText({ + text: 'new panel', + }), + }), + ], + }); + + grid.forceRender(); + + // repeater has run so there are new clone row objects + const newClone1 = grid.state.children[2] as SceneGridRow; + const newClone2 = grid.state.children[3] as SceneGridRow; + + expect(originalRow.state.children.length).toBe(2); + expect(newClone1.state.children.length).toBe(2); + expect(newClone2.state.children.length).toBe(2); + }); + it('Should push row at the bottom down', () => { // Should push row at the bottom down const rowAtTheBottom = grid.state.children[6] as SceneGridRow; diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts index 12d11116678..663315e5d04 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts @@ -46,6 +46,48 @@ export class RowRepeaterBehavior extends SceneObjectBase { + const repeatedRows = layout.state.children.filter( + (child) => child instanceof SceneGridRow && child.state.key?.includes(filterKey) + ); + + // go through cloned rows, search for panels that are not clones + for (const row of repeatedRows) { + if (!(row instanceof SceneGridRow)) { + continue; + } + + // if no differences in row children compared to original, then no new panel added to clone + if (row.state.children.length === originalRow.state.children.length) { + continue; + } + + //if there are differences, find the new panel, move it to the original and perform re peat + const gridItem = row.state.children.find((gridItem) => !gridItem.state.key?.includes('clone')); + if (gridItem) { + const newGridItem = gridItem.clone(); + row.setState({ children: row.state.children.filter((item) => item !== gridItem) }); + + // if we are moving a panel from the origin row to a clone row, we just return + // this means we are modifying the origin row, retriggering the repeat and losing that panel + if (originalRow.state.children.find((item) => item.state.key === newGridItem.state.key)) { + return; + } + + originalRow.setState({ children: [...originalRow.state.children, newGridItem] }); + this.performRepeat(true); + } + } + }); + + return () => { + sub.unsubscribe(); + }; } private _getRow(): SceneGridRow { @@ -66,7 +108,7 @@ export class RowRepeaterBehavior extends SceneObjectBase 0) { ensureUniqueKeys(itemClone, localValue); + //disallow clones to be dragged around or out of the row if (itemClone instanceof DashboardGridItem) { + itemClone.setState({ + isDraggable: false, + }); + itemClone.state.body.setState({ menu: new VizPanelMenu({ $behaviors: [repeatPanelMenuBehavior], diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts index dbbf225c129..00150b02a0a 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts @@ -1,4 +1,5 @@ import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { AdHocFiltersVariable, dataLayers, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; import { AdHocFilterItem, PanelContext } from '@grafana/ui'; @@ -160,7 +161,9 @@ export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceR const newVariable = new AdHocFiltersVariable({ name: 'Filters', datasource: ds, + supportsMultiValueOperators: Boolean(getDataSourceSrv().getInstanceSettings(ds)?.meta.multiValueFilterOperators), useQueriesAsFilterForOptions: true, + layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined, }); // Add it to the scene diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index 9b02fcc2f6a..042d2cd2ce7 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -136,6 +136,8 @@ jest.mock('@grafana/runtime', () => ({ toDataQuery: (q: StandardVariableQuery) => q, }, }), + // mock getInstanceSettings() + getInstanceSettings: jest.fn(), }), getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { return runRequestMock(ds, request); diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx index 105f747a867..d464023a07a 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx @@ -1,7 +1,18 @@ import { map, of } from 'rxjs'; +import { MockDataSourceApi } from 'test/mocks/datasource_srv'; -import { AnnotationQuery, DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data'; +import { + AnnotationQuery, + DataQueryRequest, + DataSourceApi, + DataSourceInstanceSettings, + DataSourcePluginMeta, + getDataSourceUID, + LoadingState, + PanelData, +} from '@grafana/data'; import { SceneGridLayout, SceneTimeRange, dataLayers } from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; @@ -29,11 +40,49 @@ const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: ); }); +const noAnnotationsDsInstanceSettings = { + name: 'noAnnotationsDs', + uid: 'noAnnotationsDs', + meta: { + annotations: false, + } as DataSourcePluginMeta, + readOnly: false, + type: 'noAnnotations', +} as DataSourceInstanceSettings; + +const grafanaDsInstanceSettings = { + name: 'Grafana', + uid: '-- Grafana --', + meta: { + annotations: true, + } as DataSourcePluginMeta, + readOnly: false, + type: 'grafana', +} as DataSourceInstanceSettings; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getDataSourceSrv: () => { return { - getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }), + //return default datasource when no ref is provided + getInstanceSettings: (ref: DataSourceRef | string | null) => { + if (!ref) { + return new MockDataSourceApi(noAnnotationsDsInstanceSettings); + } + if (getDataSourceUID(ref) === '-- Grafana --') { + return new MockDataSourceApi(grafanaDsInstanceSettings); + } + return jest.fn().mockResolvedValue({ uid: 'ds1' }); + }, + get: (ref: DataSourceRef) => { + if (getDataSourceUID(ref) === 'noAnnotationsDs') { + return Promise.resolve(new MockDataSourceApi(noAnnotationsDsInstanceSettings)); + } + if (getDataSourceUID(ref) === '-- Grafana --') { + return Promise.resolve(new MockDataSourceApi(grafanaDsInstanceSettings)); + } + return Promise.resolve(new MockDataSourceApi('ds1')); + }, }; }, getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { @@ -52,17 +101,23 @@ describe('AnnotationsEditView', () => { beforeEach(async () => { const result = await buildTestScene(); annotationsView = result.annotationsView; + jest.spyOn(console, 'error').mockImplementation(); }); it('should return the correct urlKey', () => { expect(annotationsView.getUrlKey()).toBe('annotations'); }); + it('should return undefined when datasource does not support annotations', () => { + const ds = annotationsView.getDataSourceRefForAnnotation(); + expect(ds).toBe(undefined); + expect(console.error).toHaveBeenCalledWith('Default datasource does not support annotations'); + }); + it('should add a new annotation and group it with the other annotations', () => { const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard()); expect(dataLayers?.state.annotationLayers.length).toBe(1); - annotationsView.onNew(); expect(dataLayers?.state.annotationLayers.length).toBe(2); diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx index 40d836c7148..72510de5130 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx @@ -1,4 +1,4 @@ -import { AnnotationQuery, NavModel, NavModelItem, PageLayoutType, getDataSourceRef } from '@grafana/data'; +import { AnnotationQuery, getDataSourceRef, NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, VizPanel, dataLayers } from '@grafana/scenes'; import { Page } from 'app/core/components/Page/Page'; @@ -51,11 +51,23 @@ export class AnnotationsEditView extends SceneObjectBase { + public getDataSourceRefForAnnotation = () => { + // get current default datasource ref from instance settings + // null is passed to get the default datasource + const defaultInstanceDS = getDataSourceSrv().getInstanceSettings(null); + // check for an annotation flag in the plugin json to see if it supports annotations + if (!defaultInstanceDS || !defaultInstanceDS.meta.annotations) { + console.error('Default datasource does not support annotations'); + return undefined; + } + return getDataSourceRef(defaultInstanceDS); + }; + + public onNew = async () => { const newAnnotationQuery: AnnotationQuery = { name: newAnnotationName, enable: true, - datasource: getDataSourceRef(getDataSourceSrv().getInstanceSettings(null)!), + datasource: this.getDataSourceRefForAnnotation(), iconColor: 'red', }; @@ -69,7 +81,6 @@ export class AnnotationsEditView extends SceneObjectBase + {!ds?.meta.annotations && ( + + + The selected data source does not support annotations. Please select a different data source. + + + )} b instanceof RowRepeaterBehavior)); + const hasVariables = !!(row.state.$variables && row.state.$variables.state.variables.length > 0); + + return hasBehavior && !hasVariables; +} diff --git a/public/app/features/dashboard-scene/utils/variables.test.ts b/public/app/features/dashboard-scene/utils/variables.test.ts index cc97261c95b..0f3616b2f75 100644 --- a/public/app/features/dashboard-scene/utils/variables.test.ts +++ b/public/app/features/dashboard-scene/utils/variables.test.ts @@ -26,6 +26,14 @@ import { NEW_LINK } from '../settings/links/utils'; import { createSceneVariableFromVariableModel, createVariablesForSnapshot } from './variables'; +// mock getDataSourceSrv.getInstanceSettings() +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn(), + }), +})); + describe('when creating variables objects', () => { it('should migrate custom variable', () => { const variable: CustomVariableModel = { @@ -425,6 +433,7 @@ describe('when creating variables objects', () => { datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, applyMode: 'auto', useQueriesAsFilterForOptions: true, + supportsMultiValueOperators: false, }); }); @@ -508,6 +517,7 @@ describe('when creating variables objects', () => { }, ], useQueriesAsFilterForOptions: true, + supportsMultiValueOperators: false, }); }); diff --git a/public/app/features/dashboard-scene/utils/variables.ts b/public/app/features/dashboard-scene/utils/variables.ts index 3eefdc84fc3..7421ce979fa 100644 --- a/public/app/features/dashboard-scene/utils/variables.ts +++ b/public/app/features/dashboard-scene/utils/variables.ts @@ -1,5 +1,5 @@ import { TypedVariableModel } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { AdHocFiltersVariable, ConstantVariable, @@ -56,6 +56,10 @@ export function createVariablesForSnapshot(oldModel: DashboardModel) { baseFilters: v.baseFilters ?? [], defaultKeys: v.defaultKeys, useQueriesAsFilterForOptions: true, + layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined, + supportsMultiValueOperators: Boolean( + getDataSourceSrv().getInstanceSettings(v.datasource)?.meta.multiValueFilterOperators + ), }); } // for other variable types we are using the SnapshotVariable @@ -133,6 +137,10 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode baseFilters: variable.baseFilters ?? [], defaultKeys: variable.defaultKeys, useQueriesAsFilterForOptions: true, + layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined, + supportsMultiValueOperators: Boolean( + getDataSourceSrv().getInstanceSettings(variable.datasource)?.meta.multiValueFilterOperators + ), }); } if (variable.type === 'custom') { diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx index 645d74a6ec4..6349e5ccd18 100644 --- a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx @@ -24,9 +24,11 @@ import { Select, useStyles2, Stack, + Alert, } from '@grafana/ui'; import { ColorValueEditor } from 'app/core/components/OptionsUI/color'; import config from 'app/core/config'; +import { Trans } from 'app/core/internationalization'; import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor'; import { AngularEditorLoader } from 'app/features/dashboard-scene/settings/annotations/AngularEditorLoader'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; @@ -189,6 +191,13 @@ export const AnnotationSettingsEdit = ({ editIdx, dashboard }: Props) => { + {!ds?.meta.annotations && ( + + + The selected data source does not support annotations. Please select a different data source. + + + )} diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 4e178bfe8a6..7a1f01125f0 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -1,7 +1,6 @@ import { screen, waitFor } from '@testing-library/react'; import { KBarProvider } from 'kbar'; import { Component } from 'react'; -import { match } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { render } from 'test/test-utils'; @@ -101,7 +100,7 @@ function setup(propOverrides?: Partial) { const props: Props = { ...getRouteComponentProps({ - match: { params: { slug: 'my-dash', uid: '11' } } as unknown as match, + match: { params: { slug: 'my-dash', uid: '11' }, isExact: false, path: '', url: '' }, route: { routeName: DashboardRoutes.Normal } as RouteDescriptor, }), navIndex: { @@ -271,7 +270,7 @@ describe('DashboardPage', () => { const { rerender } = setup(); rerender({ dashboard: getTestDashboard() }); rerender({ - match: { params: { uid: 'new-uid' } } as unknown as match, + match: { params: { uid: 'new-uid' }, isExact: false, path: '', url: '' }, dashboard: getTestDashboard({ title: 'Another dashboard' }), }); await waitFor(() => { diff --git a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx index 97932973433..29753f6275a 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react'; import { Component } from 'react'; -import { match } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { Dashboard } from '@grafana/schema'; @@ -75,7 +74,10 @@ function soloPanelPageScenario(description: string, scenarioFn: (ctx: ScenarioCo ...getRouteComponentProps({ match: { params: { slug: 'my-dash', uid: '11' }, - } as unknown as match, + isExact: false, + path: '', + url: '', + }, queryParams: { panelId: '1', }, diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx index 69fd9176a8e..a204d4f4278 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx @@ -20,12 +20,15 @@ export const getStyles = (theme: GrafanaTheme2) => { gap: '4px', marginBottom: '10px', }), + feedbackContainer: css({ + color: theme.colors.text.link, + }), feedback: css({ - margin: '6px 6px 6px 0', - color: theme.colors.text.secondary, + margin: '6px', + color: theme.colors.text.link, fontSize: theme.typography.bodySmall.fontSize, '&:hover': { - color: theme.colors.text.link, + textDecoration: 'underline', }, }), }; @@ -63,15 +66,18 @@ export default function TracePageActions(props: TracePageActionsProps) { return (
                                                                          - - Give feedback - + { expect(extensions).toEqual([ { - type: 'link', title: 'Add to dashboard', description: 'Use the query and panel from explore and create/add it to a dashboard', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'apps', configure: expect.any(Function), onClick: expect.any(Function), category: 'Dashboards', }, { - type: 'link', title: 'Add correlation', description: 'Create a correlation from this query', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'link', configure: expect.any(Function), onClick: expect.any(Function), diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx index a23cf1fc3a8..d842e451aa0 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -1,9 +1,9 @@ -import { PluginExtensionPoints, type PluginExtensionLinkConfig } from '@grafana/data'; +import { PluginExtensionAddedLinkConfig, PluginExtensionPoints } from '@grafana/data'; import { contextSrv } from 'app/core/core'; import { dispatch } from 'app/store/store'; import { AccessControlAction } from 'app/types'; -import { createExtensionLinkConfig, logWarning } from '../../plugins/extensions/utils'; +import { createAddedLinkConfig, logWarning } from '../../plugins/extensions/utils'; import { changeCorrelationEditorDetails } from '../state/main'; import { runQueries } from '../state/query'; @@ -11,13 +11,13 @@ import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm'; import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle'; import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint'; -export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] { +export function getExploreExtensionConfigs(): PluginExtensionAddedLinkConfig[] { try { return [ - createExtensionLinkConfig({ + createAddedLinkConfig({ title: 'Add to dashboard', description: 'Use the query and panel from explore and create/add it to a dashboard', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'apps', category: 'Dashboards', configure: () => { @@ -39,10 +39,10 @@ export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] { }); }, }), - createExtensionLinkConfig({ + createAddedLinkConfig({ title: 'Add correlation', description: 'Create a correlation from this query', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'link', configure: (context) => { return context?.shouldShowAddCorrelation ? {} : undefined; diff --git a/public/app/features/invites/SignupInvited.test.tsx b/public/app/features/invites/SignupInvited.test.tsx index f352df4e701..295b0779a95 100644 --- a/public/app/features/invites/SignupInvited.test.tsx +++ b/public/app/features/invites/SignupInvited.test.tsx @@ -1,7 +1,6 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { match } from 'react-router-dom'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render } from 'test/test-utils'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; @@ -40,15 +39,14 @@ async function setupTestContext({ get = defaultGet }: { get?: typeof defaultGet ...getRouteComponentProps({ match: { params: { code: 'some code' }, - } as unknown as match, + isExact: false, + path: '', + url: '', + }, }), }; - render( - - - - ); + render(); await waitFor(() => expect(getSpy).toHaveBeenCalled()); expect(getSpy).toHaveBeenCalledTimes(1); diff --git a/public/app/features/playlist/PlaylistTableRows.tsx b/public/app/features/playlist/PlaylistTableRows.tsx index 630936b3e90..34de08eec1f 100644 --- a/public/app/features/playlist/PlaylistTableRows.tsx +++ b/public/app/features/playlist/PlaylistTableRows.tsx @@ -102,30 +102,31 @@ export const PlaylistTableRows = ({ items, onDelete }: Props) => { function getStyles(theme: GrafanaTheme2) { return { - row: css` - padding: 6px; - background: ${theme.colors.background.secondary}; - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 3px; + row: css({ + padding: theme.spacing(0.75), + background: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: '3px', - border: 1px solid ${theme.colors.border.medium}; - &:hover { - border: 1px solid ${theme.colors.border.strong}; - } - `, - rightMargin: css` - margin-right: 5px; - `, - actions: css` - align-items: center; - justify-content: center; - display: flex; - `, - settings: css` - label: settings; - text-align: right; - `, + border: `1px solid ${theme.colors.border.medium}`, + '&:hover': { + border: `1px solid ${theme.colors.border.strong}`, + }, + }), + rightMargin: css({ + marginRight: '5px', + }), + actions: css({ + alignItems: 'center', + justifyContent: 'center', + display: 'flex', + }), + settings: css({ + label: 'settings', + textAlign: 'right', + }), }; } diff --git a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts index 9d4578febf0..5429ada00bf 100644 --- a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts +++ b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts @@ -1,6 +1,6 @@ -import { type PluginExtensionLinkConfig } from '@grafana/data'; +import { PluginExtensionAddedLinkConfig } from '@grafana/data'; import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs'; -export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] { +export function getCoreExtensionConfigurations(): PluginExtensionAddedLinkConfig[] { return [...getExploreExtensionConfigs()]; } diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index d055c74d060..b3af3582ea7 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -1,16 +1,11 @@ import * as React from 'react'; -import { - PluginAddedComponentConfig, - PluginExtensionComponentConfig, - PluginExtensionLinkConfig, - PluginExtensionTypes, -} from '@grafana/data'; +import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { getPluginExtensions } from './getPluginExtensions'; -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { isReadOnlyProxy } from './utils'; import { assertPluginExtensionLink } from './validators'; @@ -24,19 +19,17 @@ jest.mock('@grafana/runtime', () => { async function createRegistries( preloadResults: Array<{ pluginId: string; - addedComponentConfigs: PluginAddedComponentConfig[]; - extensionConfigs: any[]; + addedComponentConfigs: PluginExtensionAddedComponentConfig[]; + addedLinkConfigs: PluginExtensionAddedLinkConfig[]; }> ) { - const registry = new ReactivePluginExtensionsRegistry(); + const addedLinksRegistry = new AddedLinksRegistry(); const addedComponentsRegistry = new AddedComponentsRegistry(); - for (const { pluginId, extensionConfigs, addedComponentConfigs } of preloadResults) { - registry.register({ + for (const { pluginId, addedLinkConfigs, addedComponentConfigs } of preloadResults) { + addedLinksRegistry.register({ pluginId, - exposedComponentConfigs: [], - extensionConfigs, - addedComponentConfigs: [], + configs: addedLinkConfigs, }); addedComponentsRegistry.register({ pluginId, @@ -44,39 +37,41 @@ async function createRegistries( }); } - return { registry: await registry.getRegistry(), addedComponentsRegistry: await addedComponentsRegistry.getState() }; + return { + addedLinksRegistry: await addedLinksRegistry.getState(), + addedComponentsRegistry: await addedComponentsRegistry.getState(), + }; } describe('getPluginExtensions()', () => { - const extensionPoint1 = 'grafana/dashboard/panel/menu'; - const extensionPoint2 = 'plugins/myorg-basic-app/start'; - const extensionPoint3 = 'grafana/datasources/config'; + const extensionPoint1 = 'grafana/dashboard/panel/menu/v1'; + const extensionPoint2 = 'plugins/myorg-basic-app/start/v1'; + const extensionPoint3 = 'grafana/datasources/config/v1'; const pluginId = 'grafana-basic-app'; // Sample extension configs that are used in the tests below - let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig, component1: PluginExtensionComponentConfig; + let link1: PluginExtensionAddedLinkConfig, + link2: PluginExtensionAddedLinkConfig, + component1: PluginExtensionAddedComponentConfig; beforeEach(() => { link1 = { - type: PluginExtensionTypes.link, title: 'Link 1', description: 'Link 1 description', path: `/a/${pluginId}/declare-incident`, - extensionPointId: extensionPoint1, + targets: extensionPoint1, configure: jest.fn().mockReturnValue({}), }; link2 = { - type: PluginExtensionTypes.link, title: 'Link 2', description: 'Link 2 description', path: `/a/${pluginId}/declare-incident`, - extensionPointId: extensionPoint2, + targets: extensionPoint2, configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), }; component1 = { - type: PluginExtensionTypes.component, title: 'Component 1', description: 'Component 1 description', - extensionPointId: extensionPoint3, + targets: extensionPoint3, component: (context) => { return
                                                                          Hello world!
                                                                          ; }, @@ -88,7 +83,7 @@ describe('getPluginExtensions()', () => { test('should return the extensions for the given placement', async () => { const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] }, ]); const { extensions } = getPluginExtensions({ ...registries, @@ -99,7 +94,6 @@ describe('getPluginExtensions()', () => { expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.link, title: link1.title, description: link1.description, path: expect.stringContaining(link1.path!), @@ -110,7 +104,7 @@ describe('getPluginExtensions()', () => { test('should not limit the number of extensions per plugin by default', async () => { // Registering 3 extensions for the same plugin for the same placement const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, ]); const { extensions } = getPluginExtensions({ ...registries, @@ -121,7 +115,6 @@ describe('getPluginExtensions()', () => { expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.link, title: link1.title, description: link1.description, path: expect.stringContaining(link1.path!), @@ -131,11 +124,11 @@ describe('getPluginExtensions()', () => { test('should be possible to limit the number of extensions per plugin for a given placement', async () => { const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, { pluginId: 'my-plugin', addedComponentConfigs: [], - extensionConfigs: [ + addedLinkConfigs: [ { ...link1, path: '/a/my-plugin/declare-incident' }, { ...link1, path: '/a/my-plugin/declare-incident' }, { ...link1, path: '/a/my-plugin/declare-incident' }, @@ -155,7 +148,6 @@ describe('getPluginExtensions()', () => { expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.link, title: link1.title, description: link1.description, path: expect.stringContaining(link1.path!), @@ -165,7 +157,7 @@ describe('getPluginExtensions()', () => { test('should return with an empty list if there are no extensions registered for a placement yet', async () => { const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] }, ]); const { extensions } = getPluginExtensions({ ...registries, @@ -177,7 +169,7 @@ describe('getPluginExtensions()', () => { test('should pass the context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); @@ -194,7 +186,7 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2, @@ -220,7 +212,7 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2, @@ -231,7 +223,7 @@ describe('getPluginExtensions()', () => { expect(link2.configure).toHaveBeenCalledTimes(1); expect(extension.path).toBe( - `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart` + `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart%2Fv1` ); }); @@ -248,7 +240,7 @@ describe('getPluginExtensions()', () => { title: 'test', })); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2, @@ -265,7 +257,7 @@ describe('getPluginExtensions()', () => { }); test('should pass a read only context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, context, @@ -289,7 +281,7 @@ describe('getPluginExtensions()', () => { throw new Error('Something went wrong!'); }); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); expect(() => { getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); @@ -309,7 +301,7 @@ describe('getPluginExtensions()', () => { })); const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] }, ]); const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ ...registries, @@ -336,7 +328,7 @@ describe('getPluginExtensions()', () => { link2.configure = jest.fn().mockImplementation(() => overrides); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -347,7 +339,7 @@ describe('getPluginExtensions()', () => { test('should skip the extension if the configure() function returns a promise', async () => { link2.configure = jest.fn().mockImplementation(() => Promise.resolve({})); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -358,7 +350,7 @@ describe('getPluginExtensions()', () => { test('should skip (hide) the extension if the configure() function returns undefined', async () => { link2.configure = jest.fn().mockImplementation(() => undefined); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -372,7 +364,7 @@ describe('getPluginExtensions()', () => { }); const context = {}; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -395,7 +387,7 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn().mockRejectedValue(new Error('testing')); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -414,7 +406,7 @@ describe('getPluginExtensions()', () => { throw new Error('Something went wrong!'); }); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -432,7 +424,7 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn(); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -455,7 +447,7 @@ describe('getPluginExtensions()', () => { array: ['a'], }; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); expect(() => { @@ -471,7 +463,7 @@ describe('getPluginExtensions()', () => { const registries = await createRegistries([ { pluginId, - extensionConfigs: [ + addedLinkConfigs: [ { ...link1, path: undefined, @@ -501,22 +493,19 @@ describe('getPluginExtensions()', () => { const registries = await createRegistries([ { pluginId, - extensionConfigs: [], - addedComponentConfigs: [ - { - ...component1, - targets: component1.extensionPointId, - }, - ], + addedLinkConfigs: [], + addedComponentConfigs: [component1], }, ]); - const { extensions } = getPluginExtensions({ ...registries, extensionPointId: component1.extensionPointId }); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets, + }); expect(extensions).toHaveLength(1); expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.component, title: component1.title, description: component1.description, }) @@ -527,16 +516,13 @@ describe('getPluginExtensions()', () => { const registries = await createRegistries([ { pluginId, - extensionConfigs: [], + addedLinkConfigs: [], addedComponentConfigs: [ - { - ...component1, - targets: component1.extensionPointId, - }, + component1, { title: 'Component 2', description: 'Component 2 description', - targets: component1.extensionPointId, + targets: component1.targets, component: (context) => { return
                                                                          Hello world2!
                                                                          ; }, @@ -547,14 +533,13 @@ describe('getPluginExtensions()', () => { const { extensions } = getPluginExtensions({ ...registries, limitPerPlugin: 1, - extensionPointId: component1.extensionPointId, + extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets, }); expect(extensions).toHaveLength(1); expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.component, title: component1.title, description: component1.description, }) diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 6560ca8e393..241f26ca15f 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -4,58 +4,53 @@ import { type PluginExtension, PluginExtensionTypes, type PluginExtensionLink, - type PluginExtensionLinkConfig, type PluginExtensionComponent, - urlUtil, } from '@grafana/data'; -import { GetPluginExtensions, reportInteraction } from '@grafana/runtime'; +import { GetPluginExtensions } from '@grafana/runtime'; -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; -import type { AddedComponentsRegistryState, PluginExtensionRegistry } from './types'; +import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; +import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; +import { RegistryType } from './registry/Registry'; +import type { PluginExtensionRegistries } from './registry/types'; import { - isPluginExtensionLinkConfig, getReadOnlyProxy, logWarning, generateExtensionId, - getEventHelpers, wrapWithPluginContext, + getLinkExtensionOnClick, + getLinkExtensionOverrides, + getLinkExtensionPathWithTracking, } from './utils'; -import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; type GetExtensions = ({ context, extensionPointId, limitPerPlugin, - registry, + addedLinksRegistry, addedComponentsRegistry, }: { context?: object | Record; extensionPointId: string; limitPerPlugin?: number; - registry: PluginExtensionRegistry; - addedComponentsRegistry: AddedComponentsRegistryState; + addedComponentsRegistry: RegistryType | undefined; + addedLinksRegistry: RegistryType | undefined; }) => { extensions: PluginExtension[] }; -export function createPluginExtensionsGetter( - extensionRegistry: ReactivePluginExtensionsRegistry, - addedComponentRegistry: AddedComponentsRegistry -): GetPluginExtensions { - let registry: PluginExtensionRegistry = { id: '', extensions: {} }; - let addedComponentsRegistryState: AddedComponentsRegistryState = {}; +export function createPluginExtensionsGetter(registries: PluginExtensionRegistries): GetPluginExtensions { + let addedComponentsRegistry: RegistryType; + let addedLinksRegistry: RegistryType>>; - // Create a subscription to keep an copy of the registry state for use in the non-async + // Create registry subscriptions to keep an copy of the registry state for use in the non-async // plugin extensions getter. - extensionRegistry.asObservable().subscribe((r) => { - registry = r; + registries.addedComponentsRegistry.asObservable().subscribe((componentsRegistry) => { + addedComponentsRegistry = componentsRegistry; }); - addedComponentRegistry.asObservable().subscribe((r) => { - addedComponentsRegistryState = r; + registries.addedLinksRegistry.asObservable().subscribe((linksRegistry) => { + addedLinksRegistry = linksRegistry; }); - return (options) => - getPluginExtensions({ ...options, registry, addedComponentsRegistry: addedComponentsRegistryState }); + return (options) => getPluginExtensions({ ...options, addedComponentsRegistry, addedLinksRegistry }); } // Returns with a list of plugin extensions for the given extension point @@ -63,20 +58,17 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, - registry, + addedLinksRegistry, addedComponentsRegistry, }) => { const frozenContext = context ? getReadOnlyProxy(context) : {}; - const registryItems = registry.extensions[extensionPointId] ?? []; // We don't return the extensions separated by type, because in that case it would be much harder to define a sort-order for them. const extensions: PluginExtension[] = []; const extensionsByPlugin: Record = {}; - for (const registryItem of registryItems) { + for (const addedLink of addedLinksRegistry?.[extensionPointId] ?? []) { try { - const extensionConfig = registryItem.config; - const { pluginId } = registryItem; - + const { pluginId } = addedLink; // Only limit if the `limitPerPlugin` is set if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { continue; @@ -86,34 +78,35 @@ export const getPluginExtensions: GetExtensions = ({ extensionsByPlugin[pluginId] = 0; } - // LINK - if (isPluginExtensionLinkConfig(extensionConfig)) { - // Run the configure() function with the current context, and apply the ovverides - const overrides = getLinkExtensionOverrides(pluginId, extensionConfig, frozenContext); + // Run the configure() function with the current context, and apply the ovverides + const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext); - // configure() returned an `undefined` -> hide the extension - if (extensionConfig.configure && overrides === undefined) { - continue; - } - - const path = overrides?.path || extensionConfig.path; - const extension: PluginExtensionLink = { - id: generateExtensionId(pluginId, extensionConfig), - type: PluginExtensionTypes.link, - pluginId: pluginId, - onClick: getLinkExtensionOnClick(pluginId, extensionConfig, frozenContext), - - // Configurable properties - icon: overrides?.icon || extensionConfig.icon, - title: overrides?.title || extensionConfig.title, - description: overrides?.description || extensionConfig.description, - path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionConfig) : undefined, - category: overrides?.category || extensionConfig.category, - }; - - extensions.push(extension); - extensionsByPlugin[pluginId] += 1; + // configure() returned an `undefined` -> hide the extension + if (addedLink.configure && overrides === undefined) { + continue; } + + const path = overrides?.path || addedLink.path; + const extension: PluginExtensionLink = { + id: generateExtensionId(pluginId, { + ...addedLink, + extensionPointId, + type: PluginExtensionTypes.link, + }), + type: PluginExtensionTypes.link, + pluginId: pluginId, + onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), + + // Configurable properties + icon: overrides?.icon || addedLink.icon, + title: overrides?.title || addedLink.title, + description: overrides?.description || addedLink.description, + path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, + category: overrides?.category || addedLink.category, + }; + + extensions.push(extension); + extensionsByPlugin[pluginId] += 1; } catch (error) { if (error instanceof Error) { logWarning(error.message); @@ -121,139 +114,32 @@ export const getPluginExtensions: GetExtensions = ({ } } - if (extensionPointId in addedComponentsRegistry) { - try { - const addedComponents = addedComponentsRegistry[extensionPointId]; - for (const addedComponent of addedComponents) { - // Only limit if the `limitPerPlugin` is set - if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) { - continue; - } - - if (extensionsByPlugin[addedComponent.pluginId] === undefined) { - extensionsByPlugin[addedComponent.pluginId] = 0; - } - const extension: PluginExtensionComponent = { - id: generateExtensionId(addedComponent.pluginId, { - ...addedComponent, - extensionPointId, - type: PluginExtensionTypes.component, - }), - type: PluginExtensionTypes.component, - pluginId: addedComponent.pluginId, - title: addedComponent.title, - description: addedComponent.description, - component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component), - }; - - extensions.push(extension); - extensionsByPlugin[addedComponent.pluginId] += 1; - } - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } + const addedComponents = addedComponentsRegistry?.[extensionPointId] ?? []; + for (const addedComponent of addedComponents) { + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) { + continue; } + + if (extensionsByPlugin[addedComponent.pluginId] === undefined) { + extensionsByPlugin[addedComponent.pluginId] = 0; + } + const extension: PluginExtensionComponent = { + id: generateExtensionId(addedComponent.pluginId, { + ...addedComponent, + extensionPointId, + type: PluginExtensionTypes.component, + }), + type: PluginExtensionTypes.component, + pluginId: addedComponent.pluginId, + title: addedComponent.title, + description: addedComponent.description, + component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component), + }; + + extensions.push(extension); + extensionsByPlugin[addedComponent.pluginId] += 1; } return { extensions }; }; - -function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLinkConfig, context?: object) { - try { - const overrides = config.configure?.(context); - - // Hiding the extension - if (overrides === undefined) { - return undefined; - } - - let { - title = config.title, - description = config.description, - path = config.path, - icon = config.icon, - category = config.category, - ...rest - } = overrides; - - assertIsNotPromise( - overrides, - `The configure() function for "${config.title}" returned a promise, skipping updates.` - ); - - path && assertLinkPathIsValid(pluginId, path); - assertStringProps({ title, description }, ['title', 'description']); - - if (Object.keys(rest).length > 0) { - logWarning( - `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join( - ', ' - )} which will be ignored.` - ); - } - - return { - title, - description, - path, - icon, - category, - }; - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } - - // If there is an error, we hide the extension - // (This seems to be safest option in case the extension is doing something wrong.) - return undefined; - } -} - -function getLinkExtensionOnClick( - pluginId: string, - config: PluginExtensionLinkConfig, - context?: object -): ((event?: React.MouseEvent) => void) | undefined { - const { onClick } = config; - - if (!onClick) { - return; - } - - return function onClickExtensionLink(event?: React.MouseEvent) { - try { - reportInteraction('ui_extension_link_clicked', { - pluginId: pluginId, - extensionPointId: config.extensionPointId, - title: config.title, - category: config.category, - }); - - const result = onClick(event, getEventHelpers(pluginId, context)); - - if (isPromise(result)) { - result.catch((e) => { - if (e instanceof Error) { - logWarning(e.message); - } - }); - } - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } - } - }; -} - -function getLinkExtensionPathWithTracking(pluginId: string, path: string, config: PluginExtensionLinkConfig): string { - return urlUtil.appendQueryToUrl( - path, - urlUtil.toUrlParams({ - uel_pid: pluginId, - uel_epid: config.extensionPointId, - }) - ); -} diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts deleted file mode 100644 index 5ab6abee781..00000000000 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { firstValueFrom } from 'rxjs'; - -import { PluginExtensionTypes } from '@grafana/data'; - -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; - -describe('createPluginExtensionsRegistry', () => { - const consoleWarn = jest.fn(); - - beforeEach(() => { - global.console.warn = consoleWarn; - consoleWarn.mockReset(); - }); - - it('should return empty registry when no extensions registered', async () => { - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const registry = await firstValueFrom(observable); - expect(registry).toEqual({ - id: '', - extensions: {}, - }); - }); - - it('should generate an id for the registry once we register an extension to it', async () => { - const pluginId = 'grafana-basic-app'; - const extensionPointId = 'grafana/dashboard/panel/menu'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId, - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry = await reactiveRegistry.getRegistry(); - - expect(registry.id).toBeDefined(); - expect(registry.extensions[extensionPointId]).toHaveLength(1); - }); - - it('should generate an a new id every time the registry changes', async () => { - const pluginId = 'grafana-basic-app'; - const extensionPointId = 'grafana/dashboard/panel/menu'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId, - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry1 = await reactiveRegistry.getRegistry(); - const id1 = registry1.id; - - expect(id1).toBeDefined(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId, - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - const id2 = registry2.id; - - expect(id2).toBeDefined(); - expect(id2).not.toEqual(id1); - }); - - it('should be possible to register extensions in the registry', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry = await reactiveRegistry.getRegistry(); - - expect(registry.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - 'plugins/myorg-basic-app/start': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for the same placement (different plugins)', async () => { - const pluginId1 = 'grafana-basic-app'; - const pluginId2 = 'grafana-basic-app2'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: pluginId1, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry1 = await reactiveRegistry.getRegistry(); - - expect(registry1.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - - // Register extensions for the second plugin to a different placement - reactiveRegistry.register({ - pluginId: pluginId2, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - { - pluginId: pluginId2, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for a different placement (different plugin)', async () => { - const pluginId1 = 'grafana-basic-app'; - const pluginId2 = 'grafana-basic-app2'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: pluginId1, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry1 = await reactiveRegistry.getRegistry(); - - expect(registry1.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - - // Register extensions for the second plugin to a different placement - reactiveRegistry.register({ - pluginId: pluginId2, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - 'plugins/myorg-basic-app/start': [ - { - pluginId: pluginId2, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for the same placement (same plugin)', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident-1`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - // Register extensions to a different extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident-2`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident-1`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident-2`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for a different placement (same plugin)', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - // Register extensions to a different extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - 'plugins/myorg-basic-app/start': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should notify subscribers when the registry changes', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - observable.subscribe(subscribeCallback); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(subscribeCallback).toHaveBeenCalledTimes(2); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: 'another-plugin', - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/another-plugin/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(subscribeCallback).toHaveBeenCalledTimes(3); - - const registry = subscribeCallback.mock.calls[2][0]; - - expect(registry.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - { - pluginId: 'another-plugin', - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/another-plugin/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should give the last version of the registry for new subscribers', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - - expect(registry.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should not register extensions for a plugin that had errors', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - error: new Error('Something is broken'), - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); - - it('should not register an extension if it has an invalid configure() function', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - //@ts-ignore - configure: '...', - }, - ], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); - - it('should not register an extension if it has invalid properties (empty title / description)', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: '', - description: '', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); - - it('should not register link extensions with invalid path configured', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Title 1', - description: 'Description 1', - path: `/a/another-plugin/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); -}); diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts deleted file mode 100644 index d5d29cb22e1..00000000000 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; - -import { PluginPreloadResult } from '../pluginPreloader'; - -import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types'; -import { deepFreeze, logWarning } from './utils'; -import { isPluginExtensionConfigValid } from './validators'; - -export class ReactivePluginExtensionsRegistry { - private resultSubject: Subject; - private registrySubject: ReplaySubject; - - constructor() { - this.resultSubject = new Subject(); - // This is the subject that we expose. - // (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.) - this.registrySubject = new ReplaySubject(1); - - this.resultSubject - .pipe( - scan(resultsToRegistry, { id: '', extensions: {} }), - // Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values) - startWith({ id: '', extensions: {} }), - map((registry) => deepFreeze(registry)) - ) - // Emitting the new registry to `this.registrySubject` - .subscribe(this.registrySubject); - } - - register(result: PluginPreloadResult): void { - this.resultSubject.next(result); - } - - asObservable(): Observable { - return this.registrySubject.asObservable(); - } - - getRegistry(): Promise { - return firstValueFrom(this.asObservable()); - } -} - -function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPreloadResult): PluginExtensionRegistry { - const { pluginId, extensionConfigs, error } = result; - - // TODO: We should probably move this section to where we load the plugin since this is only used - // to provide a log to the user. - if (error) { - logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`); - return registry; - } - - for (const extensionConfig of extensionConfigs) { - const { extensionPointId } = extensionConfig; - - // Check if the config is valid - if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) { - return registry; - } - - let registryItem: PluginExtensionRegistryItem = { - config: extensionConfig, - - // Additional meta information about the extension - pluginId, - }; - - if (!Array.isArray(registry.extensions[extensionPointId])) { - registry.extensions[extensionPointId] = [registryItem]; - } else { - registry.extensions[extensionPointId].push(registryItem); - } - } - - // Add a unique ID to the registry (the registry object itself is immutable) - registry.id = uuidv4(); - - return registry; -} diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index 01799ecce62..5d5263b5e95 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -314,7 +314,7 @@ describe('AddedComponentsRegistry', () => { expect(Object.keys(currentState)).toHaveLength(0); }); - it('should log a warning when exposed component id is not suffixed with component version', async () => { + it('should log a warning when added component id is not suffixed with component version', async () => { const registry = new AddedComponentsRegistry(); registry.register({ pluginId: 'grafana-basic-app', @@ -322,14 +322,14 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/alerting/home'], + targets: ['grafana/test/home'], component: () => React.createElement('div', null, 'Hello World1'), }, ], }); expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Added component with id 'grafana/alerting/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." + "[Plugin Extensions] Added component with id 'grafana/test/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index 78420c42a17..954e49b0f34 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -1,7 +1,12 @@ -import { PluginAddedComponentConfig } from '@grafana/data'; +import { PluginExtensionAddedComponentConfig } from '@grafana/data'; import { logWarning, wrapWithPluginContext } from '../utils'; -import { extensionPointEndsWithVersion, isExtensionPointIdValid, isReactComponent } from '../validators'; +import { + extensionPointEndsWithVersion, + isExtensionPointIdValid, + isGrafanaCoreExtensionPoint, + isReactComponent, +} from '../validators'; import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; @@ -12,7 +17,10 @@ export type AddedComponentRegistryItem = { component: React.ComponentType; }; -export class AddedComponentsRegistry extends Registry { +export class AddedComponentsRegistry extends Registry< + AddedComponentRegistryItem[], + PluginExtensionAddedComponentConfig +> { constructor(initialState: RegistryType = {}) { super({ initialState, @@ -21,7 +29,7 @@ export class AddedComponentsRegistry extends Registry, - item: PluginExtensionConfigs + item: PluginExtensionConfigs ): RegistryType { const { pluginId, configs } = item; @@ -52,7 +60,7 @@ export class AddedComponentsRegistry extends Registry { + const consoleWarn = jest.fn(); + + beforeEach(() => { + global.console.warn = consoleWarn; + consoleWarn.mockReset(); + }); + + it('should return empty registry when no extensions registered', async () => { + const addedLinksRegistry = new AddedLinksRegistry(); + const observable = addedLinksRegistry.asObservable(); + const registry = await firstValueFrom(observable); + expect(registry).toEqual({}); + }); + + it('should be possible to register link extensions in the registry', async () => { + const pluginId = 'grafana-basic-app'; + const addedLinksRegistry = new AddedLinksRegistry(); + + addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'plugins/myorg-basic-app/start', + configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), + }, + ], + }); + + const registry = await addedLinksRegistry.getState(); + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'plugins/myorg-basic-app/start', + configure: expect.any(Function), + }, + ], + }); + }); + it('should be possible to asynchronously register link extensions for the same placement (different plugins)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + { + pluginId: pluginId2, + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register link extensions for a different placement (different plugin)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + targets: 'plugins/myorg-basic-app/start', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId2, + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + extensionPointId: 'plugins/myorg-basic-app/start', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register link extensions for the same placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident-1`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident-2`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident-1`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + { + pluginId: pluginId, + + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident-2`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register link extensions for a different placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'plugins/myorg-basic-app/start', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'plugins/myorg-basic-app/start', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should notify subscribers when the registry changes', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: 'another-plugin', + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/another-plugin/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(3); + + const registry = subscribeCallback.mock.calls[2][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + { + pluginId: 'another-plugin', + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/another-plugin/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should give the last version of the registry for new subscribers', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should not register a link extension if it has an invalid configure() function', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + //@ts-ignore + configure: '...', + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register a link extension if it has invalid properties (empty title / description)', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: '', + description: '', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register link extensions with invalid path configured', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Title 1', + description: 'Description 1', + path: `/a/another-plugin/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts new file mode 100644 index 00000000000..30b4ebbdfcc --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -0,0 +1,98 @@ +import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data'; +import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions'; + +import { logWarning } from '../utils'; +import { + extensionPointEndsWithVersion, + isConfigureFnValid, + isExtensionPointIdValid, + isGrafanaCoreExtensionPoint, + isLinkPathValid, +} from '../validators'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +export type AddedLinkRegistryItem = { + pluginId: string; + extensionPointId: string; + title: string; + description: string; + path?: string; + onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + configure?: PluginAddedLinksConfigureFunc; + icon?: IconName; + category?: string; +}; + +export class AddedLinksRegistry extends Registry { + constructor(initialState: RegistryType = {}) { + super({ + initialState, + }); + } + + mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType { + const { pluginId, configs } = item; + + for (const config of configs) { + const { path, title, description, configure, onClick, targets } = config; + if (!title) { + logWarning(`Could not register added link with title '${title}'. Reason: Title is missing.`); + continue; + } + + if (!description) { + logWarning(`Could not register added link with title '${title}'. Reason: Description is missing.`); + continue; + } + + if (!isConfigureFnValid(configure)) { + logWarning(`Could not register added link with title '${title}'. Reason: configure is not a function.`); + continue; + } + + if (!path && !onClick) { + logWarning( + `Could not register added link with title '${title}'. Reason: Either "path" or "onClick" is required.` + ); + continue; + } + + if (path && !isLinkPathValid(pluginId, path)) { + logWarning( + `Could not register added link with title '${title}'. Reason: The "path" is required and should start with "/a/${pluginId}/" (currently: "${path}"). Skipping the extension.` + ); + continue; + } + + const extensionPointIds = Array.isArray(targets) ? targets : [targets]; + for (const extensionPointId of extensionPointIds) { + if (!isExtensionPointIdValid(pluginId, extensionPointId)) { + logWarning( + `Could not register added link with id '${extensionPointId}'. Reason: Target extension point id must start with grafana, plugins or plugin id.` + ); + continue; + } + + if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) { + logWarning( + `Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.` + ); + } + + const { targets, ...registryItem } = config; + + if (!(extensionPointId in registry)) { + registry[extensionPointId] = []; + } + + registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId }); + } + } + + return registry; + } +} diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts index 52b8e97a9c4..40cba5bf44b 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -1,4 +1,4 @@ -import { PluginExposedComponentConfig } from '@grafana/data'; +import { PluginExtensionExposedComponentConfig } from '@grafana/data'; import { logWarning } from '../utils'; import { extensionPointEndsWithVersion } from '../validators'; @@ -12,7 +12,10 @@ export type ExposedComponentRegistryItem = { component: React.ComponentType; }; -export class ExposedComponentsRegistry extends Registry { +export class ExposedComponentsRegistry extends Registry< + ExposedComponentRegistryItem, + PluginExtensionExposedComponentConfig +> { constructor(initialState: RegistryType = {}) { super({ initialState, @@ -21,7 +24,7 @@ export class ExposedComponentsRegistry extends Registry, - { pluginId, configs }: PluginExtensionConfigs + { pluginId, configs }: PluginExtensionConfigs ): RegistryType { if (!configs) { return registry; diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts new file mode 100644 index 00000000000..05fe0f21b7a --- /dev/null +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -0,0 +1,21 @@ +import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './types'; + +export function setupPluginExtensionRegistries(): PluginExtensionRegistries { + const pluginExtensionsRegistries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + }; + + pluginExtensionsRegistries.addedLinksRegistry.register({ + pluginId: 'grafana', + configs: getCoreExtensionConfigurations(), + }); + + return pluginExtensionsRegistries; +} diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts new file mode 100644 index 00000000000..115e859b7d9 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/types.ts @@ -0,0 +1,9 @@ +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; + +export type PluginExtensionRegistries = { + addedComponentsRegistry: AddedComponentsRegistry; + exposedComponentsRegistry: ExposedComponentsRegistry; + addedLinksRegistry: AddedLinksRegistry; +}; diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts deleted file mode 100644 index b5e5d1ebbc8..00000000000 --- a/public/app/features/plugins/extensions/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PluginExtensionConfig } from '@grafana/data'; - -import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; -import { RegistryType } from './registry/Registry'; - -// The information that is stored in the registry -export type PluginExtensionRegistryItem = { - // Any additional meta information that we would like to store about the extension in the registry - pluginId: string; - - config: PluginExtensionConfig; -}; - -// A map of placement names to a list of extensions -export type PluginExtensionRegistry = { - id: string; - extensions: Record; -}; - -export type AddedComponentsRegistryState = RegistryType>>; diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 269baf3df0f..8985d182aea 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -15,7 +15,7 @@ export function createUsePluginComponent(registry: ExposedComponentsRegistry) { const registry = useObservable(observableRegistry); return useMemo(() => { - if (!registry || !registry[id]) { + if (!registry?.[id]) { return { isLoading: false, component: null, diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index db3d1c6710c..b2ad9804d6b 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -19,16 +19,10 @@ export function createUsePluginComponents(registry: AddedComponentsRegistry) { const registry = useObservable(observableRegistry); return useMemo(() => { - if (!registry || !registry[extensionPointId]) { - return { - isLoading: false, - components: [], - }; - } const components: Array> = []; - const registryItems = registry[extensionPointId]; const extensionsByPlugin: Record = {}; - for (const registryItem of registryItems) { + + for (const registryItem of registry?.[extensionPointId] ?? []) { const { pluginId } = registryItem; // Only limit if the `limitPerPlugin` is set diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 886dfecdec8..17ff510f8ef 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -1,26 +1,28 @@ import { act } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; -import { PluginExtensionTypes } from '@grafana/data'; - -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './registry/types'; import { createUsePluginExtensions } from './usePluginExtensions'; describe('usePluginExtensions()', () => { - let reactiveRegistry: ReactivePluginExtensionsRegistry; - let addedComponentsRegistry: AddedComponentsRegistry; + let registries: PluginExtensionRegistries; beforeEach(() => { - reactiveRegistry = new ReactivePluginExtensionsRegistry(); - addedComponentsRegistry = new AddedComponentsRegistry(); + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + }; }); it('should return an empty array if there are no extensions registered for the extension point', () => { - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); const { result } = renderHook(() => usePluginExtensions({ - extensionPointId: 'foo/bar', + extensionPointId: 'foo/bar/v1', }) ); @@ -28,32 +30,28 @@ describe('usePluginExtensions()', () => { }); it('should return the plugin link extensions from the registry', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); expect(result.current.extensions.length).toBe(2); @@ -62,33 +60,29 @@ describe('usePluginExtensions()', () => { }); it('should return the plugin component extensions from the registry', () => { - const linkExtensionPointId = 'plugins/foo/bar'; + const linkExtensionPointId = 'plugins/foo/bar/v1'; const componentExtensionPointId = 'plugins/component/bar/v1'; const pluginId = 'my-app-plugin'; - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId: linkExtensionPointId, + targets: linkExtensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId: linkExtensionPointId, + targets: linkExtensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); - addedComponentsRegistry.register({ + registries.addedComponentsRegistry.register({ pluginId, configs: [ { @@ -106,7 +100,7 @@ describe('usePluginExtensions()', () => { ], }); - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId })); expect(result.current.extensions.length).toBe(2); @@ -115,9 +109,9 @@ describe('usePluginExtensions()', () => { }); it('should dynamically update the extensions registered for a certain extension point', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); // No extensions yet @@ -125,26 +119,22 @@ describe('usePluginExtensions()', () => { // Add extensions to the registry act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); @@ -157,78 +147,81 @@ describe('usePluginExtensions()', () => { }); it('should only render the hook once', () => { - const spy = jest.spyOn(reactiveRegistry, 'asObservable'); - const extensionPointId = 'plugins/foo/bar'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable'); + const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable'); + const extensionPointId = 'plugins/foo/bar/v1'; + const usePluginExtensions = createUsePluginExtensions(registries); renderHook(() => usePluginExtensions({ extensionPointId })); - expect(spy).toHaveBeenCalledTimes(1); + expect(addedComponentsRegistrySpy).toHaveBeenCalledTimes(1); + expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1); }); - it('should return the same extensions object if the context object is the same', () => { - const extensionPointId = 'plugins/foo/bar'; + it('should return the same extensions object if the context object is the same', async () => { + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); // Add extensions to the registry act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); // Check if it returns the same extensions object in case nothing changes const context = {}; - const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context })); - const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context })); - expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(true); + const { rerender, result } = renderHook(usePluginExtensions, { + initialProps: { + extensionPointId, + context, + }, + }); + const firstResult = result.current; + + rerender({ context, extensionPointId }); + const secondResult = result.current; + + expect(firstResult.extensions).toBe(secondResult.extensions); }); it('should return a new extensions object if the context object is different', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); // Add extensions to the registry act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); @@ -239,26 +232,23 @@ describe('usePluginExtensions()', () => { }); it('should return a new extensions object if the registry changes but the context object is the same', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; const context = {}; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); // Add the first extension act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); @@ -267,20 +257,17 @@ describe('usePluginExtensions()', () => { // Add the second extension act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, // extensionPointId: 'plugins/foo/bar/zed', // A different extension point (to be sure that it's also returning a new object when the actual extension point doesn't change) title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index b00d4517265..62e583ad857 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -1,64 +1,40 @@ +import { useMemo } from 'react'; import { useObservable } from 'react-use'; import { PluginExtension } from '@grafana/data'; import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; import { getPluginExtensions } from './getPluginExtensions'; -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { PluginExtensionRegistries } from './registry/types'; -export function createUsePluginExtensions( - extensionsRegistry: ReactivePluginExtensionsRegistry, - addedComponentsRegistry: AddedComponentsRegistry -) { - const observableRegistry = extensionsRegistry.asObservable(); - const observableAddedComponentRegistry = addedComponentsRegistry.asObservable(); - const cache: { - id: string; - extensions: Record; - } = { - id: '', - extensions: {}, - }; +export function createUsePluginExtensions(registries: PluginExtensionRegistries) { + const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable(); + const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable(); return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { - const registry = useObservable(observableRegistry); - const addedComponentsRegistry = useObservable(observableAddedComponentRegistry); + const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); + const addedLinksRegistry = useObservable(observableAddedLinksRegistry); - if (!registry || !addedComponentsRegistry) { - return { extensions: [], isLoading: false }; - } + const { extensions } = useMemo(() => { + if (!addedLinksRegistry && !addedComponentsRegistry) { + return { extensions: [], isLoading: false }; + } - if (registry.id !== cache.id) { - cache.id = registry.id; - cache.extensions = {}; - } - - // `getPluginExtensions` will return a new array of objects even if it is called with the same options, as it always constructing a frozen objects. - // Due to this we are caching the result of `getPluginExtensions` to avoid unnecessary re-renders for components that are using this hook. - // (NOTE: we are only checking referential equality of `context` object, so it is important to not mutate the object passed to this hook.) - const key = `${options.extensionPointId}-${options.limitPerPlugin}`; - if (cache.extensions[key] && cache.extensions[key].context === options.context) { - return { - extensions: cache.extensions[key].extensions, - isLoading: false, - }; - } - - const { extensions } = getPluginExtensions({ - ...options, - registry, + return getPluginExtensions({ + extensionPointId: options.extensionPointId, + context: options.context, + limitPerPlugin: options.limitPerPlugin, + addedComponentsRegistry, + addedLinksRegistry, + }); + }, [ + addedLinksRegistry, addedComponentsRegistry, - }); + options.extensionPointId, + options.context, + options.limitPerPlugin, + ]); - cache.extensions[key] = { - context: options.context, - extensions, - }; - - return { - extensions, - isLoading: false, - }; + return { extensions, isLoading: false }; }; } diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx new file mode 100644 index 00000000000..ceefed61393 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -0,0 +1,118 @@ +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { createUsePluginLinks } from './usePluginLinks'; + +jest.mock('app/features/plugins/pluginSettings', () => ({ + getPluginSettings: jest.fn().mockResolvedValue({ + id: 'my-app-plugin', + enabled: true, + jsonData: {}, + type: 'panel', + name: 'My App Plugin', + module: 'app/plugins/my-app-plugin/module', + }), +})); + +describe('usePluginLinks()', () => { + let registry: AddedLinksRegistry; + + beforeEach(() => { + registry = new AddedLinksRegistry(); + }); + + it('should return an empty array if there are no link extensions registered for the extension point', () => { + const usePluginComponents = createUsePluginLinks(registry); + const { result } = renderHook(() => + usePluginComponents({ + extensionPointId: 'foo/bar', + }) + ); + + expect(result.current.links).toEqual([]); + }); + + it('should only return the link extensions for the given extension point ids', async () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const pluginId = 'my-app-plugin'; + + registry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + targets: extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + { + targets: 'plugins/another-extension/v1', + title: '3', + description: '3', + path: `/a/${pluginId}/3`, + }, + ], + }); + + const usePluginExtensions = createUsePluginLinks(registry); + const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); + + expect(result.current.links.length).toBe(2); + expect(result.current.links[0].title).toBe('1'); + expect(result.current.links[1].title).toBe('2'); + }); + + it('should dynamically update the extensions registered for a certain extension point', () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const pluginId = 'my-app-plugin'; + const usePluginExtensions = createUsePluginLinks(registry); + let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); + + // No extensions yet + expect(result.current.links.length).toBe(0); + + // Add extensions to the registry + act(() => { + registry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + targets: extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + }); + }); + + // Check if the hook returns the new extensions + rerender(); + + expect(result.current.links.length).toBe(2); + expect(result.current.links[0].title).toBe('1'); + expect(result.current.links[1].title).toBe('2'); + }); + + it('should only render the hook once', () => { + const addedLinksRegistrySpy = jest.spyOn(registry, 'asObservable'); + const extensionPointId = 'plugins/foo/bar'; + const usePluginLinks = createUsePluginLinks(registry); + + renderHook(() => usePluginLinks({ extensionPointId })); + expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx new file mode 100644 index 00000000000..0350209636a --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -0,0 +1,90 @@ +import { isString } from 'lodash'; +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; +import { + UsePluginLinksOptions, + UsePluginLinksResult, +} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; + +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { + generateExtensionId, + getLinkExtensionOnClick, + getLinkExtensionOverrides, + getLinkExtensionPathWithTracking, + getReadOnlyProxy, +} from './utils'; + +// Returns an array of component extensions for the given extension point +export function createUsePluginLinks(registry: AddedLinksRegistry) { + const observableRegistry = registry.asObservable(); + + return function usePluginLinks({ + limitPerPlugin, + extensionPointId, + context, + }: UsePluginLinksOptions): UsePluginLinksResult { + const registry = useObservable(observableRegistry); + + return useMemo(() => { + if (!registry || !registry[extensionPointId]) { + return { + isLoading: false, + links: [], + }; + } + const frozenContext = context ? getReadOnlyProxy(context) : {}; + const extensions: PluginExtensionLink[] = []; + const extensionsByPlugin: Record = {}; + + for (const addedLink of registry[extensionPointId] ?? []) { + const { pluginId } = addedLink; + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { + continue; + } + + if (extensionsByPlugin[pluginId] === undefined) { + extensionsByPlugin[pluginId] = 0; + } + + // Run the configure() function with the current context, and apply the ovverides + const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext); + + // configure() returned an `undefined` -> hide the extension + if (addedLink.configure && overrides === undefined) { + continue; + } + + const path = overrides?.path || addedLink.path; + const extension: PluginExtensionLink = { + id: generateExtensionId(pluginId, { + ...addedLink, + extensionPointId, + type: PluginExtensionTypes.link, + }), + type: PluginExtensionTypes.link, + pluginId: pluginId, + onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), + + // Configurable properties + icon: overrides?.icon || addedLink.icon, + title: overrides?.title || addedLink.title, + description: overrides?.description || addedLink.description, + path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, + category: overrides?.category || addedLink.category, + }; + + extensions.push(extension); + extensionsByPlugin[pluginId] += 1; + } + + return { + isLoading: false, + links: extensions, + }; + }, [context, extensionPointId, limitPerPlugin, registry]); + }; +} diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index c0189806dd2..f579138e76a 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -1,18 +1,11 @@ import { render, screen } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; -import { type PluginExtensionLinkConfig, PluginExtensionTypes, dateTime, usePluginContext } from '@grafana/data'; +import { dateTime, usePluginContext } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; -import { - deepFreeze, - isPluginExtensionLinkConfig, - handleErrorsInFn, - getReadOnlyProxy, - getEventHelpers, - wrapWithPluginContext, -} from './utils'; +import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, getEventHelpers, wrapWithPluginContext } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ ...jest.requireActual('app/features/plugins/pluginSettings'), @@ -198,28 +191,6 @@ describe('Plugin Extensions / Utils', () => { }); }); - describe('isPluginExtensionLinkConfig()', () => { - test('should return TRUE if the object is a command extension config', () => { - expect( - isPluginExtensionLinkConfig({ - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtensionLinkConfig) - ).toBe(true); - }); - test('should return FALSE if the object is NOT a link extension', () => { - expect( - isPluginExtensionLinkConfig({ - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtensionLinkConfig) - ).toBe(false); - }); - }); - describe('handleErrorsInFn()', () => { test('should catch errors thrown by the provided function and print them as console warnings', () => { global.console.warn = jest.fn(); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index fba4871185c..8c3075dd402 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -14,12 +14,18 @@ import { PluginContextProvider, PluginExtensionLink, PanelMenuItem, + PluginExtensionAddedLinkConfig, + urlUtil, } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { ShowModalReactEvent } from 'app/types/events'; +import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; +import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; + export function logWarning(message: string) { console.warn(`[Plugin Extensions] ${message}`); } @@ -218,11 +224,10 @@ export function isReadOnlyProxy(value: unknown): boolean { return isRecord(value) && value[_isProxy] === true; } -export function createExtensionLinkConfig( - config: Omit, 'type'> -): PluginExtensionLinkConfig { - const linkConfig: PluginExtensionLinkConfig = { - type: PluginExtensionTypes.link, +export function createAddedLinkConfig( + config: PluginExtensionAddedLinkConfig +): PluginExtensionAddedLinkConfig { + const linkConfig: PluginExtensionAddedLinkConfig = { ...config, }; assertLinkConfig(linkConfig); @@ -230,12 +235,8 @@ export function createExtensionLinkConfig( } function assertLinkConfig( - config: PluginExtensionLinkConfig -): asserts config is PluginExtensionLinkConfig { - if (config.type !== PluginExtensionTypes.link) { - throw Error('config is not a extension link'); - } -} + config: PluginExtensionAddedLinkConfig +): asserts config is PluginExtensionAddedLinkConfig {} export function truncateTitle(title: string, length: number): string { if (title.length < length) { @@ -294,3 +295,103 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel return subMenu; } + +export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkRegistryItem, context?: object) { + try { + const overrides = config.configure?.(context); + + // Hiding the extension + if (overrides === undefined) { + return undefined; + } + + let { + title = config.title, + description = config.description, + path = config.path, + icon = config.icon, + category = config.category, + ...rest + } = overrides; + + assertIsNotPromise( + overrides, + `The configure() function for "${config.title}" returned a promise, skipping updates.` + ); + + path && assertLinkPathIsValid(pluginId, path); + assertStringProps({ title, description }, ['title', 'description']); + + if (Object.keys(rest).length > 0) { + logWarning( + `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join( + ', ' + )} which will be ignored.` + ); + } + + return { + title, + description, + path, + icon, + category, + }; + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + + // If there is an error, we hide the extension + // (This seems to be safest option in case the extension is doing something wrong.) + return undefined; + } +} + +export function getLinkExtensionOnClick( + pluginId: string, + extensionPointId: string, + config: AddedLinkRegistryItem, + context?: object +): ((event?: React.MouseEvent) => void) | undefined { + const { onClick } = config; + + if (!onClick) { + return; + } + + return function onClickExtensionLink(event?: React.MouseEvent) { + try { + reportInteraction('ui_extension_link_clicked', { + pluginId: pluginId, + extensionPointId, + title: config.title, + category: config.category, + }); + + const result = onClick(event, getEventHelpers(pluginId, context)); + + if (isPromise(result)) { + result.catch((e) => { + if (e instanceof Error) { + logWarning(e.message); + } + }); + } + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + } + }; +} + +export function getLinkExtensionPathWithTracking(pluginId: string, path: string, extensionPointId: string): string { + return urlUtil.appendQueryToUrl( + path, + urlUtil.toUrlParams({ + uel_pid: pluginId, + uel_epid: extensionPointId, + }) + ); +} diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index 761acaa6e31..c7ad7a8f271 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -1,43 +1,16 @@ import { memo } from 'react'; -import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import { PluginExtensionAddedLinkConfig, PluginExtensionLinkConfig, PluginExtensionPoints } from '@grafana/data'; import { assertConfigureIsValid, assertLinkPathIsValid, - assertExtensionPointIdIsValid, - assertPluginExtensionLink, assertStringProps, - isPluginExtensionConfigValid, + isGrafanaCoreExtensionPoint, isReactComponent, } from './validators'; describe('Plugin Extension Validators', () => { - describe('assertPluginExtensionLink()', () => { - it('should NOT throw an error if it is a link extension', () => { - expect(() => { - assertPluginExtensionLink({ - id: 'id', - pluginId: 'myorg-b-app', - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtension); - }).not.toThrowError(); - }); - - it('should throw an error if it is not a link extension', () => { - expect(() => { - assertPluginExtensionLink({ - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - } as PluginExtension); - }).toThrowError(); - }); - }); - describe('assertLinkPathIsValid()', () => { it('should not throw an error if the link path is valid', () => { expect(() => { @@ -80,45 +53,14 @@ describe('Plugin Extension Validators', () => { }); }); - describe('assertExtensionPointIdIsValid()', () => { - it('should throw an error if the extensionPointId does not have the right prefix', () => { - expect(() => { - assertExtensionPointIdIsValid('my-org-app', { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'wrong-extension-point-id', - }); - }).toThrowError(); - }); - - it('should NOT throw an error if the extensionPointId is correct', () => { - expect(() => { - assertExtensionPointIdIsValid('my-org-app', { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - }); - - assertExtensionPointIdIsValid('my-org-app', { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'plugins/my-super-plugin/some-page/extension-point-a', - }); - }).not.toThrowError(); - }); - }); - describe('assertConfigureIsValid()', () => { it('should NOT throw an error if the configure() function is missing', () => { expect(() => { assertConfigureIsValid({ title: 'Title', description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - } as PluginExtensionLinkConfig); + targets: 'grafana/some-page/extension-point-a', + } as PluginExtensionAddedLinkConfig); }).not.toThrowError(); }); @@ -127,9 +69,9 @@ describe('Plugin Extension Validators', () => { assertConfigureIsValid({ title: 'Title', description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', + targets: 'grafana/some-page/extension-point-a', configure: () => {}, - } as PluginExtensionLinkConfig); + } as PluginExtensionAddedLinkConfig); }).not.toThrowError(); }); @@ -203,70 +145,6 @@ describe('Plugin Extension Validators', () => { }); }); - describe('isPluginExtensionConfigValid()', () => { - it('should return TRUE if the plugin extension configuration is valid', () => { - const pluginId = 'my-super-plugin'; - - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - onClick: jest.fn(), - extensionPointId: 'grafana/some-page/extension-point-a', - } as PluginExtensionLinkConfig) - ).toBe(true); - - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - path: `/a/${pluginId}/page`, - } as PluginExtensionLinkConfig) - ).toBe(true); - }); - - it('should return FALSE if the plugin extension configuration is invalid', () => { - const pluginId = 'my-super-plugin'; - - global.console.warn = jest.fn(); - - // Link (wrong path) - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - path: '/administration/users', - } as PluginExtensionLinkConfig) - ).toBe(false); - - // Link (no path and no onClick) - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - } as PluginExtensionLinkConfig) - ).toBe(false); - - // Link (missing title) - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: '', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - path: `/a/${pluginId}/page`, - } as PluginExtensionLinkConfig) - ).toBe(false); - }); - }); - describe('isReactComponent()', () => { it('should return TRUE if we pass in a valid React component', () => { expect(isReactComponent(() =>
                                                                          Some text
                                                                          )).toBe(true); @@ -292,4 +170,18 @@ describe('Plugin Extension Validators', () => { expect(isReactComponent(null)).toBe(false); }); }); + + describe('isGrafanaCoreExtensionPoint()', () => { + it('should return TRUE if we pass an PluginExtensionPoints value', () => { + expect(isGrafanaCoreExtensionPoint(PluginExtensionPoints.AlertingAlertingRuleAction)).toBe(true); + }); + + it('should return TRUE if we pass a string that is not listed under the PluginExtensionPoints enum', () => { + expect(isGrafanaCoreExtensionPoint('grafana/alerting/alertingrule/action')).toBe(true); + }); + + it('should return FALSE if we pass a string that is not listed under the PluginExtensionPoints enum', () => { + expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false); + }); + }); }); diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index 1584992b670..96970860973 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -1,13 +1,7 @@ -import type { - PluginExtension, - PluginExtensionConfig, - PluginExtensionLink, - PluginExtensionLinkConfig, -} from '@grafana/data'; +import type { PluginExtensionAddedLinkConfig, PluginExtension, PluginExtensionLink } from '@grafana/data'; +import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions'; import { isPluginExtensionLink } from '@grafana/runtime'; -import { isPluginExtensionLinkConfig, logWarning } from './utils'; - export function assertPluginExtensionLink( extension: PluginExtension | undefined, errorMessage = 'extension is not a link extension' @@ -17,15 +11,6 @@ export function assertPluginExtensionLink( } } -export function assertPluginExtensionLinkConfig( - extension: PluginExtensionLinkConfig, - errorMessage = 'extension is not a command extension config' -): asserts extension is PluginExtensionLinkConfig { - if (!isPluginExtensionLinkConfig(extension)) { - throw new Error(errorMessage); - } -} - export function assertLinkPathIsValid(pluginId: string, path: string) { if (!isLinkPathValid(pluginId, path)) { throw new Error( @@ -40,18 +25,10 @@ export function assertIsReactComponent(component: React.ComponentType) { } } -export function assertExtensionPointIdIsValid(pluginId: string, extension: PluginExtensionConfig) { - if (!isExtensionPointIdValid(pluginId, extension.extensionPointId)) { +export function assertConfigureIsValid(config: PluginExtensionAddedLinkConfig) { + if (!isConfigureFnValid(config.configure)) { throw new Error( - `Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/", "plugins/" or "capabilities/${pluginId}" (currently: "${extension.extensionPointId}"). Skipping the extension.` - ); - } -} - -export function assertConfigureIsValid(extension: PluginExtensionLinkConfig) { - if (!isConfigureFnValid(extension)) { - throw new Error( - `Invalid extension "${extension.title}". The "configure" property must be a function. Skipping the extension.` + `Invalid extension "${config.title}". The "configure" property must be a function. Skipping the extension.` ); } } @@ -88,43 +65,20 @@ export function extensionPointEndsWithVersion(extensionPointId: string) { return extensionPointId.match(/.*\/v\d+$/); } -export function isConfigureFnValid(extension: PluginExtensionLinkConfig) { - return extension.configure ? typeof extension.configure === 'function' : true; +export function isGrafanaCoreExtensionPoint(extensionPointId: string) { + return Object.values(PluginExtensionPoints) + .map((v) => v.toString()) + .includes(extensionPointId); +} + +export function isConfigureFnValid(configure?: PluginAddedLinksConfigureFunc | undefined) { + return configure ? typeof configure === 'function' : true; } export function isStringPropValid(prop: unknown) { return typeof prop === 'string' && prop.length > 0; } -export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionConfig): boolean { - try { - assertStringProps(extension, ['title', 'description', 'extensionPointId']); - assertExtensionPointIdIsValid(pluginId, extension); - - // Link - if (isPluginExtensionLinkConfig(extension)) { - assertConfigureIsValid(extension); - - if (!extension.path && !extension.onClick) { - logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`); - return false; - } - - if (extension.path) { - assertLinkPathIsValid(pluginId, extension.path); - } - } - - return true; - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } - - return false; - } -} - export function isPromise(value: unknown): value is Promise { return ( value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value) diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 7fa1c536896..024d743a652 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -1,26 +1,18 @@ -import type { PluginExposedComponentConfig, PluginExtensionConfig } from '@grafana/data'; -import { PluginAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; +import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data'; +import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; import type { AppPluginConfig } from '@grafana/runtime'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; -import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './extensions/registry/AddedComponentsRegistry'; -import { ExposedComponentsRegistry } from './extensions/registry/ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './extensions/registry/types'; import * as pluginLoader from './plugin_loader'; export type PluginPreloadResult = { pluginId: string; error?: unknown; - extensionConfigs: PluginExtensionConfig[]; - exposedComponentConfigs: PluginExposedComponentConfig[]; - addedComponentConfigs: PluginAddedComponentConfig[]; -}; - -type PluginExtensionRegistries = { - extensionsRegistry: ReactivePluginExtensionsRegistry; - addedComponentsRegistry: AddedComponentsRegistry; - exposedComponentsRegistry: ExposedComponentsRegistry; + exposedComponentConfigs: PluginExtensionExposedComponentConfig[]; + addedComponentConfigs?: PluginExtensionAddedComponentConfig[]; + addedLinkConfigs?: PluginExtensionAddedLinkConfig[]; }; export async function preloadPlugins( @@ -38,14 +30,17 @@ export async function preloadPlugins( continue; } - registries.extensionsRegistry.register(preloadedPlugin); registries.exposedComponentsRegistry.register({ pluginId: preloadedPlugin.pluginId, configs: preloadedPlugin.exposedComponentConfigs, }); registries.addedComponentsRegistry.register({ pluginId: preloadedPlugin.pluginId, - configs: preloadedPlugin.addedComponentConfigs, + configs: preloadedPlugin.addedComponentConfigs || [], + }); + registries.addedLinksRegistry.register({ + pluginId: preloadedPlugin.pluginId, + configs: preloadedPlugin.addedLinkConfigs || [], }); } @@ -62,16 +57,22 @@ async function preload(config: AppPluginConfig): Promise { isAngular: config.angular.detected, pluginId, }); - const { extensionConfigs = [], exposedComponentConfigs = [], addedComponentConfigs = [] } = plugin; + const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin; // Fetching meta-information for the preloaded app plugin and caching it for later. // (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.) getPluginSettings(pluginId); - return { pluginId, extensionConfigs, exposedComponentConfigs, addedComponentConfigs }; + return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs }; } catch (error) { console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); - return { pluginId, extensionConfigs: [], error, exposedComponentConfigs: [], addedComponentConfigs: [] }; + return { + pluginId, + error, + exposedComponentConfigs: [], + addedComponentConfigs: [], + addedLinkConfigs: [], + }; } finally { stopMeasure(`frontend_plugin_preload_${pluginId}`); } diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 2168b2682f8..7a384684d20 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { AdHocVariableFilter, GrafanaTheme2, PageLayoutType, VariableHide, urlUtil } from '@grafana/data'; -import { locationService, useChromeHeaderHeight } from '@grafana/runtime'; +import { config, locationService, useChromeHeaderHeight } from '@grafana/runtime'; import { AdHocFiltersVariable, DataSourceVariable, @@ -259,9 +259,11 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad addFilterButtonText: 'Add label', datasource: trailDS, hide: VariableHide.hideLabel, - layout: 'vertical', + layout: config.featureToggles.newFiltersUI ? 'combobox' : 'vertical', filters: initialFilters ?? [], baseFilters: getBaseFiltersForMetric(metric), + // since we only support prometheus datasources, this is always true + supportsMultiValueOperators: true, }), ], }); diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index c27d4e53122..a64f1f3cd70 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -1,5 +1,6 @@ //DOCS: https://prometheus.io/docs/alerting/latest/configuration/ import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data'; +import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen'; export type AlertManagerCortexConfig = { template_files: Record; @@ -65,12 +66,15 @@ export type WebhookConfig = { max_alerts?: number; }; +type GrafanaManagedReceiverConfigSettings = Record; export type GrafanaManagedReceiverConfig = { uid?: string; disableResolveMessage?: boolean; secureFields?: Record; - secureSettings?: Record; - settings?: Record; // sometimes settings are optional for security reasons (RBAC) + secureSettings?: GrafanaManagedReceiverConfigSettings; + /** If retrieved from k8s API, SecureSettings property name is different */ + // SecureSettings?: GrafanaManagedReceiverConfigSettings; + settings: GrafanaManagedReceiverConfigSettings; type: string; /** * Name of the _receiver_, which in most cases will be the @@ -88,6 +92,10 @@ export type GrafanaManagedReceiverConfig = { export interface GrafanaManagedContactPoint { name: string; + /** If parsed from k8s API, we'll have an ID property */ + id?: string; + metadata?: IoK8SApimachineryPkgApisMetaV1ObjectMeta; + provisioned?: boolean; grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; } diff --git a/public/app/plugins/datasource/azuremonitor/azureMetadata/logsResourceTypes.ts b/public/app/plugins/datasource/azuremonitor/azureMetadata/logsResourceTypes.ts index cfff000ceb1..fa24e15fd4a 100644 --- a/public/app/plugins/datasource/azuremonitor/azureMetadata/logsResourceTypes.ts +++ b/public/app/plugins/datasource/azuremonitor/azureMetadata/logsResourceTypes.ts @@ -2,21 +2,34 @@ This list is obtained parsing https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/resource-logs-categories To programatically get the list, execute in the browser console: - const i = []; - document.querySelectorAll('*[id]').forEach((el) => { - if(el.textContent.match(/^(microsoft|Microsoft|Wandisco)/)) { - i.push(el.textContent.toLowerCase()) - }}); - i; + const namespaces = [] + document.querySelectorAll('tr').forEach((tr, index) => { + if(index !== 0) { + const tds = tr.cells + const resourceProvider = tds[0].innerText.replace("\n", "") + if(tds[2].innerText === "N/A" || !resourceProvider.match(/^(microsoft|Microsoft|Wandisco)/)) { + return; + } + const logsCategories = tds[2].innerText.split("\n") + logsCategories.forEach((category) => { + if(category === "") { + return; + } + const string =resourceProvider+'/'+category + namespaces.push(string.toLowerCase()) + }) + } + }) + namespaces; Note: Validate that the output makes sense, the format of the page may change. That list is not complete so we should merge it with the one below. You can do with a node console: const u = require('lodash') - const new = [list from above] + const newList = [list from above] const logsResourceTypes = [this list] - console.dir(u.uniq(logsResourceTypes.concat(new)).sort(), {'maxArrayLength': null}) + console.dir(u.uniq(logsResourceTypes.concat(newList)).sort(), {'maxArrayLength': null}) */ @@ -27,6 +40,7 @@ export const logsResourceTypes = [ 'microsoft.agfoodplatform/farmbeats', 'microsoft.analysisservices/servers', 'microsoft.apimanagement/service', + 'microsoft.app/managedenvironments', 'microsoft.appconfiguration/configurationstores', 'microsoft.appplatform/spring', 'microsoft.attestation/attestationproviders', @@ -35,6 +49,9 @@ export const logsResourceTypes = [ 'microsoft.autonomousdevelopmentplatform/datapools', 'microsoft.autonomousdevelopmentplatform/workspaces', 'microsoft.avs/privateclouds', + 'microsoft.azuredatatransfer/connections/flows', + 'microsoft.azureplaywrightservice/accounts', + 'microsoft.azuresphere/catalogs', 'microsoft.azurestackresourcemonitor/storageaccountmonitor', 'microsoft.batch/batchaccounts', 'microsoft.batchai/workspaces', @@ -43,22 +60,31 @@ export const logsResourceTypes = [ 'microsoft.botservice/botservices', 'microsoft.cache/redis', 'microsoft.cache/redisenterprise', + 'microsoft.cache/redisenterprise/databases', 'microsoft.cdn/cdnwebapplicationfirewallpolicies', 'microsoft.cdn/profiles', 'microsoft.cdn/profiles/endpoints', + 'microsoft.chaos/experiments', 'microsoft.classiccompute/domainnames', 'microsoft.classiccompute/virtualmachines', 'microsoft.classicnetwork/networksecuritygroups', 'microsoft.classicstorage/storageaccounts', + 'microsoft.codesigning/codesigningaccounts', 'microsoft.cognitiveservices/accounts', 'microsoft.communication/communicationservices', + 'microsoft.community/communitytrainings', 'microsoft.compute/disks', 'microsoft.compute/virtualmachines', 'microsoft.compute/virtualmachinescalesets', + 'microsoft.confidentialledger/managedccf', + 'microsoft.confidentialledger/managedccfs', 'microsoft.connectedcache/cachenodes', + 'microsoft.connectedcache/enterprisemcccustomers', + 'microsoft.connectedcache/ispcustomers', 'microsoft.connectedvehicle/platformaccounts', 'microsoft.containerinstance/containergroups', 'microsoft.containerregistry/registries', + 'microsoft.containerservice/fleets', 'microsoft.containerservice/managedclusters', 'microsoft.customerinsights/hubs', 'microsoft.customproviders/resourceproviders', @@ -71,6 +97,7 @@ export const logsResourceTypes = [ 'microsoft.datafactory/factories', 'microsoft.datalakeanalytics/accounts', 'microsoft.datalakestore/accounts', + 'microsoft.dataprotection/backupvaults', 'microsoft.datashare/accounts', 'microsoft.dbformariadb/servers', 'microsoft.dbformysql/flexibleservers', @@ -79,17 +106,22 @@ export const logsResourceTypes = [ 'microsoft.dbforpostgresql/servergroupsv2', 'microsoft.dbforpostgresql/servers', 'microsoft.dbforpostgresql/serversv2', + 'microsoft.desktopvirtualization/appattachpackages', 'microsoft.desktopvirtualization/applicationgroups', 'microsoft.desktopvirtualization/hostpools', 'microsoft.desktopvirtualization/scalingplans', 'microsoft.desktopvirtualization/workspaces', + 'microsoft.devcenter/devcenters', 'microsoft.devices/elasticpools/iothubtenants', 'microsoft.devices/iothubs', 'microsoft.devices/provisioningservices', + 'microsoft.devopsinfrastructure/pools', 'microsoft.digitaltwins/digitaltwinsinstances', 'microsoft.documentdb/cassandraclusters', 'microsoft.documentdb/databaseaccounts', + 'microsoft.documentdb/mongoclusters', 'microsoft.eventgrid/domains', + 'microsoft.eventgrid/namespaces', 'microsoft.eventgrid/partnernamespaces', 'microsoft.eventgrid/partnertopics', 'microsoft.eventgrid/systemtopics', @@ -98,12 +130,16 @@ export const logsResourceTypes = [ 'microsoft.eventhub/namespaces', 'microsoft.experimentation/experimentworkspaces', 'microsoft.fabric.admin/fabriclocations', + 'microsoft.hardwaresecuritymodules/cloudhsmclusters', 'microsoft.hdinsight/clusters', 'microsoft.healthcareapis/services', 'microsoft.healthcareapis/workspaces/dicomservices', 'microsoft.healthcareapis/workspaces/fhirservices', + 'microsoft.healthcareapis/workspaces/iotconnectors', + 'microsoft.healthdataaiservices/deidservices', 'microsoft.insights/autoscalesettings', 'microsoft.insights/components', + 'microsoft.insights/datacollectionrules', 'microsoft.insights/qos', 'microsoft.iotcentral/iotapps', 'microsoft.keyvault/managedhsms', @@ -114,17 +150,22 @@ export const logsResourceTypes = [ 'microsoft.logic/integrationaccounts', 'microsoft.logic/integrationserviceenvironments', 'microsoft.logic/workflows', + 'microsoft.machinelearningservices/registries', 'microsoft.machinelearningservices/workspaces', + 'microsoft.machinelearningservices/workspaces/onlineendpoints', + 'microsoft.managednetworkfabric/networkdevices', 'microsoft.media/mediaservices', 'microsoft.media/mediaservices/liveevents', 'microsoft.media/mediaservices/streamingendpoints', 'microsoft.media/videoanalyzers', + 'microsoft.monitor/accounts', 'microsoft.netapp/netappaccounts/capacitypools', 'microsoft.netapp/netappaccounts/capacitypools/volumes', 'microsoft.network/applicationgateways', 'microsoft.network/azurefirewalls', 'microsoft.network/bastionhosts', 'microsoft.network/connections', + 'microsoft.network/dnsresolverpolicies', 'microsoft.network/dnszones', 'microsoft.network/expressroutecircuits', 'microsoft.network/expressroutegateways', @@ -133,8 +174,11 @@ export const logsResourceTypes = [ 'microsoft.network/loadbalancers', 'microsoft.network/natgateways', 'microsoft.network/networkinterfaces', + 'microsoft.network/networkmanagers', + 'microsoft.network/networkmanagers/ipampools', 'microsoft.network/networksecuritygroups', 'microsoft.network/networksecurityperimeters', + 'microsoft.network/networksecurityperimeters/profiles', 'microsoft.network/networkvirtualappliances', 'microsoft.network/networkwatchers', 'microsoft.network/networkwatchers/connectionmonitors', @@ -142,10 +186,16 @@ export const logsResourceTypes = [ 'microsoft.network/privateendpoints', 'microsoft.network/privatelinkservices', 'microsoft.network/publicipaddresses', + 'microsoft.network/publicipprefixes', 'microsoft.network/trafficmanagerprofiles', 'microsoft.network/virtualnetworkgateways', 'microsoft.network/virtualnetworks', 'microsoft.network/vpngateways', + 'microsoft.networkanalytics/dataproducts', + 'microsoft.networkcloud/baremetalmachines', + 'microsoft.networkcloud/clustermanagers', + 'microsoft.networkcloud/clusters', + 'microsoft.networkcloud/storageappliances', 'microsoft.networkfunction/azuretrafficcollectors', 'microsoft.notificationhubs/namespaces', 'microsoft.notificationhubs/namespaces/notificationhubs', @@ -153,9 +203,11 @@ export const logsResourceTypes = [ 'microsoft.operationalinsights/workspaces', 'microsoft.operationsmanagement/solutions', 'microsoft.peering/peeringservices', + 'microsoft.playfab/titles', 'microsoft.powerbi/tenants', 'microsoft.powerbi/tenants/workspaces', 'microsoft.powerbidedicated/capacities', + 'microsoft.providerhub/providerregistrations', 'microsoft.purview/accounts', 'microsoft.recoveryservices/vaults', 'microsoft.relay/namespaces', @@ -163,10 +215,14 @@ export const logsResourceTypes = [ 'microsoft.resources/subscriptions/resourcegroups', 'microsoft.search/searchservices', 'microsoft.security/antimalwaresettings', + 'microsoft.security/defenderforstoragesettings', 'microsoft.securityinsights/settings', 'microsoft.servicebus/namespaces', + 'microsoft.servicenetworking/trafficcontrollers', 'microsoft.signalrservice/signalr', + 'microsoft.signalrservice/signalr/replicas', 'microsoft.signalrservice/webpubsub', + 'microsoft.signalrservice/webpubsub/replicas', 'microsoft.singularity/accounts', 'microsoft.sql/managedinstances', 'microsoft.sql/managedinstances/databases', @@ -177,7 +233,9 @@ export const logsResourceTypes = [ 'microsoft.storage/storageaccounts/fileservices', 'microsoft.storage/storageaccounts/queueservices', 'microsoft.storage/storageaccounts/tableservices', + 'microsoft.storagecache/amlfilesystems', 'microsoft.storagecache/caches', + 'microsoft.storagemover/storagemovers', 'microsoft.storagesync/storagesyncservices', 'microsoft.streamanalytics/streamingjobs', 'microsoft.synapse/workspaces', @@ -194,4 +252,6 @@ export const logsResourceTypes = [ 'microsoft.web/serverfarms', 'microsoft.web/sites', 'microsoft.web/sites/slots', + 'microsoft.web/staticsites', + 'microsoft.workloads/sapvirtualinstances', ]; diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 2a2f663960d..32a78621bba 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -22,7 +22,7 @@ "react-select": "5.8.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/e2e-selectors": "11.3.0-pre", @@ -32,7 +32,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 87ca556d8a8..922b16b0959 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -23,7 +23,7 @@ "react-select": "5.8.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/e2e-selectors": "11.3.0-pre", @@ -34,7 +34,7 @@ "@types/debounce-promise": "3.1.9", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts index 21a964561e0..c809ef0f794 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts @@ -208,6 +208,53 @@ describe('CloudWatchLogsQueryRunner', () => { }); }); + it('should call getQueryResults until the query returns even if it the startQuery gets a throttling error from aws', async () => { + const { runner } = setupMockedLogsQueryRunner(); + + const options: DataQueryRequest = { + ...LogsRequestMock, + targets: rawLogQueriesStub, + }; + + const queryFn = jest + .fn() + .mockReturnValueOnce(of(startQueryErrorWhenThrottlingResponseStub)) + .mockReturnValueOnce(of(startQuerySuccessResponseStub)) + .mockReturnValueOnce(of(getQuerySuccessResponseStub)); + + const response = runner.handleLogQueries(rawLogQueriesStub, options, queryFn); + const results = await lastValueFrom(response); + expect(queryFn).toHaveBeenCalledTimes(3); + + // first call + expect(queryFn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + targets: expect.arrayContaining([expect.objectContaining({ subtype: 'StartQuery' })]), + }) + ); + // we retry because the first call failed with the rate limiting error + expect(queryFn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + targets: expect.arrayContaining([expect.objectContaining({ subtype: 'StartQuery' })]), + }) + ); + // we get results because second call was successful + expect(queryFn).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + targets: expect.arrayContaining([expect.objectContaining({ subtype: 'GetQueryResults' })]), + }) + ); + + expect(results).toEqual({ + ...getQuerySuccessResponseStub, + errors: [], + key: 'test-key', + }); + }); + it('should return an error if it timesout before the start queries can get past a rate limiting error', async () => { const { runner } = setupMockedLogsQueryRunner(); // first time timeout is called it will not be timed out, second time it will be timed out @@ -469,6 +516,18 @@ const startQueryErrorWhenRateLimitedResponseStub = { ], }; +const startQueryErrorWhenThrottlingResponseStub = { + data: [], + errors: [ + { + refId: 'A', + message: + 'failed to execute log action with subtype: StartQuery: ThrottlingException: ThrottlingException: Rate exceeded', + status: 500, + }, + ], +}; + const startQueryErrorWhenBadSyntaxResponseStub = { data: [], state: 'Error', diff --git a/public/app/plugins/datasource/cloudwatch/utils/logsRetry.ts b/public/app/plugins/datasource/cloudwatch/utils/logsRetry.ts index fa7d3e51582..deae2449531 100644 --- a/public/app/plugins/datasource/cloudwatch/utils/logsRetry.ts +++ b/public/app/plugins/datasource/cloudwatch/utils/logsRetry.ts @@ -94,7 +94,10 @@ function splitErrorsData(errors: DataQueryError[]) { const refIdsForRequestsToRetry: string[] = []; const errorsNotToRetry: DataQueryError[] = []; errors.map((err) => { - if (err?.message?.includes('LimitExceededException') && err.refId) { + if ( + err?.refId && + (err.message?.includes('LimitExceededException') || err.message?.includes('ThrottlingException')) + ) { refIdsForRequestsToRetry.push(err.refId); } else { errorsNotToRetry.push(err); diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index 7f4fffa0b0c..0bc2186eeb8 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -13,7 +13,7 @@ "lodash": "4.17.21", "react": "18.2.0", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/e2e-selectors": "11.3.0-pre", @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index e20a8c4c98f..583415ef850 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -17,7 +17,7 @@ "react-dom": "18.2.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/plugin-configs": "11.3.0-pre", @@ -27,7 +27,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 8222c6c6af4..cb974c6544d 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -18,7 +18,7 @@ "react-select": "5.8.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uuid": "9.0.1" }, "devDependencies": { @@ -30,7 +30,7 @@ "@types/d3-random": "^3.0.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/testing-library__jest-dom": "5.14.9", diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index 2937051f0c8..b498a1ecb3f 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -19,7 +19,7 @@ "react-window": "1.8.10", "rxjs": "7.8.1", "stream-browserify": "3.0.0", - "tslib": "2.6.3", + "tslib": "2.7.0", "uuid": "9.0.1" }, "devDependencies": { @@ -31,7 +31,7 @@ "@types/jest": "29.5.12", "@types/lodash": "4.17.7", "@types/logfmt": "^1.2.3", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/react-window": "1.8.8", diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index ddc53acf97c..3ced516d4b7 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -13,7 +13,7 @@ "lodash": "4.17.21", "react": "18.2.0", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/e2e-selectors": "workspace:*", @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index dc103364ca5..9b625ba7f8d 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -13,7 +13,7 @@ "lodash": "4.17.21", "react": "18.2.0", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/e2e-selectors": "11.3.0-pre", @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index be3cdb5fe64..350bedf7620 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -15,7 +15,7 @@ "react-dom": "18.2.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/plugin-configs": "11.3.0-pre", @@ -23,7 +23,7 @@ "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/prometheus/plugin.json b/public/app/plugins/datasource/prometheus/plugin.json index c11bd2e9809..9f4cf3c82e1 100644 --- a/public/app/plugins/datasource/prometheus/plugin.json +++ b/public/app/plugins/datasource/prometheus/plugin.json @@ -89,6 +89,7 @@ "queryOptions": { "minInterval": true }, + "multiValueFilterOperators": true, "info": { "description": "Open source time series database & alerting", "author": { diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index eaf0fb56da9..06288f4f1b3 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -35,7 +35,7 @@ "semver": "7.6.3", "stream-browserify": "3.0.0", "string_decoder": "1.3.0", - "tslib": "2.6.3", + "tslib": "2.7.0", "uuid": "9.0.1" }, "devDependencies": { @@ -46,7 +46,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index b2970124d11..e5167fac452 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -17,7 +17,7 @@ "react-select": "5.8.0", "react-use": "17.5.1", "rxjs": "7.8.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "devDependencies": { "@grafana/plugin-configs": "workspace:*", @@ -25,7 +25,7 @@ "@testing-library/react": "15.0.2", "@types/jest": "29.5.12", "@types/lodash": "4.17.7", - "@types/node": "20.16.2", + "@types/node": "20.16.3", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "ts-node": "10.9.2", diff --git a/public/app/plugins/panel/nodeGraph/module.tsx b/public/app/plugins/panel/nodeGraph/module.tsx index 27c4d06a7f2..3a3c0399a34 100644 --- a/public/app/plugins/panel/nodeGraph/module.tsx +++ b/public/app/plugins/panel/nodeGraph/module.tsx @@ -1,4 +1,4 @@ -import { PanelPlugin } from '@grafana/data'; +import { FieldConfigProperty, PanelPlugin } from '@grafana/data'; import { NodeGraphPanel } from './NodeGraphPanel'; import { ArcOptionsEditor } from './editor/ArcOptionsEditor'; @@ -6,6 +6,9 @@ import { NodeGraphSuggestionsSupplier } from './suggestions'; import { NodeGraphOptions } from './types'; export const plugin = new PanelPlugin(NodeGraphPanel) + .useFieldConfig({ + disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => v !== FieldConfigProperty.Links), + }) .setPanelOptions((builder, context) => { builder.addNestedOptions({ category: ['Nodes'], diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 338fa189fc9..734e6c650ad 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -50,6 +50,11 @@ export interface AlertingRule extends RuleBase { }; state: PromAlertingRuleState; type: PromRuleType.Alerting; + + /** + * Pending period in seconds, aka for. 0 or undefined means no pending period + */ + duration?: number; totals?: Partial, number>>; totalsFiltered?: Partial, number>>; activeAt?: string; // ISO timestamp diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index ee20063525e..cd0c99c8fd8 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -233,6 +233,12 @@ "message": "" } }, + "bookmarks-page": { + "empty": { + "message": "", + "tip": "" + } + }, "bouncing-loader": { "label": "Wird geladen" }, @@ -327,6 +333,9 @@ "combobox": { "clear": { "title": "" + }, + "custom-value": { + "create": "" } }, "command-palette": { @@ -707,6 +716,13 @@ "time-range-label": "" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "" + } + } + }, "explore": { "add-to-dashboard": "Zum Dashboard hinzufügen", "logs": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ddb0abacdda..ba9a51a6a84 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -333,6 +333,9 @@ "combobox": { "clear": { "title": "Clear value" + }, + "custom-value": { + "create": "Create custom value" } }, "command-palette": { @@ -713,6 +716,13 @@ "time-range-label": "Lock time range" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "The selected data source does not support annotations. Please select a different data source." + } + } + }, "explore": { "add-to-dashboard": "Add to dashboard", "logs": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 95da8ab5db6..8b3b53bbe1b 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -233,6 +233,12 @@ "message": "" } }, + "bookmarks-page": { + "empty": { + "message": "", + "tip": "" + } + }, "bouncing-loader": { "label": "Cargando" }, @@ -327,6 +333,9 @@ "combobox": { "clear": { "title": "" + }, + "custom-value": { + "create": "" } }, "command-palette": { @@ -707,6 +716,13 @@ "time-range-label": "" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "" + } + } + }, "explore": { "add-to-dashboard": "Añadir al tablero", "logs": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index a84b8d83155..dc5a373ddd9 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -233,6 +233,12 @@ "message": "" } }, + "bookmarks-page": { + "empty": { + "message": "", + "tip": "" + } + }, "bouncing-loader": { "label": "Chargement" }, @@ -327,6 +333,9 @@ "combobox": { "clear": { "title": "" + }, + "custom-value": { + "create": "" } }, "command-palette": { @@ -707,6 +716,13 @@ "time-range-label": "" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "" + } + } + }, "explore": { "add-to-dashboard": "Ajouter au tableau de bord", "logs": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bc7da0abbdb..28726d4ec22 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -333,6 +333,9 @@ "combobox": { "clear": { "title": "Cľęäř väľūę" + }, + "custom-value": { + "create": "Cřęäŧę čūşŧőm väľūę" } }, "command-palette": { @@ -713,6 +716,13 @@ "time-range-label": "Ŀőčĸ ŧįmę řäʼnģę" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "Ŧĥę şęľęčŧęđ đäŧä şőūřčę đőęş ʼnőŧ şūppőřŧ äʼnʼnőŧäŧįőʼnş. Pľęäşę şęľęčŧ ä đįƒƒęřęʼnŧ đäŧä şőūřčę." + } + } + }, "explore": { "add-to-dashboard": "Åđđ ŧő đäşĥþőäřđ", "logs": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 4d6c51e607c..13a19d5d8fa 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -233,6 +233,12 @@ "message": "" } }, + "bookmarks-page": { + "empty": { + "message": "", + "tip": "" + } + }, "bouncing-loader": { "label": "Carregando" }, @@ -327,6 +333,9 @@ "combobox": { "clear": { "title": "" + }, + "custom-value": { + "create": "" } }, "command-palette": { @@ -707,6 +716,13 @@ "time-range-label": "" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "" + } + } + }, "explore": { "add-to-dashboard": "Adicionar ao painel de controle", "logs": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index c03ec19a1ae..8657f547d64 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -230,6 +230,12 @@ "message": "" } }, + "bookmarks-page": { + "empty": { + "message": "", + "tip": "" + } + }, "bouncing-loader": { "label": "正在加载" }, @@ -319,6 +325,9 @@ "combobox": { "clear": { "title": "" + }, + "custom-value": { + "create": "" } }, "command-palette": { @@ -699,6 +708,13 @@ "time-range-label": "" } }, + "errors": { + "dashboard-settings": { + "annotations": { + "datasource": "" + } + } + }, "explore": { "add-to-dashboard": "添加到仪表板", "logs": { diff --git a/public/openapi3.json b/public/openapi3.json index 8830aae451a..309ce67bff5 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -15352,7 +15352,7 @@ }, "/auth/keys": { "get": { - "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + "description": "Will return auth keys.\n\nDeprecated: true.\n\nDeprecated. Please use GET /api/serviceaccounts and GET /api/serviceaccounts/{id}/tokens instead\nsee https://grafana.com/docs/grafana/next/administration/service-accounts/migrate-api-keys/.", "operationId": "getAPIkeys", "parameters": [ { @@ -15405,7 +15405,7 @@ "/auth/keys/{id}": { "delete": { "deprecated": true, - "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts-using-the-api.", + "description": "Deletes an API key.\nDeprecated. See: https://grafana.com/docs/grafana/next/administration/service-accounts/migrate-api-keys/.", "operationId": "deleteAPIkey", "parameters": [ { diff --git a/public/sass/_angular.scss b/public/sass/_angular.scss index 43f14cbba21..fce198b2516 100644 --- a/public/sass/_angular.scss +++ b/public/sass/_angular.scss @@ -2568,3 +2568,37 @@ button.close { color: $text-muted; min-width: 47px; } + +.input-mini { + width: 60px; +} + +.input-medium { + width: 150px; +} + +input[type='checkbox'].cr1 { + display: none; +} + +.editor-option label.cr1 { + display: inline-block; + margin: 5px 0 1px 0; +} + +label.cr1 { + display: inline-block; + height: 18px; + position: relative; + clear: none; + text-indent: 2px; + margin: 0 0 0px 0; + padding: 0 0 0 20px; + vertical-align: top; + background: url($checkboxImageUrl) left top no-repeat; + cursor: pointer; +} + +input[type='checkbox'].cr1:checked + label { + background: url($checkboxImageUrl) 0px -18px no-repeat; +} diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index 755843375f3..e54884826f3 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -1,6 +1,5 @@ // BASE @import 'base/reboot'; -@import 'base/forms'; @import 'base/grid'; @import 'base/font_awesome'; diff --git a/public/sass/base/_forms.scss b/public/sass/base/_forms.scss deleted file mode 100644 index c231563e6e0..00000000000 --- a/public/sass/base/_forms.scss +++ /dev/null @@ -1,252 +0,0 @@ -@use 'sass:color'; - -@mixin form-control-validation($color) { - // Color the label and help text - .text-help, - .form-control-label, - .radio, - .checkbox, - .radio-inline, - .checkbox-inline, - &.radio label, - &.checkbox label, - &.radio-inline label, - &.checkbox-inline label, - .custom-control { - color: $color; - } - - .form-control { - border-color: $color; - } - - // Set validation states also for addons - .input-group-addon { - color: $color; - border-color: $color; - background-color: color.adjust($color, $lightness: 40%); - } - // Optional feedback icon - .form-control-feedback { - color: $color; - } -} - -// -// Forms -// -------------------------------------------------- - -// GENERAL STYLES -// -------------- - -// Reset height since textareas have rows -// Set font for forms - -// Set the height of select and file controls to match text inputs -select, -input[type='file'] { - height: $input-height; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - line-height: $input-height; -} - -// Make select elements obey height by applying a border -select { - width: 220px; // default input width + 10px of padding that doesn't get applied - border: 1px solid $input-border-color; - background-color: $input-bg; // Chrome on Linux and Mobile Safari need background-color -} - -// Make multiple select elements height not fixed -select[multiple], -select[size] { - height: auto; -} - -// Focus for select, file, radio, and checkbox -select:focus, -input[type='file']:focus, -input[type='radio']:focus, -input[type='checkbox']:focus { - // Default - outline: thin dotted; - // WebKit - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; -} - -// not a big fan of number fields -input[type='number']::-webkit-outer-spin-button, -input[type='number']::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} -input[type='number'] { - -moz-appearance: textfield; -} -// Placeholder -// ------------------------- - -// Placeholder text gets special styles because when browsers invalidate entire lines if it doesn't understand a selector -input, -textarea { - &::placeholder { - color: $input-color-placeholder; - } -} - -// INPUT SIZES -// ----------- - -// General classes for quick sizes -.input-mini { - width: 60px; -} -.input-small { - width: 90px; -} -.input-medium { - width: 150px; -} -.input-large { - width: 210px; -} -.input-xlarge { - width: 270px; -} -.input-xxlarge { - width: 530px; -} - -// GRID SIZING FOR INPUTS -// ---------------------- - -// DISABLED STATE -// -------------- - -// Disabled and read-only inputs -input[disabled], -select[disabled], -textarea[disabled], -input[readonly], -select[readonly], -textarea[readonly] { - cursor: $cursor-disabled; - background-color: $input-bg-disabled; -} - -// Explicitly reset the colors here -input[type='radio'][disabled], -input[type='checkbox'][disabled], -input[type='radio'][readonly], -input[type='checkbox'][readonly] { - cursor: $cursor-disabled; - background-color: transparent; -} - -input[type='text'].input-fluid { - width: 100%; - box-sizing: border-box; - padding: 10px; - font-size: 16px; - -moz-box-sizing: border-box; - height: 100%; -} - -input[type='checkbox'].cr1 { - display: none; -} - -.editor-option label.cr1 { - display: inline-block; - margin: 5px 0 1px 0; -} - -label.cr1 { - display: inline-block; - height: 18px; - position: relative; - clear: none; - text-indent: 2px; - margin: 0 0 0px 0; - padding: 0 0 0 20px; - vertical-align: top; - background: url($checkboxImageUrl) left top no-repeat; - cursor: pointer; -} - -input[type='checkbox'].cr1:checked + label { - background: url($checkboxImageUrl) 0px -18px no-repeat; -} - -.gf-fluid-input { - border: none; - display: block; - overflow: hidden; - padding-right: 10px; - input[type='text'] { - width: 100%; - padding: 5px 6px; - height: 100%; - box-sizing: border-box; - } - textarea { - width: 100%; - padding: 5px 6px; - height: 100%; - box-sizing: border-box; - } -} - -// Form control feedback states -// -// Apply contextual and semantic states to individual form controls. - -.form-control-success, -.form-control-warning, -.form-control-danger { - padding-right: $space-xl; - background-repeat: no-repeat; - background-position: center right calc($input-height / 4); - background-size: calc($input-height / 2) calc($input-height / 2); -} - -// Form validation states -.has-success { - @include form-control-validation($brand-success); - - .form-control-success { - background-image: $form-icon-success; - } -} - -.has-warning { - @include form-control-validation($brand-warning); - - .form-control-warning { - background-image: $form-icon-warning; - } -} - -.has-danger { - @include form-control-validation($brand-danger); - - .form-control-danger { - background-image: $form-icon-danger; - } -} - -input:-webkit-autofill, -input:-webkit-autofill:hover, -input:-webkit-autofill:focus, -input:-webkit-autofill, -textarea:-webkit-autofill, -textarea:-webkit-autofill:hover, -textarea:-webkit-autofill:focus, -select:-webkit-autofill, -select:-webkit-autofill:hover, -select:-webkit-autofill:focus { - -webkit-box-shadow: 0 0 0px 1000px $input-bg inset !important; - -webkit-text-fill-color: $input-color !important; - box-shadow: 0 0 0px 1000px $input-bg inset; - border: 1px solid $input-bg; -} diff --git a/public/views/index.html b/public/views/index.html index ae7e4147f3e..486b9fb1b1c 100644 --- a/public/views/index.html +++ b/public/views/index.html @@ -43,6 +43,7 @@ display: flex; justify-content: center; align-items: center; + margin: 0 32px; } .preloader__enter { @@ -158,10 +159,37 @@ font-size: 15px; } + .theme-light .preloader--done .preloader__text { + color: #52545c !important; + } + .preloader--done .preloader__text--fail { display: block; } + .preloader--done .preloader__text--fail a { + color: white; + } + + .theme-light .preloader--done .preloader__text--fail a { + color: rgb(31, 98, 224); + } + + .preloader--done code { + white-space: nowrap; + padding: 2px 5px; + margin: 0px 2px; + font-size: 0.8rem; + background-color: rgb(24, 27, 31); + color: rgb(204, 204, 220); + border: 1px solid rgba(204, 204, 220, 0.2); + border-radius: 4px; + } + + .preloader__error-list li { + margin-bottom: 8px; + } + [ng\:cloak], [ng-cloak], .ng-cloak { @@ -176,25 +204,21 @@
                                                                          -

                                                                          - If you're seeing this Grafana has failed to load its application files -
                                                                          -
                                                                          -

                                                                          -

                                                                          - 1. This could be caused by your reverse proxy settings.

                                                                          - 2. If you host grafana under subpath make sure your grafana.ini root_url setting includes subpath. If not - using a reverse proxy make sure to set serve_from_sub_path to true.
                                                                          -
                                                                          - 3. If you have a local dev build make sure you build frontend using: yarn start, or yarn build
                                                                          -
                                                                          - 4. Sometimes restarting grafana-server can help
                                                                          -
                                                                          - 5. Check if you are using a non-supported browser. For more information, refer to the list of - - supported browsers. -

                                                                          +
                                                                          +

                                                                          If you're seeing this Grafana has failed to load its application files

                                                                          +
                                                                            +
                                                                          1. This could be caused by your reverse proxy settings.
                                                                          2. +
                                                                          3. If you host grafana under a subpath make sure your grafana.ini root_url setting + includes subpath. If not using a reverse proxy make sure to set serve_from_sub_path to true.
                                                                          4. +
                                                                          5. If you have a local dev build make sure you build frontend using: yarn start, or + yarn build.
                                                                          6. +
                                                                          7. Sometimes restarting grafana-server can help.
                                                                          8. +
                                                                          9. Check if you are using a non-supported browser. For more information, refer to the list of + + supported browsers .
                                                                          10. +
                                                                          +