diff --git a/.betterer.results b/.betterer.results index d9446d6b110..bb7e73b4731 100644 --- a/.betterer.results +++ b/.betterer.results @@ -256,6 +256,17 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], + "packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], + "packages/grafana-data/src/transformations/transformers/nulls/nullToUndefThreshold.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], "packages/grafana-data/src/transformations/transformers/reduce.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -796,40 +807,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/grafana-ui/src/components/Graph/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "packages/grafana-ui/src/components/GraphNG/GraphNG.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Do not use any type assertions.", "9"], - [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Do not use any type assertions.", "11"] - ], - "packages/grafana-ui/src/components/GraphNG/hooks.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], - "packages/grafana-ui/src/components/GraphNG/nullToUndefThreshold.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] - ], "packages/grafana-ui/src/components/InfoBox/InfoBox.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -981,11 +958,6 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/Tags/Tag.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "packages/grafana-ui/src/components/TimeSeries/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] @@ -1032,6 +1004,45 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-ui/src/graveyard/Graph/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Do not use any type assertions.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/hooks.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "packages/grafana-ui/src/graveyard/TimeSeries/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "packages/grafana-ui/src/options/builder/axis.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1177,6 +1188,23 @@ exports[`better eslint`] = { "public/app/core/components/ForgottenPassword/ForgottenPassword.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], + "public/app/core/components/GraphNG/GraphNG.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Do not use any type assertions.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"] + ], + "public/app/core/components/GraphNG/hooks.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/core/components/Layers/LayerDragDropList.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -1411,6 +1439,11 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"] ], + "public/app/core/components/TimeSeries/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "public/app/core/components/TraceToLogs/TagMappingInput.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 81b5d3e891b..125af1b3853 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -312,9 +312,6 @@ /packages/grafana-ui/src/components/Table/ @grafana/grafana-bi-squad /packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/GraphNG/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/Graph/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad /packages/grafana-ui/src/components/DataLinks/ @grafana/dataviz-squad /packages/grafana-ui/src/components/ValuePicker/ @grafana/dataviz-squad @@ -322,6 +319,9 @@ /packages/grafana-ui/src/components/VizLegend/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizRepeater/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizTooltip/ @grafana/dataviz-squad +/packages/grafana-ui/src/graveyard/Graph/ @grafana/dataviz-squad +/packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad +/packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend /packages/grafana-data/src/transformations/ @grafana/grafana-bi-squad /packages/grafana-data/src/**/*logs* @grafana/observability-logs @@ -366,6 +366,8 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/core/components/TimePicker/ @grafana/grafana-frontend-platform /public/app/core/components/Layers/ @grafana/dataviz-squad /public/app/core/components/TraceToLogs @grafana/observability-traces-and-profiling +/public/app/core/components/GraphNG/ @grafana/dataviz-squad +/public/app/core/components/TimeSeries/ @grafana/dataviz-squad /public/app/features/all.ts @grafana/grafana-frontend-platform /public/app/features/admin/ @grafana/grafana-authnz-team /public/app/features/auth-config/ @grafana/grafana-authnz-team diff --git a/docs/sources/administration/api-keys/index.md b/docs/sources/administration/api-keys/index.md index f2e1be18e75..6ea11889724 100644 --- a/docs/sources/administration/api-keys/index.md +++ b/docs/sources/administration/api-keys/index.md @@ -48,7 +48,7 @@ To follow these instructions, you need at least one of the following: To create an API, complete the following steps: 1. Sign in to Grafana. -1. Click **Administration** in the left-side menu and select **API Keys**. +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. @@ -101,7 +101,7 @@ For more information about permissions, refer to [Roles and permissions]({{< rel To migrate all API keys to service accounts, complete the following steps: -1. Sign in to Grafana, point to **Configuration** (the gear icon), and click **API Keys**. +1. Sign in to Grafana, point to **Administration**, **Users and access**, and click **API Keys**. 1. In the top of the page, find the section which says **Switch from API keys to service accounts** 1. Click **Migrate to service accounts now**. 1. A confirmation window will appear, asking to confirm the migration. Click **Yes, migrate now** if you are willing to continue. @@ -110,7 +110,7 @@ To migrate all API keys to service accounts, complete the following steps: To migrate a single API key to a service account, complete the following steps: 1. Sign in to Grafana. -1. Click **Administration** in the left-side menu and select **API Keys**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **API Keys**. 1. Find the API Key you want to migrate. 1. Click **Migrate to service account**. diff --git a/docs/sources/administration/enterprise-licensing/_index.md b/docs/sources/administration/enterprise-licensing/_index.md index 966e0889d72..fc8e07e7123 100644 --- a/docs/sources/administration/enterprise-licensing/_index.md +++ b/docs/sources/administration/enterprise-licensing/_index.md @@ -52,7 +52,7 @@ There is more than one way to add the license to a Grafana instance: This is the preferred option for single instance installations of Grafana Enterprise. 1. Sign in as a Grafana server administrator. -1. Click **Administration > Stats and license** in the side navigation menu. +1. Click **Administration > General > Stats and license** in the side navigation menu. 1. Click **Upload a new token**. 1. Select your license file, and upload it. @@ -207,6 +207,8 @@ To determine the number of active users: 1. Click **Administration** in the side navigation menu. +1. Click **General**. + 1. Click **Stats and license**. 1. Review the utilization count on the **Utilization** panel. diff --git a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md index 6f6a35e5eea..fa06cd3aa02 100644 --- a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md +++ b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md @@ -112,6 +112,6 @@ In this task you configure Grafana Enterprise to validate the license with AWS i ### Task 4: Start or restart Grafana 1. To restart Grafana and activate your license, update the service running Grafana to use the latest revision of the task definition that you created. -1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > Stats and license** to validate that your license is active. +1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > General > Stats and license** to validate that your license is active. For more information about validating that your license is active, refer to [Grafana Enterprise license restrictions]({{< relref "../../#grafana-enterprise-license-restrictions" >}}). diff --git a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md index a63c1af461d..49060c0989d 100644 --- a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md +++ b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md @@ -123,7 +123,7 @@ To restart Grafana on a Kubernetes cluster, 1. Run the command `kubectl rollout restart deployment my-release`. -1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > Stats and license** to validate that your license is active. +1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > General > Stats and license** to validate that your license is active. For more information about restarting Grafana, refer to [Restart Grafana]({{< relref "../../../../setup-grafana/start-restart-grafana/" >}}). diff --git a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md index 74a1152a1a3..56ba176742d 100644 --- a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md +++ b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md @@ -36,7 +36,7 @@ You can use AWS Marketplace to make the following modifications to your Grafana 1. Sign in to Grafana as a Server Administrator. -1. Click **Administration** in the side navigation menu, and then **Stats and license**. +1. Click **Administration** in the side navigation menu, **General**, and then **Stats and license**. 1. In the **Token** section under **Enterprise License**, click **Renew token**. diff --git a/docs/sources/administration/organization-management/index.md b/docs/sources/administration/organization-management/index.md index 0d93b93e5d6..a332c8110db 100644 --- a/docs/sources/administration/organization-management/index.md +++ b/docs/sources/administration/organization-management/index.md @@ -59,7 +59,7 @@ Complete this task when you want to view a list of existing organizations. **To view a list of organizations:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. ## Create an organization @@ -72,7 +72,7 @@ Create an organization when you want to isolate dashboards and other resources f **To create an organization:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. 1. Click **+ New org**. 1. Enter the name of the new organization and click **Create**. @@ -100,7 +100,7 @@ Deleting the organization also deletes all teams and dashboards associated the o **To delete an organization:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. 1. Click the red **X** next to the organization that you want to delete. 1. Click **Delete**. @@ -115,6 +115,6 @@ Edit an organization when you want to change its name. **To edit an organization:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. 1. Click the organization you want to edit. 1. Update the organization name and click **Update**. diff --git a/docs/sources/administration/organization-preferences/index.md b/docs/sources/administration/organization-preferences/index.md index 211deadfa17..913f6ca8345 100644 --- a/docs/sources/administration/organization-preferences/index.md +++ b/docs/sources/administration/organization-preferences/index.md @@ -44,6 +44,7 @@ Grafana server administrators and organization administrators can change organiz Follow these instructions if you are a Grafana Server Admin. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Organizations**. 1. In the organization list, click the name of the organization that you want to change. 1. In **Name**, enter the new organization name. @@ -54,6 +55,7 @@ Follow these instructions if you are a Grafana Server Admin. If you are an Organization Admin, follow these steps: 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In **Organization name**, enter the new name. 1. Click **Update organization name**. @@ -63,7 +65,7 @@ If you are an Organization Admin, follow these steps: Organization administrators and team administrators can change team names and email addresses. To change the team name or email, follow these steps: -1. Click **Administration** in the left-side menu and select **Team**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Team**. 1. In the team list, click the name of the team that you want to change. 1. Click the **Settings** tab. 1. In the Team details section, you can edit the following: @@ -112,6 +114,7 @@ To see what the current settings are, refer to [View server settings]({{< relref Organization administrators can change the UI theme for all users in an organization. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In the Preferences section, select the UI theme. 1. Click **Save**. @@ -120,7 +123,7 @@ Organization administrators can change the UI theme for all users in an organiza Organization and team administrators can change the UI theme for all users on a team. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the UI theme. 1. Click the **Settings** tab. 1. In the Preferences section, select the UI theme. @@ -149,6 +152,7 @@ Grafana server administrators can choose a default timezone for all users on the Organization administrators can choose a default timezone for their organization. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. 1. Click **Save**. @@ -157,7 +161,7 @@ Organization administrators can choose a default timezone for their organization Organization administrators and team administrators can choose a default timezone for all users on a team. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the timezone. 1. Click the **Settings** tab. 1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. @@ -209,6 +213,7 @@ Organization administrators can choose a default home dashboard for their organi 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. 1. Click **Save**. @@ -219,7 +224,7 @@ Organization administrators and Team Admins can set a default home dashboard for 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the home dashboard. 1. Click the **Settings** tab. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. @@ -246,6 +251,7 @@ Grafana server administrators can change the default Grafana UI language for all Organization administrators can change the language for all users in an organization. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In the Preferences section, select an option in the **Language** dropdown. 1. Click **Save**. @@ -254,7 +260,7 @@ Organization administrators can change the language for all users in an organiza Organization and team administrators can set a default language for all users on a team. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the language. 1. Click the **Settings** tab. 1. In the Preferences section, select an option in the **Language** dropdown. diff --git a/docs/sources/administration/plugin-management/index.md b/docs/sources/administration/plugin-management/index.md index bf84e299280..2fd0da04432 100644 --- a/docs/sources/administration/plugin-management/index.md +++ b/docs/sources/administration/plugin-management/index.md @@ -85,13 +85,13 @@ Before following the steps below, make sure you are logged in as a Grafana admin -Administrators can find the Plugin catalog at **Administration > Plugins**. +Administrators can find the Plugin catalog at **Administration > Plugins and data > Plugins**. ### Browse plugins To browse for available plugins: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click the **All** filter to browse all available plugins. 1. Click the **Data sources**, **Panels**, or **Applications** buttons to filter by plugin type. @@ -99,7 +99,7 @@ To browse for available plugins: To install a plugin: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click the **All** filter to browse all available plugins. 1. Browse and find a plugin. 1. Click on the plugin logo. @@ -111,7 +111,7 @@ When the update is complete, you see a confirmation message that the installatio To update a plugin: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click on the plugin logo. 1. Click **Update**. @@ -121,7 +121,7 @@ When the update is complete, you see a confirmation message that the update was To uninstall a plugin: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click on the plugin logo. 1. Click **Uninstall**. diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 0f3c5f1e846..3a96f6e3111 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -157,6 +157,8 @@ datasources: password: # Sets the basic authorization password. basicAuthPassword: + # Sets the version. Used to compare versions when + # updating. Ignored when creating a new data source. version: 1 # Allows users to edit data sources from the # Grafana UI. diff --git a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md index a86cc311720..5f442a00dfa 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md @@ -54,14 +54,14 @@ In both cases, the assignment applies only to the user, team or service account For more information about switching organizations, refer to [Switch organizations]({{< relref "../../../user-management/user-preferences/_index.md#switch-organizations" >}}). -3. In the left-side menu, click **Administration** and then **Users**, **Teams**, or **Service accounts**. +3. In the left-side menu, click **Administration**, **Users and access**, and then **Users**, **Teams**, or **Service accounts**. 4. In the **Role** column, select the fixed role that you want to assign to the user, team, or service account. 5. Click **Update**. **To assign a fixed role as a server administrator:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organizations section, click **Change role**. 1. Select a role within an organization that you want to assign to the user. diff --git a/docs/sources/administration/stats-and-license/index.md b/docs/sources/administration/stats-and-license/index.md index 52255d2e021..1643fd505cf 100644 --- a/docs/sources/administration/stats-and-license/index.md +++ b/docs/sources/administration/stats-and-license/index.md @@ -34,7 +34,7 @@ If you are a Grafana server administrator, use the Settings tab to view the sett ### View server settings 1. Log in to your Grafana server with an account that has the Grafana Admin flag set. -1. Click **Administration** in the left-side menu, and then **Settings**. +1. Click **Administration** in the left-side menu, **General**, and then **Settings**. ### Available settings @@ -51,7 +51,7 @@ If you are a Grafana server admin, then you can view useful statistics about you ### View server stats 1. Log in to your Grafana server with an account that has the Grafana Admin flag set. -1. Click **Administration** in the left-side menu, and then **Stats and license**. +1. Click **Administration** in the left-side menu, **General**, and then **Stats and license**. ### Available stats diff --git a/docs/sources/administration/team-management/index.md b/docs/sources/administration/team-management/index.md index 9ade42e2555..747f55e1971 100644 --- a/docs/sources/administration/team-management/index.md +++ b/docs/sources/administration/team-management/index.md @@ -45,7 +45,7 @@ A user can belong to multiple teams. To create a team: 1. Sign in to Grafana as an organization administrator or team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click **New Team**. 1. Complete the fields and click **Create**. 1. Click **Add member**. @@ -59,7 +59,7 @@ Add a team member to an existing team whenever you want to provide access to tea To add a team member: 1. Sign in to Grafana as an organization administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the name of the team to which you want to add members, and click **Add member**. 1. Locate and select a user. 1. Choose if you want to add the user as a team Member or an Admin. @@ -72,7 +72,7 @@ Complete this task when you want to add or modify team member permissions. To grant team member permissions: 1. Sign in to Grafana as an organization administrator or a team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the name of the team for which you want to add or modify team member permissions. 1. In the team member list, find and click the user that you want to change. You can use the search field to filter the list if necessary. 1. In the Permission column, select the new user permission level. @@ -84,7 +84,7 @@ You can remove a team member when you no longer want to apply team permissions t To remove a team member: 1. Sign in to Grafana as an organization administrator or team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click a team from which you want to remove a user. 1. Click the **X** next to the name of the user. @@ -95,7 +95,7 @@ Delete a team when you no longer need it. This action permanently deletes the te To delete a team: 1. Sign in to Grafana as an organization administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the **X** next to the name of the team. 1. Click **Delete**. @@ -106,7 +106,7 @@ See the complete list of teams in your Grafana organization. To view a list of teams: 1. Sign in to Grafana as an organization administrator or a team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. The role you use to sign in to Grafana determines how you see team lists. diff --git a/docs/sources/administration/user-management/manage-org-users/index.md b/docs/sources/administration/user-management/manage-org-users/index.md index c01c8546679..85c8a8bb882 100644 --- a/docs/sources/administration/user-management/manage-org-users/index.md +++ b/docs/sources/administration/user-management/manage-org-users/index.md @@ -38,7 +38,7 @@ You can see a list of users with accounts in your Grafana organization. If neces **To view a list of organization users**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. {{% admonition type="note" %}} If you have [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can also [view a global list of users]({{< relref "../server-user-management#view-a-list-of-users" >}}) in the Server Admin section of Grafana. @@ -59,7 +59,7 @@ Organization roles sync from the authentication provider on user sign-in. To pre **To change the organization role of a user**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Find the user account for which you want to change the role. If necessary, use the search field to filter the list. @@ -96,7 +96,7 @@ If you have [server administrator]({{< relref "../../roles-and-permissions/#graf > **Note**: It might be that you are currently in the proper organization and don't need to switch organizations. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Click **Organization users**. 1. Click **Invite**. 1. Enter the following information: @@ -127,7 +127,7 @@ The **Pending Invites** button is only visible if there are unanswered invitatio **To manage a pending invitation**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Click **Pending Invites**. The **Pending Invites** button appears only when there are unaccepted invitations. @@ -149,7 +149,7 @@ This action does not remove the user account from the Grafana server. **To remove a user from an organization**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Find the user account that you want to remove from the organization. Use the search field to filter the list, if necessary. diff --git a/docs/sources/administration/user-management/server-user-management/_index.md b/docs/sources/administration/user-management/server-user-management/_index.md index 50a38bed569..f5d14efcd25 100644 --- a/docs/sources/administration/user-management/server-user-management/_index.md +++ b/docs/sources/administration/user-management/server-user-management/_index.md @@ -39,7 +39,7 @@ You can see a list of users with accounts on your Grafana server. This action mi **To view a list of users**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. {{% admonition type="note" %}} If you have [organization administrator]({{< relref "../../roles-and-permissions/#organization-roles" >}}) permissions and _not_ [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can still [view of list of users in a given organization]({{< relref "../manage-org-users/#view-a-list-of-organization-users" >}}). @@ -56,7 +56,7 @@ View user details when you want to see login, and organizations and permissions **To view user details**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. A user account contains the following sections. @@ -88,7 +88,7 @@ Edit a user account when you want to modify user login credentials, or delete, d **To edit a user account**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. Complete any of the following actions, as necessary. @@ -115,7 +115,7 @@ When you configure advanced authentication using Oauth, SAML, LDAP, or the Auth **To add a user**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click **New user**. 1. Complete the fields and click **Create user**. @@ -136,7 +136,7 @@ The force logout action can apply to one device that is logged in to Grafana, or - Ensure you have Grafana server administrator privileges 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. Scroll down to the Sessions section. 1. Perform one of the following actions: diff --git a/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md b/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md index 1d8f5a70ac3..04ddd0dc490 100644 --- a/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md +++ b/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md @@ -31,7 +31,7 @@ You are required to specify an Admin role for each organization. The first user **To add a user to an organization**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organizations section, click **Add user to organization**. 1. Select an organization and a role. @@ -57,7 +57,7 @@ Remove a user from an organization when they no longer require access to the das **To remove a user from an organization**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organization section, click **Remove from organization** next to the organization from which you want to remove the user. 1. Click **Confirm removal**. diff --git a/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md b/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md index d02cc9c19a3..c017f104bdd 100644 --- a/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md +++ b/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md @@ -27,7 +27,7 @@ Server administrators are "super-admins" with full permissions to create, read, **To assign or remove Grafana administrator privileges**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Permissions section, next to Grafana Admin, click **Change**. 1. Click **Yes** or **No**, depending on whether or not you want this user to have the Grafana server administrator role. diff --git a/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md b/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md index 3a72c6b15b1..4569a5ac8f2 100644 --- a/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md +++ b/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md @@ -21,7 +21,7 @@ Update organization permissions when you want to enhance or restrict a user's ac **To change a user's organization permissions**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organizations section, click **Change role** for the role you want to change 1. Select another role. diff --git a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md index 1452d8774fd..df4d192de5f 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -202,7 +202,7 @@ Create alerts from any panel type. This means you can reuse the queries in the p 1. Navigate to a dashboard in the **Dashboards** section. 2. In the top right corner of the panel, click on the three dots (ellipses). -3. From the dropdown menu, select **More...** and then choose **Create alert**. +3. From the dropdown menu, select **More...** and then choose **New alert rule**. This will open the alert rule form, allowing you to configure and create your alert based on the current panel's query. 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 705b76079a0..9aec15cb7fe 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -59,7 +59,6 @@ Some features are enabled by default. You can disable these feature by setting t | Feature toggle name | Description | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `trimDefaults` | Use cue schema to remove values that will be applied automatically | | `panelTitleSearch` | Search for dashboards using panel title | | `migrationLocking` | Lock database during migrations | | `correlations` | Correlations page | @@ -162,6 +161,8 @@ Experimental features might be changed or removed without prior notice. | `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. | | `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. | | `annotationPermissionUpdate` | Separate annotation permissions from dashboard permissions to allow for more granular control. | +| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe | +| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | ## Development feature toggles diff --git a/docs/sources/tutorials/create-alerts-with-logs/index.md b/docs/sources/tutorials/create-alerts-with-logs/index.md index d62cd413fb4..faaad9a2839 100644 --- a/docs/sources/tutorials/create-alerts-with-logs/index.md +++ b/docs/sources/tutorials/create-alerts-with-logs/index.md @@ -44,7 +44,7 @@ In these steps you'll create an alert and define an expression to evaluate. Thes ### Create a Grafana-managed alert -1. Navigate in Grafana to **Alerting**, then to **Alert Rules** and click **+ Create alert rule**. +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: diff --git a/docs/sources/tutorials/grafana-fundamentals/index.md b/docs/sources/tutorials/grafana-fundamentals/index.md index abff02d94af..e2dd6d1ed8d 100644 --- a/docs/sources/tutorials/grafana-fundamentals/index.md +++ b/docs/sources/tutorials/grafana-fundamentals/index.md @@ -328,7 +328,7 @@ We have now created a dummy webhook endpoint and created a new Alerting Contact Now that Grafana knows how to notify us, it's time to set up an alert rule: 1. In the sidebar, click **Alert rules**. -1. Click **Create alert rule**. +1. Click **New alert rule**. 1. In section **1**, name the rule `fundamentals-test`. 1. For section **2**, select **Grafana Managed Alert** as the rule type. Next, find query box **A**. Choose your Prometheus datasource and enter the same query that we used in our earlier panel: `sum(rate(tns_request_duration_seconds_count[5m])) by(route)`. Click **Run queries**. You should see some data in the graph. 1. Now scroll down to query box **B**. Change the operation from **Reduce** to **Classic condition**. [You can read more about classic and multi-dimensional conditions here](/docs/grafana/latest/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule/#single-and-multi-dimensional-rule). For conditions enter the following: `WHEN last() OF A IS ABOVE 0.2`. Delete query **C**. diff --git a/docs/sources/tutorials/integrate-hubot/index.md b/docs/sources/tutorials/integrate-hubot/index.md index d6b769987f1..35299df80b6 100644 --- a/docs/sources/tutorials/integrate-hubot/index.md +++ b/docs/sources/tutorials/integrate-hubot/index.md @@ -25,10 +25,7 @@ Grafana 2.0 shipped with a great feature that enables it to render any graph or No matter what data source you are using, the PNG image of the Graph will look the same as it does in your browser. -This guide will show you how to install and configure the [Hubot-Grafana](https://github.com/stephenyeargin/hubot-grafana) plugin. This plugin allows you to tell hubot to render any dashboard or graph right from a channel in Slack, Hipchat or Basecamp. The bot will respond with an image of the graph and a link that will take you to the graph. - -> _Amazon S3 Required_: The hubot-grafana script will upload the rendered graphs to Amazon S3. This -> is so Hipchat and Slack can show them reliably (they require the image to be publicly available). +This guide shows you how to install and configure the [Hubot-Grafana](https://github.com/stephenyeargin/hubot-grafana) plugin. This plugin allows you to tell Hubot to render any dashboard or graph right from a channel in Slack, Basecamp, or any other supported Hubot adapter. The bot will respond with an image of the graph and a link that will take you to the graph. {{< figure src="/static/img/docs/tutorials/hubot_grafana.png" max-width="800px" >}} @@ -40,7 +37,7 @@ This guide will show you how to install and configure the [Hubot-Grafana](https: Hubot is very easy to install and host. If you do not already have a bot up and running please read the official [Getting Started With Hubot](https://hubot.github.com/docs/) guide. -## Install Hubot-Grafana script +## Install the Hubot-Grafana script In your Hubot project repo install the Grafana plugin using `npm`: @@ -56,18 +53,15 @@ Edit the file external-scripts.json, and add hubot-grafana to the list of plugin ## Configure -The `hubot-grafana` plugin requires a number of environment variables to be set in order to work properly. +The Hubot-Grafana plugin requires two environment variables to be set in order to work properly. ```bash export HUBOT_GRAFANA_HOST=https://play.grafana.org export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234 -export HUBOT_GRAFANA_S3_BUCKET=mybucket -export HUBOT_GRAFANA_S3_ACCESS_KEY_ID=ABCDEF123456XYZ -export HUBOT_GRAFANA_S3_SECRET_ACCESS_KEY=aBcD01234dEaDbEef01234 -export HUBOT_GRAFANA_S3_PREFIX=graphs -export HUBOT_GRAFANA_S3_REGION=us-standard ``` +There are [additional environment variables](https://github.com/stephenyeargin/hubot-grafana?tab=readme-ov-file#general-settings) that you can set to control the appearance of the graphs. + ### Grafana server side rendering The hubot plugin will take advantage of the Grafana server side rendering feature that can render any panel on the server using phantomjs. Grafana ships with a phantomjs binary (Linux only). @@ -80,9 +74,9 @@ To verify that this feature works try the `Direct link to rendered image` link i You need to set the environment variable `HUBOT_GRAFANA_API_KEY` to a Grafana API Key. You can add these from the API Keys page which you find in the Organization dropdown. -### Amazon S3 +### Image uploading -The `S3` options are optional but for the images to work properly in services like Slack and Hipchat they need to publicly available. By specifying the `S3` options the hubot-grafana script will publish the rendered panel to `S3` and it will use that URL when it posts to Slack or Hipchat. +There are several approaches to uploading the rendered graphs. If you are using Slack, Rocket.Chat, or Telegram, the adapter's native uploader will take care of sending it through their respective API. If your Hubot is hosted on a platform that doesn't support uploads (such as IRC), you can use the [built-in S3 uploader](https://github.com/stephenyeargin/hubot-grafana/wiki/Amazon-S3-Image-Hosting). Note if you configure S3, it will not use the adapter's upload features. ## Hubot commands diff --git a/docs/sources/whatsnew/whats-new-next/index.md b/docs/sources/whatsnew/whats-new-next/index.md index 743f5457d0c..61e4375d39d 100644 --- a/docs/sources/whatsnew/whats-new-next/index.md +++ b/docs/sources/whatsnew/whats-new-next/index.md @@ -229,6 +229,17 @@ The Grafana Assume Role authentication provider lets Grafana Cloud users of the To learn more, refer to the [CloudWatch authentication documentation](/docs/grafana/next/datasources/aws-cloudwatch/aws-authentication). +## Create alerts from panels + + + + +October 23, 2023 + +_Generally available in Grafana Cloud_ + +Create alerts from dashboard panels. You can reuse the panel queries and create alerts based on them. + ## No basic role diff --git a/go.mod b/go.mod index 14b37ffc1c7..cf38d15efaf 100644 --- a/go.mod +++ b/go.mod @@ -65,7 +65,7 @@ require ( github.com/grafana/cuetsy v0.1.10 // @grafana/grafana-as-code github.com/grafana/grafana-aws-sdk v0.19.1 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go v1.9.0 // @grafana/backend-platform - github.com/grafana/grafana-plugin-sdk-go v0.187.0 // @grafana/plugins-platform-backend + github.com/grafana/grafana-plugin-sdk-go v0.189.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/backend-platform github.com/hashicorp/go-hclog v1.5.0 // @grafana/plugins-platform-backend github.com/hashicorp/go-plugin v1.4.9 // @grafana/plugins-platform-backend diff --git a/go.sum b/go.sum index 9de11b6e870..94188a7f9f9 100644 --- a/go.sum +++ b/go.sum @@ -1841,6 +1841,8 @@ github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW3 github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= github.com/grafana/grafana-plugin-sdk-go v0.187.0 h1:lOwoFbbTs27KqR3F32GvOX9Et3Ek8p8qsFw+SUJtAAM= github.com/grafana/grafana-plugin-sdk-go v0.187.0/go.mod h1:PHK8eQOz3ES28RmImdTHNOTxBZaH6mb/ytJGxk7VVJc= +github.com/grafana/grafana-plugin-sdk-go v0.189.0 h1:30n0dtehLT0Z0DdRfatk1INwXOKfCb9LKaSFl8zBj9g= +github.com/grafana/grafana-plugin-sdk-go v0.189.0/go.mod h1:nctofuR6fyhx3Stbnh5ha6setroeqqBgO0Rj9s4t86o= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482/go.mod h1:GNcfpy5+SY6RVbNGQW264gC0r336Dm+0zgQ5vt6+M8Y= github.com/grafana/prometheus-alertmanager v0.25.1-0.20231027171310-70c52bf65758 h1:ATUhvJSJwzdzhnmzUI92fxVFqyqmcnzJ47wtHTK3LW4= diff --git a/package.json b/package.json index 96f39cd20f2..77b88bd2d83 100644 --- a/package.json +++ b/package.json @@ -252,7 +252,7 @@ "@grafana/flamegraph": "workspace:*", "@grafana/google-sdk": "0.1.1", "@grafana/lezer-logql": "0.2.1", - "@grafana/lezer-traceql": "0.0.8", + "@grafana/lezer-traceql": "0.0.9", "@grafana/monaco-logql": "^0.0.7", "@grafana/runtime": "workspace:*", "@grafana/scenes": "^1.20.1", diff --git a/packages/grafana-data/src/field/fieldState.ts b/packages/grafana-data/src/field/fieldState.ts index 6f816ca5cf5..55754aa9179 100644 --- a/packages/grafana-data/src/field/fieldState.ts +++ b/packages/grafana-data/src/field/fieldState.ts @@ -159,7 +159,7 @@ export function calculateFieldDisplayName(field: Field, frame?: DataFrame, allFr return displayName; } -function getUniqueFieldName(field: Field, frame?: DataFrame) { +export function getUniqueFieldName(field: Field, frame?: DataFrame) { let dupeCount = 0; let foundSelf = false; diff --git a/packages/grafana-data/src/field/index.ts b/packages/grafana-data/src/field/index.ts index b457d674034..e7b483bdd88 100644 --- a/packages/grafana-data/src/field/index.ts +++ b/packages/grafana-data/src/field/index.ts @@ -14,5 +14,5 @@ export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; export { sortThresholds, getActiveThreshold } from './thresholds'; export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides, useFieldOverrides } from './fieldOverrides'; export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; -export { getFieldDisplayName, getFrameDisplayName, cacheFieldDisplayNames } from './fieldState'; +export { getFieldDisplayName, getFrameDisplayName, cacheFieldDisplayNames, getUniqueFieldName } from './fieldState'; export { getScaleCalculator, getFieldConfigWithMinMax, getMinMaxAndDelta } from './scale'; diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts index 1fd3229f897..4cdd4ea2f8b 100644 --- a/packages/grafana-data/src/transformations/index.ts +++ b/packages/grafana-data/src/transformations/index.ts @@ -20,3 +20,6 @@ export type { RenameByRegexTransformerOptions } from './transformers/renameByReg export { joinDataFrames as outerJoinDataFrames, isLikelyAscendingVector } from './transformers/joinDataFrames'; export * from './transformers/histogram'; export { ensureTimeField } from './transformers/convertFieldType'; + +// Required for Sparklines util to work in @grafana/data, but ideally kept internal +export { applyNullInsertThreshold } from './transformers/nulls/nullInsertThreshold'; diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.test.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts rename to packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.test.ts diff --git a/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts new file mode 100644 index 00000000000..94d8dd2e3c0 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts @@ -0,0 +1,186 @@ +import { DataFrame, FieldType } from '../../../types'; + +type InsertMode = (prev: number, next: number, threshold: number) => number; + +const INSERT_MODES = { + threshold: (prev: number, next: number, threshold: number) => prev + threshold, + midpoint: (prev: number, next: number, threshold: number) => (prev + next) / 2, + // previous time + 1ms to prevent StateTimeline from forward-interpolating prior state + plusone: (prev: number, next: number, threshold: number) => prev + 1, +}; + +interface NullInsertOptions { + frame: DataFrame; + refFieldName?: string | null; + refFieldPseudoMax?: number; + refFieldPseudoMin?: number; + insertMode?: InsertMode; +} + +function getRefField(frame: DataFrame, refFieldName?: string | null) { + return frame.fields.find((field) => { + // note: getFieldDisplayName() would require full DF[] + return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time; + }); +} + +/** @internal */ +export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame { + if (opts.frame.length === 0) { + return opts.frame; + } + + let thorough = true; + let { frame, refFieldName, refFieldPseudoMax, refFieldPseudoMin, insertMode } = opts; + + if (!insertMode) { + insertMode = INSERT_MODES.threshold; + } + + const refField = getRefField(frame, refFieldName); + + if (refField == null) { + return frame; + } + + refField.state = { + ...refField.state, + nullThresholdApplied: true, + }; + + const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls || refField.config.interval || null); + + const uniqueThresholds = new Set(thresholds); + + uniqueThresholds.delete(null as any); + + if (uniqueThresholds.size === 0) { + return frame; + } + + if (uniqueThresholds.size === 1) { + const threshold = uniqueThresholds.values().next().value; + + if (threshold <= 0) { + return frame; + } + + const refValues = refField.values; + + const frameValues = frame.fields.map((field) => field.values); + + const filledFieldValues = nullInsertThreshold( + refValues, + frameValues, + threshold, + refFieldPseudoMin, + refFieldPseudoMax, + insertMode, + thorough + ); + + if (filledFieldValues === frameValues) { + return frame; + } + + return { + ...frame, + length: filledFieldValues[0].length, + fields: frame.fields.map((field, i) => ({ + ...field, + values: filledFieldValues[i], + })), + }; + } + + // TODO: unique threshold-per-field (via overrides) is unimplemented + // should be done by processing each (refField + thresholdA-field1 + thresholdA-field2...) + // as a separate nullInsertThreshold() dataset, then re-join into single dataset via join() + return frame; +} + +function nullInsertThreshold( + refValues: number[], + frameValues: any[][], + threshold: number, + refFieldPseudoMin: number | null = null, + // will insert a trailing null when refFieldPseudoMax > last datapoint + threshold + refFieldPseudoMax: number | null = null, + getInsertValue: InsertMode, + // will insert the value at every missing interval + thorough: boolean +) { + const len = refValues.length; + const refValuesNew: number[] = []; + + // Continuously subtract the threshold from the first data point, filling in insert values accordingly + if (refFieldPseudoMin != null && refFieldPseudoMin < refValues[0]) { + let preFillCount = Math.ceil((refValues[0] - refFieldPseudoMin) / threshold); + // this will be 0 or 1 threshold increment left of visible range + let prevSlot = refValues[0] - preFillCount * threshold; + + while (prevSlot < refValues[0]) { + // (prevSlot - threshold) is used to simulate the previous 'real' data point, as getInsertValue expects + refValuesNew.push(getInsertValue(prevSlot - threshold, prevSlot, threshold)); + prevSlot += threshold; + } + } + + // Insert initial value + refValuesNew.push(refValues[0]); + + let prevValue: number = refValues[0]; + + // Fill nulls when a value is greater than the threshold value + for (let i = 1; i < len; i++) { + const curValue = refValues[i]; + + while (curValue - prevValue > threshold) { + refValuesNew.push(getInsertValue(prevValue, curValue, threshold)); + + prevValue += threshold; + + if (!thorough) { + break; + } + } + + refValuesNew.push(curValue); + + prevValue = curValue; + } + + // At the end of the sequence + if (refFieldPseudoMax != null && refFieldPseudoMax > prevValue) { + while (prevValue + threshold < refFieldPseudoMax) { + refValuesNew.push(getInsertValue(prevValue, refFieldPseudoMax, threshold)); + prevValue += threshold; + } + } + + const filledLen = refValuesNew.length; + + if (filledLen === len) { + return frameValues; + } + + const filledFieldValues: any[][] = []; + + for (let fieldValues of frameValues) { + let filledValues; + + if (fieldValues !== refValues) { + filledValues = Array(filledLen); + + for (let i = 0, j = 0; i < filledLen; i++) { + filledValues[i] = refValues[j] === refValuesNew[i] ? fieldValues[j++] : null; + } + } else { + filledValues = refValuesNew; + } + + filledFieldValues.push(filledValues); + } + + return filledFieldValues; +} diff --git a/packages/grafana-ui/src/components/GraphNG/nullToUndefThreshold.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToUndefThreshold.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/nullToUndefThreshold.ts rename to packages/grafana-data/src/transformations/transformers/nulls/nullToUndefThreshold.ts diff --git a/packages/grafana-ui/src/components/GraphNG/nullToValue.test.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.test.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/nullToValue.test.ts rename to packages/grafana-data/src/transformations/transformers/nulls/nullToValue.test.ts diff --git a/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts new file mode 100644 index 00000000000..4c3ba5a81e6 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts @@ -0,0 +1,27 @@ +import { DataFrame } from '../../../types'; + +export function nullToValue(frame: DataFrame) { + return { + ...frame, + fields: frame.fields.map((field) => { + const noValue = +field.config?.noValue!; + + if (!Number.isNaN(noValue)) { + const transformedVals = field.values.slice(); + + for (let i = 0; i < transformedVals.length; i++) { + if (transformedVals[i] === null) { + transformedVals[i] = noValue; + } + } + + return { + ...field, + values: transformedVals, + }; + } else { + return field; + } + }), + }; +} diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 0bd99acbfbd..ea196a1fa03 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -33,10 +33,13 @@ export interface ExplorePanelsState extends Partial; vars: Record; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b99a9573f11..9315579eb18 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -18,7 +18,6 @@ * @public */ export interface FeatureToggles { - trimDefaults?: boolean; disableEnvelopeEncryption?: boolean; ['live-service-web-worker']?: boolean; queryOverLive?: boolean; @@ -156,4 +155,6 @@ export interface FeatureToggles { alertmanagerRemotePrimary?: boolean; alertmanagerRemoteOnly?: boolean; annotationPermissionUpdate?: boolean; + extractFieldsNameDeduplication?: boolean; + dashboardSceneForViewers?: boolean; } diff --git a/packages/grafana-data/src/valueFormats/valueFormats.test.ts b/packages/grafana-data/src/valueFormats/valueFormats.test.ts index 5bac61ae896..b0776ae4b41 100644 --- a/packages/grafana-data/src/valueFormats/valueFormats.test.ts +++ b/packages/grafana-data/src/valueFormats/valueFormats.test.ts @@ -72,7 +72,7 @@ describe('valueFormats', () => { ${'dtdurationms'} | ${undefined} | ${100000} | ${'1 minute'} ${'dtdurationms'} | ${undefined} | ${150000} | ${'2 minutes'} `( - 'With format=$format decimals=$decimals and value=$value then result shoudl be = $expected', + 'With format=$format decimals=$decimals and value=$value then result should be = $expected', async ({ format, value, decimals, expected }) => { const result = getValueFormat(format)(value, decimals, undefined, undefined); const full = formattedValueToString(result); diff --git a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx index eed018c3648..b22711457ff 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx @@ -1,11 +1,9 @@ import { css, cx } from '@emotion/css'; -import classNames from 'classnames'; import React, { useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { Icon } from '../Icon/Icon'; export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { const [showScrollTopIndicator, setShowTopScrollIndicator] = useState(false); @@ -39,9 +37,7 @@ export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { className={cx(styles.scrollIndicator, styles.scrollTopIndicator, { [styles.scrollIndicatorVisible]: showScrollTopIndicator, })} - > - - + />
{children} @@ -51,9 +47,7 @@ export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { className={cx(styles.scrollIndicator, styles.scrollBottomIndicator, { [styles.scrollIndicatorVisible]: showScrollBottomIndicator, })} - > - -
+ /> ); }; @@ -85,16 +79,5 @@ const getStyles = (theme: GrafanaTheme2) => { scrollIndicatorVisible: css({ opacity: 1, }), - scrollIcon: css({ - left: '50%', - position: 'absolute', - transform: 'translateX(-50%)', - }), - scrollTopIcon: css({ - top: 0, - }), - scrollBottomIcon: css({ - bottom: 0, - }), }; }; diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index c643d375a37..aa77fd64dc4 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -5,11 +5,10 @@ import { FieldType, isLikelyAscendingVector, sortDataFrame, + applyNullInsertThreshold, } from '@grafana/data'; import { GraphFieldConfig } from '@grafana/schema'; -import { applyNullInsertThreshold } from '../GraphNG/nullInsertThreshold'; - /** @internal * Given a sparkline config returns a DataFrame ready to be turned into Plot data set **/ diff --git a/packages/grafana-ui/src/components/Text/Text.tsx b/packages/grafana-ui/src/components/Text/Text.tsx index eb036f186e7..29f3a46c113 100644 --- a/packages/grafana-ui/src/components/Text/Text.tsx +++ b/packages/grafana-ui/src/components/Text/Text.tsx @@ -1,12 +1,11 @@ import { css } from '@emotion/css'; -import React, { createElement, CSSProperties, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import ReactDomServer from 'react-dom/server'; +import React, { createElement, CSSProperties } from 'react'; import { GrafanaTheme2, ThemeTypographyVariantTypes } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { Tooltip } from '../Tooltip/Tooltip'; +import { TruncatedText } from './TruncatedText'; import { customWeight, customColor, customVariant } from './utils'; export interface TextProps extends Omit, 'className' | 'style'> { @@ -30,70 +29,35 @@ export interface TextProps extends Omit, 'clas export const Text = React.forwardRef( ({ element = 'span', variant, weight, color, truncate, italic, textAlignment, children, ...restProps }, ref) => { const styles = useStyles2(getTextStyles, element, variant, color, weight, truncate, italic, textAlignment); - const [isOverflowing, setIsOverflowing] = useState(false); - const internalRef = useRef(null); - // wire up the forwarded ref to the internal ref - useImperativeHandle(ref, () => internalRef.current); - - const childElement = createElement( - element, - { - ...restProps, - style: undefined, // remove style prop to avoid overriding the styles - className: styles, - // when overflowing, the internalRef is passed to the tooltip which forwards it on to the child element - ref: isOverflowing ? undefined : internalRef, - }, - children - ); - - const resizeObserver = useMemo( - () => - new ResizeObserver((entries) => { - for (const entry of entries) { - if (entry.target.clientWidth && entry.target.scrollWidth) { - if (entry.target.scrollWidth > entry.target.clientWidth) { - setIsOverflowing(true); - } - if (entry.target.scrollWidth <= entry.target.clientWidth) { - setIsOverflowing(false); - } - } - } - }), - [] - ); - - useEffect(() => { - const { current } = internalRef; - if (current && truncate) { - resizeObserver.observe(current); - } - return () => { - resizeObserver.disconnect(); - }; - }, [isOverflowing, resizeObserver, truncate]); - - const getTooltipText = (children: NonNullable) => { - if (typeof children === 'string') { - return children; - } - const html = ReactDomServer.renderToStaticMarkup(<>{children}); - const getRidOfTags = html.replace(/(<([^>]+)>)/gi, ''); - return getRidOfTags; - }; - // A 'span' is an inline element therefore it can't be truncated - // and it should be wrapped in a parent element that is the one that will show the tooltip - if (truncate && isOverflowing && element !== 'span') { - return ( - - {childElement} - + const childElement = (ref: React.ForwardedRef | undefined) => { + return createElement( + element, + { + ...restProps, + style: undefined, // Remove the style prop to avoid overriding the styles + className: styles, + // When overflowing, the internalRef is passed to the tooltip, which forwards it to the child element + ref, + }, + children ); - } else { - return childElement; + }; + + // A 'span' is an inline element, so it can't be truncated + // and it should be wrapped in a parent element that will show the tooltip + if (!truncate || element === 'span') { + return childElement(undefined); } + + return ( + + ); } ); diff --git a/packages/grafana-ui/src/components/Text/TruncatedText.tsx b/packages/grafana-ui/src/components/Text/TruncatedText.tsx new file mode 100644 index 00000000000..e74cbd36bb7 --- /dev/null +++ b/packages/grafana-ui/src/components/Text/TruncatedText.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import ReactDOMServer from 'react-dom/server'; + +import { Tooltip } from '../Tooltip/Tooltip'; + +interface TruncatedTextProps { + childElement: (ref: React.ForwardedRef | undefined) => React.ReactElement; + children: NonNullable; +} + +export const TruncatedText = React.forwardRef(({ childElement, children }, ref) => { + const [isOverflowing, setIsOverflowing] = useState(false); + const internalRef = useRef(null); + + // Wire up the forwarded ref to the internal ref + useImperativeHandle(ref, () => internalRef.current); + + const resizeObserver = useMemo( + () => + new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target.clientWidth && entry.target.scrollWidth) { + if (entry.target.scrollWidth > entry.target.clientWidth) { + setIsOverflowing(true); + } + if (entry.target.scrollWidth <= entry.target.clientWidth) { + setIsOverflowing(false); + } + } + } + }), + [] + ); + + useEffect(() => { + const { current } = internalRef; + if (current) { + resizeObserver.observe(current); + } + return () => { + resizeObserver.disconnect(); + }; + }, [setIsOverflowing, resizeObserver]); + + const getTooltipText = (children: NonNullable) => { + if (typeof children === 'string') { + return children; + } + const html = ReactDOMServer.renderToStaticMarkup(<>{children}); + return html.replace(/(<([^>]+)>)/gi, ''); + }; + + if (isOverflowing) { + return ( + + {childElement(undefined)} + + ); + } else { + return childElement(internalRef); + } +}); + +TruncatedText.displayName = 'TruncatedText'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 0f5dd38ea02..72c8be994c5 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -117,9 +117,6 @@ export { export { Sparkline } from './Sparkline/Sparkline'; export { Gauge } from './Gauge/Gauge'; -export { Graph } from './Graph/Graph'; -export { GraphWithLegend } from './Graph/GraphWithLegend'; -export { GraphContextMenu, GraphContextMenuHeader } from './Graph/GraphContextMenu'; export { BarGauge } from './BarGauge/BarGauge'; export { VizTooltip, @@ -130,7 +127,6 @@ export { type SeriesTableRowProps, } from './VizTooltip'; export { VizRepeater, type VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater'; -export { graphTimeFormat, graphTickFormatter } from './Graph/utils'; export { PanelChrome, type PanelChromeProps, @@ -154,7 +150,7 @@ export { VizLegend } from './VizLegend/VizLegend'; export { VizLegendListItem } from './VizLegend/VizLegendListItem'; export { Alert, type AlertVariant } from './Alert/Alert'; -export { GraphSeriesToggler, type GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler'; +export { GraphSeriesToggler, type GraphSeriesTogglerAPI } from '../graveyard/Graph/GraphSeriesToggler'; export { Collapse, ControlledCollapse } from './Collapse/Collapse'; export { CollapsableSection } from './Collapse/CollapsableSection'; export { DataLinkButton } from './DataLinks/DataLinkButton'; @@ -215,6 +211,7 @@ export { Link } from './Link/Link'; export { TextLink } from './Link/TextLink'; export { Text } from './Text/Text'; export { Box } from './Layout/Box/Box'; +export { Stack } from './Layout/Stack/Stack'; export { Label } from './Forms/Label'; export { Field, type FieldProps } from './Forms/Field'; @@ -289,13 +286,24 @@ export { UPlotChart } from './uPlot/Plot'; export { PlotLegend } from './uPlot/PlotLegend'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; -export { type PlotTooltipInterpolator, type PlotSelection } from './uPlot/types'; +export { type PlotTooltipInterpolator, type PlotSelection, FIXED_UNIT } from './uPlot/types'; export { type UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder'; -export { GraphNG, type GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG'; -export { TimeSeries } from './TimeSeries/TimeSeries'; -export { useGraphNGContext } from './GraphNG/hooks'; -export { preparePlotFrame, buildScaleKey } from './GraphNG/utils'; -export { type GraphNGLegendEvent } from './GraphNG/types'; export * from './PanelChrome/types'; export { Label as BrowserLabel } from './BrowserLabel/Label'; export { PanelContainer } from './PanelContainer/PanelContainer'; + +// ----------------------------------------------------- +// Graveyard: exported, but no longer used internally +// These will be removed in the future +// ----------------------------------------------------- + +export { Graph } from '../graveyard/Graph/Graph'; +export { GraphWithLegend } from '../graveyard/Graph/GraphWithLegend'; +export { GraphContextMenu, GraphContextMenuHeader } from '../graveyard/Graph/GraphContextMenu'; +export { graphTimeFormat, graphTickFormatter } from '../graveyard/Graph/utils'; + +export { GraphNG, type GraphNGProps } from '../graveyard/GraphNG/GraphNG'; +export { TimeSeries } from '../graveyard/TimeSeries/TimeSeries'; +export { useGraphNGContext } from '../graveyard/GraphNG/hooks'; +export { preparePlotFrame, buildScaleKey } from '../graveyard/GraphNG/utils'; +export { type GraphNGLegendEvent } from '../graveyard/GraphNG/types'; diff --git a/packages/grafana-ui/src/components/uPlot/internal.ts b/packages/grafana-ui/src/components/uPlot/internal.ts new file mode 100644 index 00000000000..e7a9c6a14d1 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/internal.ts @@ -0,0 +1,40 @@ +import { FieldConfig, FieldType } from '@grafana/data'; +import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; + +import { FIXED_UNIT } from './types'; + +/** + * @internal -- not a public API + */ +export function buildScaleKey(config: FieldConfig, fieldType: FieldType) { + const defaultPart = 'na'; + + const scaleRange = `${config.min !== undefined ? config.min : defaultPart}-${ + config.max !== undefined ? config.max : defaultPart + }`; + + const scaleSoftRange = `${config.custom?.axisSoftMin !== undefined ? config.custom.axisSoftMin : defaultPart}-${ + config.custom?.axisSoftMax !== undefined ? config.custom.axisSoftMax : defaultPart + }`; + + const scalePlacement = `${ + config.custom?.axisPlacement !== undefined ? config.custom?.axisPlacement : AxisPlacement.Auto + }`; + + const scaleUnit = config.unit ?? FIXED_UNIT; + + const scaleDistribution = config.custom?.scaleDistribution + ? getScaleDistributionPart(config.custom.scaleDistribution) + : ScaleDistribution.Linear; + + const scaleLabel = Boolean(config.custom?.axisLabel) ? config.custom!.axisLabel : defaultPart; + + return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}/${fieldType}`; +} + +function getScaleDistributionPart(config: ScaleDistributionConfig) { + if (config.type === ScaleDistribution.Log) { + return `${config.type}${config.log}`; + } + return config.type; +} diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 959787608d7..86f3c99c277 100644 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -3,6 +3,11 @@ import uPlot, { Options, AlignedData } from 'uplot'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +/** + * @internal -- not a public API + */ +export const FIXED_UNIT = '__fixed'; + export type PlotConfig = Pick< Options, 'mode' | 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding' diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 605c8649e63..fc6224ac596 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,7 +1,7 @@ import { FieldMatcherID, fieldMatchers, FieldType, MutableDataFrame } from '@grafana/data'; import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema'; -import { preparePlotFrame } from '../GraphNG/utils'; +import { preparePlotFrame } from '../../../../../public/app/core/components/GraphNG/utils'; import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils'; diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index b9312097b66..d5d1dd7356b 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -5,7 +5,8 @@ import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, Stacki import { attachDebugger } from '../../utils'; import { createLogger } from '../../utils/logger'; -import { buildScaleKey } from '../GraphNG/utils'; + +import { buildScaleKey } from './internal'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; diff --git a/packages/grafana-ui/src/components/Graph/Graph.test.tsx b/packages/grafana-ui/src/graveyard/Graph/Graph.test.tsx similarity index 98% rename from packages/grafana-ui/src/components/Graph/Graph.test.tsx rename to packages/grafana-ui/src/graveyard/Graph/Graph.test.tsx index fc616c31cd8..847d6e883c0 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.test.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/Graph.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { GraphSeriesXY, FieldType, dateTime, FieldColorModeId, DisplayProcessor } from '@grafana/data'; import { TooltipDisplayMode } from '@grafana/schema'; -import { VizTooltip } from '../VizTooltip'; +import { VizTooltip } from '../../components/VizTooltip'; import Graph from './Graph'; diff --git a/packages/grafana-ui/src/components/Graph/Graph.tsx b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx similarity index 98% rename from packages/grafana-ui/src/components/Graph/Graph.tsx rename to packages/grafana-ui/src/graveyard/Graph/Graph.tsx index 9c61e8691fd..624d19e29c6 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx @@ -7,8 +7,8 @@ import React, { PureComponent } from 'react'; import { TimeRange, GraphSeriesXY, TimeZone, createDimension } from '@grafana/data'; import { TooltipDisplayMode } from '@grafana/schema'; -import { VizTooltipProps, VizTooltipContentProps, ActiveDimensions, VizTooltip } from '../VizTooltip'; -import { FlotPosition } from '../VizTooltip/VizTooltip'; +import { VizTooltipProps, VizTooltipContentProps, ActiveDimensions, VizTooltip } from '../../components/VizTooltip'; +import { FlotPosition } from '../../components/VizTooltip/VizTooltip'; import { GraphContextMenu, GraphContextMenuProps, ContextDimensions } from './GraphContextMenu'; import { GraphTooltip } from './GraphTooltip/GraphTooltip'; diff --git a/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx similarity index 88% rename from packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx index c58744435db..5b3da31708a 100644 --- a/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx @@ -11,13 +11,13 @@ import { GrafanaTheme2, } from '@grafana/data'; +import { ContextMenu, ContextMenuProps } from '../../components/ContextMenu/ContextMenu'; +import { FormattedValueDisplay } from '../../components/FormattedValueDisplay/FormattedValueDisplay'; +import { HorizontalGroup } from '../../components/Layout/Layout'; +import { MenuGroup, MenuGroupProps } from '../../components/Menu/MenuGroup'; +import { MenuItem } from '../../components/Menu/MenuItem'; +import { SeriesIcon } from '../../components/VizLegend/SeriesIcon'; import { useStyles2 } from '../../themes'; -import { ContextMenu, ContextMenuProps } from '../ContextMenu/ContextMenu'; -import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay'; -import { HorizontalGroup } from '../Layout/Layout'; -import { MenuGroup, MenuGroupProps } from '../Menu/MenuGroup'; -import { MenuItem } from '../Menu/MenuItem'; -import { SeriesIcon } from '../VizLegend/SeriesIcon'; import { GraphDimensions } from './GraphTooltip/types'; diff --git a/packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx similarity index 100% rename from packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx diff --git a/packages/grafana-ui/src/components/Graph/GraphTooltip/GraphTooltip.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/GraphTooltip.tsx similarity index 93% rename from packages/grafana-ui/src/components/Graph/GraphTooltip/GraphTooltip.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphTooltip/GraphTooltip.tsx index d52b37c0144..d58c0816dfe 100644 --- a/packages/grafana-ui/src/components/Graph/GraphTooltip/GraphTooltip.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/GraphTooltip.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { TooltipDisplayMode } from '@grafana/schema'; -import { VizTooltipContentProps } from '../../VizTooltip'; +import { VizTooltipContentProps } from '../../../components/VizTooltip'; import { MultiModeGraphTooltip } from './MultiModeGraphTooltip'; import { SingleModeGraphTooltip } from './SingleModeGraphTooltip'; diff --git a/packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx similarity index 98% rename from packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx index 0ab52812667..1af501183a6 100644 --- a/packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { createDimension, createTheme, FieldType, DisplayProcessor } from '@grafana/data'; -import { ActiveDimensions } from '../../VizTooltip'; +import { ActiveDimensions } from '../../../components/VizTooltip'; import { MultiModeGraphTooltip } from './MultiModeGraphTooltip'; import { GraphDimensions } from './types'; diff --git a/packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.tsx similarity index 91% rename from packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.tsx index b6810da3e93..bfe2856c700 100644 --- a/packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { getValueFromDimension } from '@grafana/data'; -import { SeriesTable } from '../../VizTooltip'; -import { FlotPosition } from '../../VizTooltip/VizTooltip'; +import { SeriesTable } from '../../../components/VizTooltip'; +import { FlotPosition } from '../../../components/VizTooltip/VizTooltip'; import { getMultiSeriesGraphHoverInfo } from '../utils'; import { GraphTooltipContentProps } from './types'; diff --git a/packages/grafana-ui/src/components/Graph/GraphTooltip/SingleModeGraphTooltip.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/SingleModeGraphTooltip.tsx similarity index 95% rename from packages/grafana-ui/src/components/Graph/GraphTooltip/SingleModeGraphTooltip.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphTooltip/SingleModeGraphTooltip.tsx index 1f9c5d4699f..7eb4d61872c 100644 --- a/packages/grafana-ui/src/components/Graph/GraphTooltip/SingleModeGraphTooltip.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/SingleModeGraphTooltip.tsx @@ -7,7 +7,7 @@ import { getFieldDisplayName, } from '@grafana/data'; -import { SeriesTable } from '../../VizTooltip'; +import { SeriesTable } from '../../../components/VizTooltip'; import { GraphTooltipContentProps } from './types'; diff --git a/packages/grafana-ui/src/components/Graph/GraphTooltip/types.ts b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/types.ts similarity index 85% rename from packages/grafana-ui/src/components/Graph/GraphTooltip/types.ts rename to packages/grafana-ui/src/graveyard/Graph/GraphTooltip/types.ts index 049ca540b1a..d4d68b729c1 100644 --- a/packages/grafana-ui/src/components/Graph/GraphTooltip/types.ts +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/types.ts @@ -1,6 +1,6 @@ import { Dimension, Dimensions, TimeZone } from '@grafana/data'; -import { ActiveDimensions } from '../../VizTooltip'; +import { ActiveDimensions } from '../../../components/VizTooltip'; /** @deprecated */ export interface GraphDimensions extends Dimensions { diff --git a/packages/grafana-ui/src/components/Graph/GraphWithLegend.internal.story.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.internal.story.tsx similarity index 100% rename from packages/grafana-ui/src/components/Graph/GraphWithLegend.internal.story.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.internal.story.tsx diff --git a/packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.tsx similarity index 94% rename from packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.tsx index f7c28de0d3d..9788f6a580a 100644 --- a/packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.tsx @@ -6,10 +6,10 @@ import React from 'react'; import { GraphSeriesValue } from '@grafana/data'; import { LegendDisplayMode, LegendPlacement } from '@grafana/schema'; +import { CustomScrollbar } from '../../components/CustomScrollbar/CustomScrollbar'; +import { VizLegend } from '../../components/VizLegend/VizLegend'; +import { VizLegendItem } from '../../components/VizLegend/types'; import { stylesFactory } from '../../themes'; -import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; -import { VizLegend } from '../VizLegend/VizLegend'; -import { VizLegendItem } from '../VizLegend/types'; import { Graph, GraphProps } from './Graph'; diff --git a/packages/grafana-ui/src/components/Graph/types.ts b/packages/grafana-ui/src/graveyard/Graph/types.ts similarity index 100% rename from packages/grafana-ui/src/components/Graph/types.ts rename to packages/grafana-ui/src/graveyard/Graph/types.ts diff --git a/packages/grafana-ui/src/components/Graph/utils.test.ts b/packages/grafana-ui/src/graveyard/Graph/utils.test.ts similarity index 100% rename from packages/grafana-ui/src/components/Graph/utils.test.ts rename to packages/grafana-ui/src/graveyard/Graph/utils.test.ts diff --git a/packages/grafana-ui/src/components/Graph/utils.ts b/packages/grafana-ui/src/graveyard/Graph/utils.ts similarity index 100% rename from packages/grafana-ui/src/components/Graph/utils.ts rename to packages/grafana-ui/src/graveyard/Graph/utils.ts diff --git a/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx new file mode 100644 index 00000000000..52dfadb27a6 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx @@ -0,0 +1,275 @@ +import React, { Component } from 'react'; +import { Subscription } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; +import uPlot, { AlignedData } from 'uplot'; + +import { + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + Field, + FieldMatcherID, + fieldMatchers, + FieldType, + LegacyGraphHoverEvent, + TimeRange, + TimeZone, +} from '@grafana/data'; +import { VizLegendOptions } from '@grafana/schema'; + +import { PanelContext, PanelContextRoot } from '../../components/PanelChrome/PanelContext'; +import { VizLayout } from '../../components/VizLayout/VizLayout'; +import { UPlotChart } from '../../components/uPlot/Plot'; +import { AxisProps } from '../../components/uPlot/config/UPlotAxisBuilder'; +import { Renderers, UPlotConfigBuilder } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { ScaleProps } from '../../components/uPlot/config/UPlotScaleBuilder'; +import { findMidPointYPosition, pluginLog } from '../../components/uPlot/utils'; +import { Themeable2 } from '../../types'; + +import { GraphNGLegendEvent, XYFieldMatchers } from './types'; +import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; + +/** + * @deprecated + * @internal -- not a public API + */ +export type PropDiffFn = (prev: T, next: T) => boolean; + +/** @deprecated */ +export interface GraphNGProps extends Themeable2 { + frames: DataFrame[]; + structureRev?: number; // a number that will change when the frames[] structure changes + width: number; + height: number; + timeRange: TimeRange; + timeZone: TimeZone[] | TimeZone; + legend: VizLegendOptions; + fields?: XYFieldMatchers; // default will assume timeseries data + renderers?: Renderers; + tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; + tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; + onLegendClick?: (event: GraphNGLegendEvent) => void; + children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; + prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder; + propsToDiff?: Array; + preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null; + renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; + + /** + * needed for propsToDiff to re-init the plot & config + * this is a generic approach to plot re-init, without having to specify which panel-level options + * should cause invalidation. we can drop this in favor of something like panelOptionsRev that gets passed in + * similar to structureRev. then we can drop propsToDiff entirely. + */ + options?: Record; +} + +function sameProps(prevProps: any, nextProps: any, propsToDiff: Array = []) { + for (const propName of propsToDiff) { + if (typeof propName === 'function') { + if (!propName(prevProps, nextProps)) { + return false; + } + } else if (nextProps[propName] !== prevProps[propName]) { + return false; + } + } + + return true; +} + +/** + * @internal -- not a public API + * @deprecated + */ +export interface GraphNGState { + alignedFrame: DataFrame; + alignedData?: AlignedData; + config?: UPlotConfigBuilder; +} + +/** + * "Time as X" core component, expects ascending x + * @deprecated + */ +export class GraphNG extends Component { + static contextType = PanelContextRoot; + panelContext: PanelContext = {} as PanelContext; + private plotInstance: React.RefObject; + + private subscription = new Subscription(); + + constructor(props: GraphNGProps) { + super(props); + let state = this.prepState(props); + state.alignedData = state.config!.prepData!([state.alignedFrame]) as AlignedData; + this.state = state; + this.plotInstance = React.createRef(); + } + + getTimeRange = () => this.props.timeRange; + + prepState(props: GraphNGProps, withConfig = true) { + let state: GraphNGState = null as any; + + const { frames, fields, preparePlotFrame } = props; + + const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame; + + const alignedFrame = preparePlotFrameFn( + frames, + fields || { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])), + }, + props.timeRange + ); + pluginLog('GraphNG', false, 'data aligned', alignedFrame); + + if (alignedFrame) { + let config = this.state?.config; + + if (withConfig) { + config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config prepared', config); + } + + state = { + alignedFrame, + config, + }; + + pluginLog('GraphNG', false, 'data prepared', state.alignedData); + } + + return state; + } + + handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) { + const time = evt.payload?.point?.time; + const u = this.plotInstance.current; + if (u && time) { + // Try finding left position on time axis + const left = u.valToPos(time, 'x'); + let top; + if (left) { + // find midpoint between points at current idx + top = findMidPointYPosition(u, u.posToIdx(left)); + } + + if (!top || !left) { + return; + } + + u.setCursor({ + left, + top, + }); + } + } + + componentDidMount() { + this.panelContext = this.context as PanelContext; + const { eventBus } = this.panelContext; + + this.subscription.add( + eventBus + .getStream(DataHoverEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: (evt) => { + if (eventBus === evt.origin) { + return; + } + this.handleCursorUpdate(evt); + }, + }) + ); + + // Legacy events (from flot graph) + this.subscription.add( + eventBus + .getStream(LegacyGraphHoverEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: (evt) => this.handleCursorUpdate(evt), + }) + ); + + this.subscription.add( + eventBus + .getStream(DataHoverClearEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: () => { + const u = this.plotInstance?.current; + + // @ts-ignore + if (u && !u.cursor._lock) { + u.setCursor({ + left: -10, + top: -10, + }); + } + }, + }) + ); + } + + componentDidUpdate(prevProps: GraphNGProps) { + const { frames, structureRev, timeZone, propsToDiff } = this.props; + + const propsChanged = !sameProps(prevProps, this.props, propsToDiff); + + if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) { + let newState = this.prepState(this.props, false); + + if (newState) { + const shouldReconfig = + this.state.config === undefined || + timeZone !== prevProps.timeZone || + structureRev !== prevProps.structureRev || + !structureRev || + propsChanged; + + if (shouldReconfig) { + newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config recreated', newState.config); + } + + newState.alignedData = newState.config!.prepData!([newState.alignedFrame]) as AlignedData; + + this.setState(newState); + } + } + } + + componentWillUnmount() { + this.subscription.unsubscribe(); + } + + render() { + const { width, height, children, renderLegend } = this.props; + const { config, alignedFrame, alignedData } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + ((this.plotInstance as React.MutableRefObject).current = u)} + > + {children ? children(config, alignedFrame) : null} + + )} + + ); + } +} diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap rename to packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap diff --git a/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts b/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts new file mode 100644 index 00000000000..e4f1d46550a --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts @@ -0,0 +1,41 @@ +import React, { useCallback, useContext } from 'react'; + +import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data'; + +import { XYFieldMatchers } from './types'; + +/** @deprecated */ +interface GraphNGContextType { + mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex; + dimFields: XYFieldMatchers; + data: DataFrame; +} + +/** @deprecated */ +export const GraphNGContext = React.createContext({} as GraphNGContextType); + +/** @deprecated */ +export const useGraphNGContext = () => { + const { data, dimFields, mapSeriesIndexToDataFrameFieldIndex } = useContext(GraphNGContext); + + const getXAxisField = useCallback(() => { + const xFieldMatcher = dimFields.x; + let xField: Field | null = null; + + for (let j = 0; j < data.fields.length; j++) { + if (xFieldMatcher(data.fields[j], data, [data])) { + xField = data.fields[j]; + break; + } + } + + return xField; + }, [data, dimFields]); + + return { + dimFields, + mapSeriesIndexToDataFrameFieldIndex, + getXAxisField, + alignedData: data, + }; +}; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts new file mode 100644 index 00000000000..7a2ceb15e8d --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts @@ -0,0 +1,334 @@ +import { FieldType, createDataFrame } from '@grafana/data'; + +import { applyNullInsertThreshold } from './nullInsertThreshold'; + +function randInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function genFrame() { + let fieldCount = 10; + let valueCount = 3000; + let step = 1000; + let skipProb = 0.5; + let skipSteps = [1, 5]; // min, max + + let allValues = Array(fieldCount); + + allValues[0] = Array(valueCount); + + for (let i = 0, curStep = Date.now(); i < valueCount; i++) { + curStep = allValues[0][i] = curStep + step * (Math.random() < skipProb ? randInt(skipSteps[0], skipSteps[1]) : 1); + } + + for (let fi = 1; fi < fieldCount; fi++) { + let values = Array(valueCount); + + for (let i = 0; i < valueCount; i++) { + values[i] = Math.random() * 100; + } + + allValues[fi] = values; + } + + return { + length: valueCount, + fields: allValues.map((values, i) => { + return { + name: 'A-' + i, + type: i === 0 ? FieldType.time : FieldType.number, + config: { + interval: i === 0 ? step : null, + }, + values: values, + }; + }), + }; +} + +describe('nullInsertThreshold Transformer', () => { + test('should insert nulls at +threshold between adjacent > threshold: 1', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, null, 6, null, null, null, null, null, null, 8]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, null, null, null, null, null, 'c']); + }); + + test('should insert nulls at +threshold between adjacent > threshold: 2', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 2 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 2 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([5, 7, 9, 11]); + expect(result.fields[1].values).toEqual([4, 6, null, 8]); + expect(result.fields[2].values).toEqual(['a', 'b', null, 'c']); + }); + + test('should insert nulls at +interval between adjacent > interval: 1', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, null, 6, null, null, null, null, null, null, 8]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, null, null, null, null, null, 'c']); + }); + + test('should insert leading null at beginning +interval when timeRange.from.valueOf() exceeds threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [4, 6, 13] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ + frame: df, + refFieldName: null, + refFieldPseudoMin: -0.5, + refFieldPseudoMax: 13, + }); + + expect(result.fields[0].values).toEqual([-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); + expect(result.fields[1].values).toEqual([ + null, + null, + null, + null, + null, + 4, + null, + 6, + null, + null, + null, + null, + null, + null, + 8, + ]); + expect(result.fields[2].values).toEqual([ + null, + null, + null, + null, + null, + 'a', + null, + 'b', + null, + null, + null, + null, + null, + null, + 'c', + ]); + }); + + // this tests that intervals at 24hr but starting not at 12am UTC are not always snapped to 12am UTC + test('should insert leading null at beginning +interval when timeRange.from.valueOf() exceeds threshold 11PM UTC', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: { interval: 86400000 }, + values: [1679439600000, 1679526000000, 1679612400000, 1679698800000, 1679785200000], + }, + { name: 'One', type: FieldType.number, values: [0, 1, 2, 3, 4] }, + ], + }); + + const result = applyNullInsertThreshold({ + frame: df, + refFieldName: null, + refFieldPseudoMin: 1679320395828, + refFieldPseudoMax: 1679815217157, + }); + + expect(result.fields[0].values).toEqual([ + 1679266800000, 1679353200000, 1679439600000, 1679526000000, 1679612400000, 1679698800000, 1679785200000, + ]); + expect(result.fields[1].values).toEqual([null, null, 0, 1, 2, 3, 4]); + }); + + test('should insert trailing null at end +interval when timeRange.to.valueOf() exceeds threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df, refFieldName: null, refFieldPseudoMax: 13 }); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + expect(result.fields[1].values).toEqual([4, null, 6, null, null, null, null, null, null, 8, null, null]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, null, null, null, null, null, 'c', null, null]); + + // should work for frames with 1 datapoint + const df2 = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, + { name: 'One', type: FieldType.number, values: [1] }, + { name: 'Two', type: FieldType.string, values: ['a'] }, + ], + }); + + // Max is 2.5 as opposed to the above 13 otherwise + // we get 12 nulls instead of the additional 1 + const result2 = applyNullInsertThreshold({ frame: df2, refFieldName: null, refFieldPseudoMax: 2.5 }); + + expect(result2.fields[0].values).toEqual([1, 2]); + expect(result2.fields[1].values).toEqual([1, null]); + expect(result2.fields[2].values).toEqual(['a', null]); + }); + + test('should not insert trailing null at end +interval when timeRange.to.valueOf() equals threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, + { name: 'One', type: FieldType.number, values: [1] }, + { name: 'Two', type: FieldType.string, values: ['a'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df, refFieldName: null, refFieldPseudoMax: 2 }); + + expect(result.fields[0].values).toEqual([1]); + expect(result.fields[1].values).toEqual([1]); + expect(result.fields[2].values).toEqual(['a']); + }); + + // TODO: make this work + test.skip('should insert nulls at +threshold (when defined) instead of +interval', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([5, 6, 7, 8, 11]); + expect(result.fields[1].values).toEqual([4, null, 6, null, 8]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, 'c']); + }); + + test('should noop on 0 datapoints', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [] }, + { name: 'Value', type: FieldType.number, values: [] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop on invalid threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 4] }, + { name: 'Value', type: FieldType.number, config: { custom: { insertNulls: -1 } }, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop on invalid interval', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: -1 }, values: [1, 2, 4] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop when no missing steps', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 3] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop when refFieldName not found', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 5] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df, refFieldName: 'Time2' }); + + expect(result).toBe(df); + }); + + // Leave this test skipped - it should be run manually + test.skip('perf stress test should be <= 10ms', () => { + // 10 fields x 3,000 values with 50% skip (output = 10 fields x 6,000 values) + let bigFrameA = genFrame(); + + // eslint-disable-next-line no-console + console.time('insertValues-10x3k'); + applyNullInsertThreshold({ frame: bigFrameA }); + // eslint-disable-next-line no-console + console.timeEnd('insertValues-10x3k'); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts similarity index 99% rename from packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts rename to packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts index 352f2f0660b..9361a6bab8d 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts @@ -19,6 +19,7 @@ interface NullInsertOptions { insertMode?: InsertMode; } +/** @deprecated */ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame { if (opts.frame.length === 0) { return opts.frame; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts new file mode 100644 index 00000000000..bcc52e5e1b9 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts @@ -0,0 +1,33 @@ +/** + * mutates all nulls -> undefineds in the fieldValues array for value-less refValues ranges below maxThreshold + * refValues is typically a time array and maxThreshold is the allowable distance between in time + * @deprecated + */ +export function nullToUndefThreshold(refValues: number[], fieldValues: any[], maxThreshold: number): any[] { + let prevRef; + let nullIdx; + + for (let i = 0; i < fieldValues.length; i++) { + let fieldVal = fieldValues[i]; + + if (fieldVal == null) { + if (nullIdx == null && prevRef != null) { + nullIdx = i; + } + } else { + if (nullIdx != null) { + if (refValues[i] - (prevRef as number) < maxThreshold) { + while (nullIdx < i) { + fieldValues[nullIdx++] = undefined; + } + } + + nullIdx = null; + } + + prevRef = refValues[i]; + } + } + + return fieldValues; +} diff --git a/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts new file mode 100644 index 00000000000..e14f64e1710 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts @@ -0,0 +1,94 @@ +import { FieldType, createDataFrame } from '@grafana/data'; + +import { applyNullInsertThreshold } from './nullInsertThreshold'; +import { nullToValue } from './nullToValue'; + +describe('nullToValue Transformer', () => { + test('should change all nulls to configured zero value', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 3, 10] }, + { + name: 'One', + type: FieldType.number, + config: { custom: { insertNulls: 1 }, noValue: '0' }, + values: [4, 6, 8], + }, + { + name: 'Two', + type: FieldType.string, + config: { custom: { insertNulls: 1 }, noValue: '0' }, + values: ['a', 'b', 'c'], + }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df })); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, 0, 6, 0, 0, 0, 0, 0, 0, 8]); + expect(result.fields[2].values).toEqual(['a', 0, 'b', 0, 0, 0, 0, 0, 0, 'c']); + }); + + test('should change all nulls to configured positive value', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [5, 7, 11] }, + { + name: 'One', + type: FieldType.number, + config: { custom: { insertNulls: 2 }, noValue: '1' }, + values: [4, 6, 8], + }, + { + name: 'Two', + type: FieldType.string, + config: { custom: { insertNulls: 2 }, noValue: '1' }, + values: ['a', 'b', 'c'], + }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df })); + + expect(result.fields[0].values).toEqual([5, 7, 9, 11]); + expect(result.fields[1].values).toEqual([4, 6, 1, 8]); + expect(result.fields[2].values).toEqual(['a', 'b', 1, 'c']); + }); + + test('should change all nulls to configured negative value', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, config: { noValue: '-1' }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { noValue: '-1' }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df })); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, -1, 6, -1, -1, -1, -1, -1, -1, 8]); + expect(result.fields[2].values).toEqual(['a', -1, 'b', -1, -1, -1, -1, -1, -1, 'c']); + }); + + test('should have no effect without nulls', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 3] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df, refFieldName: null })); + + expect(result.fields[0].values).toEqual([1, 2, 3]); + expect(result.fields[1].values).toEqual([4, 6, 8]); + expect(result.fields[2].values).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/nullToValue.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.ts similarity index 96% rename from packages/grafana-ui/src/components/GraphNG/nullToValue.ts rename to packages/grafana-ui/src/graveyard/GraphNG/nullToValue.ts index 62aea31b837..7ad8e3705aa 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullToValue.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.ts @@ -1,5 +1,6 @@ import { DataFrame } from '@grafana/data'; +/** @deprecated */ export function nullToValue(frame: DataFrame) { return { ...frame, diff --git a/packages/grafana-ui/src/graveyard/GraphNG/types.ts b/packages/grafana-ui/src/graveyard/GraphNG/types.ts new file mode 100644 index 00000000000..91dd83fb5a8 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/types.ts @@ -0,0 +1,18 @@ +import { DataFrameFieldIndex, FieldMatcher } from '@grafana/data'; + +import { SeriesVisibilityChangeMode } from '../../components/PanelChrome'; + +/** + * Event being triggered when the user interact with the Graph legend. + * @deprecated + */ +export interface GraphNGLegendEvent { + fieldIndex: DataFrameFieldIndex; + mode: SeriesVisibilityChangeMode; +} + +/** @deprecated */ +export interface XYFieldMatchers { + x: FieldMatcher; // first match + y: FieldMatcher; +} diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/utils.test.ts rename to packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.ts similarity index 98% rename from packages/grafana-ui/src/components/GraphNG/utils.ts rename to packages/grafana-ui/src/graveyard/GraphNG/utils.ts index 9922b85d9df..742031446ac 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/utils.ts @@ -7,7 +7,8 @@ import { ScaleDistributionConfig, } from '@grafana/schema'; -import { FIXED_UNIT } from './GraphNG'; +import { FIXED_UNIT } from '../../components/uPlot/types'; + import { applyNullInsertThreshold } from './nullInsertThreshold'; import { nullToUndefThreshold } from './nullToUndefThreshold'; import { XYFieldMatchers } from './types'; diff --git a/packages/grafana-ui/src/graveyard/README.md b/packages/grafana-ui/src/graveyard/README.md new file mode 100644 index 00000000000..2715ecec38a --- /dev/null +++ b/packages/grafana-ui/src/graveyard/README.md @@ -0,0 +1 @@ +Items in this folder are all deprecated and will be removed in the future diff --git a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx similarity index 87% rename from packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx rename to packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx index 019671e2b6a..9a748236d77 100644 --- a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx @@ -2,11 +2,11 @@ import React, { Component } from 'react'; import { DataFrame, TimeRange } from '@grafana/data'; +import { PanelContextRoot } from '../../components/PanelChrome/PanelContext'; +import { hasVisibleLegendSeries, PlotLegend } from '../../components/uPlot/PlotLegend'; +import { UPlotConfigBuilder } from '../../components/uPlot/config/UPlotConfigBuilder'; import { withTheme2 } from '../../themes/ThemeContext'; import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; -import { PanelContextRoot } from '../PanelChrome/PanelContext'; -import { hasVisibleLegendSeries, PlotLegend } from '../uPlot/PlotLegend'; -import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { preparePlotConfigBuilder } from './utils'; diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.test.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts similarity index 100% rename from packages/grafana-ui/src/components/TimeSeries/utils.test.ts rename to packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts similarity index 98% rename from packages/grafana-ui/src/components/TimeSeries/utils.ts rename to packages/grafana-ui/src/graveyard/TimeSeries/utils.ts index 9d2d373b33d..ad029097ba5 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts @@ -61,10 +61,10 @@ for (let i = 0; i < BIN_INCRS.length; i++) { BIN_INCRS[i] = 2 ** i; } +import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { getScaleGradientFn } from '../../components/uPlot/config/gradientFills'; +import { getStackingGroups, preparePlotData2 } from '../../components/uPlot/utils'; import { buildScaleKey } from '../GraphNG/utils'; -import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../uPlot/config/UPlotConfigBuilder'; -import { getScaleGradientFn } from '../uPlot/config/gradientFills'; -import { getStackingGroups, preparePlotData2 } from '../uPlot/utils'; const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals)); diff --git a/packages/grafana-ui/src/unstable.ts b/packages/grafana-ui/src/unstable.ts index f90909a1517..2e08df72642 100644 --- a/packages/grafana-ui/src/unstable.ts +++ b/packages/grafana-ui/src/unstable.ts @@ -10,4 +10,3 @@ */ export { Grid } from './components/Layout/Grid/Grid'; -export { Stack } from './components/Layout/Stack/Stack'; diff --git a/pkg/api/api.go b/pkg/api/api.go index 15bee759b6d..ee3c5a828a6 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -471,7 +471,6 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Post("/calculate-diff", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.CalculateDashboardDiff)) dashboardRoute.Post("/validate", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.ValidateDashboard)) - dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard)) dashboardRoute.Post("/db", authorize(ac.EvalAny(ac.EvalPermission(dashboards.ActionDashboardsCreate), ac.EvalPermission(dashboards.ActionDashboardsWrite))), routing.Wrap(hs.PostDashboard)) dashboardRoute.Get("/home", routing.Wrap(hs.GetHomeDashboard)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 008031c85cc..dd70a773dd3 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -68,32 +68,6 @@ func dashboardGuardianResponse(err error) response.Response { return response.Error(http.StatusForbidden, "Access denied to this dashboard", nil) } -// swagger:route POST /dashboards/trim dashboards trimDashboard -// -// Trim defaults from dashboard. -// -// Responses: -// 200: trimDashboardResponse -// 401: unauthorisedError -// 500: internalServerError -func (hs *HTTPServer) TrimDashboard(c *contextmodel.ReqContext) response.Response { - cmd := dashboards.TrimDashboardCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - dash := cmd.Dashboard - meta := cmd.Meta - - // TODO temporarily just return the input as a no-op while we convert to thema calls - dto := dtos.TrimDashboardFullWithMeta{ - Dashboard: dash, - Meta: meta, - } - - c.TimeRequest(metrics.MApiDashboardGet) - return response.JSON(http.StatusOK, dto) -} - // swagger:route GET /dashboards/uid/{uid} dashboards getDashboardByUID // // Get dashboard by uid. @@ -1233,13 +1207,6 @@ type CalcDashboardDiffParams struct { } } -// swagger:parameters trimDashboard -type TrimDashboardParams struct { - // in:body - // required:true - Body dashboards.TrimDashboardCommand -} - // swagger:response dashboardResponse type DashboardResponse struct { // The response message @@ -1315,12 +1282,6 @@ type CalculateDashboardDiffResponse struct { Body []byte `json:"body"` } -// swagger:response trimDashboardResponse -type TrimDashboardResponse struct { - // in: body - Body dtos.TrimDashboardFullWithMeta `json:"body"` -} - // swagger:response getHomeDashboardResponse type GetHomeDashboardResponse struct { // in: body diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 8e920a20e12..ef4d34864dc 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -51,11 +51,6 @@ type DashboardFullWithMeta struct { Dashboard *simplejson.Json `json:"dashboard"` } -type TrimDashboardFullWithMeta struct { - Meta *simplejson.Json `json:"meta"` - Dashboard *simplejson.Json `json:"dashboard"` -} - type DashboardRedirect struct { RedirectUri string `json:"redirectUri"` } diff --git a/pkg/plugins/envvars/envvars.go b/pkg/plugins/envvars/envvars.go index 57259ca28db..8951b49e46a 100644 --- a/pkg/plugins/envvars/envvars.go +++ b/pkg/plugins/envvars/envvars.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" @@ -79,6 +80,10 @@ func (s *Service) Get(ctx context.Context, p *plugins.Plugin) []string { func (s *Service) GetConfigMap(ctx context.Context, _ string, _ *auth.ExternalService) map[string]string { m := make(map[string]string) + if s.cfg.GrafanaAppURL != "" { + m[backend.AppURL] = s.cfg.GrafanaAppURL + } + // TODO add support via plugin SDK //if externalService != nil { // m[oauthtokenretriever.AppURL] = s.cfg.GrafanaAppURL diff --git a/pkg/plugins/envvars/envvars_test.go b/pkg/plugins/envvars/envvars_test.go index 46cc8b56e52..cdee809c0b4 100644 --- a/pkg/plugins/envvars/envvars_test.go +++ b/pkg/plugins/envvars/envvars_test.go @@ -500,3 +500,14 @@ func TestService_GetConfigMap_featureToggles(t *testing.T) { } }) } + +func TestService_GetConfigMap_appURL(t *testing.T) { + t.Run("Uses the configured app URL", func(t *testing.T) { + s := &Service{ + cfg: &config.Cfg{ + GrafanaAppURL: "https://myorg.com/", + }, + } + require.Equal(t, map[string]string{"GF_APP_URL": "https://myorg.com/"}, s.GetConfigMap(context.Background(), "", nil)) + }) +} diff --git a/pkg/plugins/manager/loader/finder/local.go b/pkg/plugins/manager/loader/finder/local.go index a46ab30bc70..9428a4bac2b 100644 --- a/pkg/plugins/manager/loader/finder/local.go +++ b/pkg/plugins/manager/loader/finder/local.go @@ -57,7 +57,11 @@ func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins. continue } - paths, err := l.getAbsPluginJSONPaths(path) + followDistFolder := true + if src.PluginClass(ctx) == plugins.ClassCore { + followDistFolder = false + } + paths, err := l.getAbsPluginJSONPaths(path, followDistFolder) if err != nil { return nil, err } @@ -154,7 +158,7 @@ func (l *Local) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) return plugin, nil } -func (l *Local) getAbsPluginJSONPaths(path string) ([]string, error) { +func (l *Local) getAbsPluginJSONPaths(path string, followDistFolder bool) ([]string, error) { var pluginJSONPaths []string var err error @@ -163,7 +167,7 @@ func (l *Local) getAbsPluginJSONPaths(path string) ([]string, error) { return []string{}, err } - if err = walk(path, true, true, + if err = walk(path, true, true, followDistFolder, func(currentPath string, fi os.FileInfo, err error) error { if err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 42cefb50a1a..8b2be26565e 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -274,7 +274,7 @@ func TestFinder_Find(t *testing.T) { func TestFinder_getAbsPluginJSONPaths(t *testing.T) { t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { return walkFn(path, nil, os.ErrNotExist) } t.Cleanup(func() { @@ -282,14 +282,14 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) { }) finder := NewLocalFinder(false) - paths, err := finder.getAbsPluginJSONPaths("test") + paths, err := finder.getAbsPluginJSONPaths("test", true) require.NoError(t, err) require.Empty(t, paths) }) t.Run("When scanning a folder that lacks permission shouldn't return an error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { return walkFn(path, nil, os.ErrPermission) } t.Cleanup(func() { @@ -297,14 +297,14 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) { }) finder := NewLocalFinder(false) - paths, err := finder.getAbsPluginJSONPaths("test") + paths, err := finder.getAbsPluginJSONPaths("test", true) require.NoError(t, err) require.Empty(t, paths) }) t.Run("When scanning a folder that returns a non-handled error should return that error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { return walkFn(path, nil, errors.New("random error")) } t.Cleanup(func() { @@ -312,10 +312,28 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) { }) finder := NewLocalFinder(false) - paths, err := finder.getAbsPluginJSONPaths("test") + paths, err := finder.getAbsPluginJSONPaths("test", true) require.Error(t, err) require.Empty(t, paths) }) + + t.Run("should forward if the dist folder should be evaluated", func(t *testing.T) { + origWalk := walk + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { + if followDistFolder { + return walkFn(path, nil, errors.New("unexpected followDistFolder")) + } + return walkFn(path, nil, filepath.SkipDir) + } + t.Cleanup(func() { + walk = origWalk + }) + + finder := NewLocalFinder(false) + paths, err := finder.getAbsPluginJSONPaths("test", false) + require.ErrorIs(t, err, filepath.SkipDir) + require.Empty(t, paths) + }) } var fsComparer = cmp.Comparer(func(fs1 plugins.FS, fs2 plugins.FS) bool { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3db02aff0bf..d28bca5998e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -9,12 +9,6 @@ package featuremgmt var ( // Register each toggle here standardFeatureFlags = []FeatureFlag{ - { - Name: "trimDefaults", - Description: "Use cue schema to remove values that will be applied automatically", - Stage: FeatureStagePublicPreview, - Owner: grafanaAsCodeSquad, - }, { Name: "disableEnvelopeEncryption", Description: "Disable envelope encryption (emergency only)", @@ -966,5 +960,19 @@ var ( RequiresDevMode: false, Owner: grafanaAuthnzSquad, }, + { + Name: "extractFieldsNameDeduplication", + Description: "Make sure extracted field names are unique in the dataframe", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaBiSquad, + }, + { + Name: "dashboardSceneForViewers", + Description: "Enables dashboard rendering using Scenes for viewer roles", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 1dfdae95a8d..8276c937fa3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -1,5 +1,4 @@ Name,Stage,Owner,requiresDevMode,RequiresLicense,RequiresRestart,FrontendOnly -trimDefaults,preview,@grafana/grafana-as-code,false,false,false,false disableEnvelopeEncryption,GA,@grafana/grafana-as-code,false,false,false,false live-service-web-worker,experimental,@grafana/grafana-app-platform-squad,false,false,false,true queryOverLive,experimental,@grafana/grafana-app-platform-squad,false,false,false,true @@ -137,3 +136,5 @@ alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,fal alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false,false alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false,false annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false +extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true +dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 24e183d6ac1..bd3493e5147 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -7,10 +7,6 @@ package featuremgmt const ( - // FlagTrimDefaults - // Use cue schema to remove values that will be applied automatically - FlagTrimDefaults = "trimDefaults" - // FlagDisableEnvelopeEncryption // Disable envelope encryption (emergency only) FlagDisableEnvelopeEncryption = "disableEnvelopeEncryption" @@ -558,4 +554,12 @@ const ( // FlagAnnotationPermissionUpdate // Separate annotation permissions from dashboard permissions to allow for more granular control. FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" + + // FlagExtractFieldsNameDeduplication + // Make sure extracted field names are unique in the dataframe + FlagExtractFieldsNameDeduplication = "extractFieldsNameDeduplication" + + // FlagDashboardSceneForViewers + // Enables dashboard rendering using Scenes for viewer roles + FlagDashboardSceneForViewers = "dashboardSceneForViewers" ) diff --git a/pkg/services/featuremgmt/usage_stats_test.go b/pkg/services/featuremgmt/usage_stats_test.go index 1496bb055c4..03c7fe0c2df 100644 --- a/pkg/services/featuremgmt/usage_stats_test.go +++ b/pkg/services/featuremgmt/usage_stats_test.go @@ -9,7 +9,6 @@ import ( func TestFeatureUsageStats(t *testing.T) { featureManagerWithAllFeatures := WithFeatures( - "trimDefaults", "database_metrics", "live-config", "UPPER_SNAKE_CASE", @@ -17,7 +16,6 @@ func TestFeatureUsageStats(t *testing.T) { ) require.Equal(t, map[string]any{ - "stats.features.trim_defaults.count": 1, "stats.features.database_metrics.count": 1, "stats.features.live_config.count": 1, "stats.features.upper_snake_case.count": 1, diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 996ccd5351c..7d03bb84fc3 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -403,7 +403,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn) require.NoError(t, err) - alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, ac, dashSrv) + alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv) require.NoError(t, err) elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac) @@ -480,7 +480,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { folderPermissions, dashboardPermissions, ac, serviceWithFlagOff) require.NoError(t, err) - alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, ac, dashSrv) + alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv) require.NoError(t, err) elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac) @@ -618,7 +618,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { dashSrv, err := service.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, nil, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service) require.NoError(t, err) - alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, ac, dashSrv) + alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, dashSrv) require.NoError(t, err) ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, tc.depth, tc.prefix, createCmd) diff --git a/pkg/services/ngalert/migration/template.go b/pkg/services/ngalert/migration/template.go index fb218d582c9..a6823d8c816 100644 --- a/pkg/services/ngalert/migration/template.go +++ b/pkg/services/ngalert/migration/template.go @@ -35,7 +35,7 @@ func (t Token) String() string { } else if t.IsVariable() { return t.Variable } else { - panic("empty token") + return "" } } @@ -108,7 +108,7 @@ func tokenizeVariable(in []rune) (Token, int, error) { ) if !startVariable(in) { - panic("tokenizeVariable called with input that doesn't start with delimiter") + return Token{}, pos, fmt.Errorf("expected '${', got '%s'", string(in[:2])) } pos += 2 // seek past opening delimiter @@ -118,11 +118,11 @@ func tokenizeVariable(in []rune) (Token, int, error) { r = in[pos] if unicode.IsSpace(r) && r != ' ' { - return Token{}, pos, fmt.Errorf("unexpected whitespace") + return Token{}, pos, errors.New("unexpected whitespace") } if startVariable(in[pos:]) { - return Token{}, pos, fmt.Errorf("ambiguous delimiter") + return Token{}, pos, errors.New("ambiguous delimiter") } if r == '}' { @@ -139,7 +139,12 @@ func tokenizeVariable(in []rune) (Token, int, error) { return Token{}, pos, fmt.Errorf("expected '}', got '%c'", r) } - return Token{Variable: string(runes)}, pos, nil + token := Token{Variable: string(runes)} + if !token.IsVariable() { + return Token{}, pos, errors.New("empty variable") + } + + return token, pos, nil } func startVariable(in []rune) bool { diff --git a/pkg/services/ngalert/migration/template_test.go b/pkg/services/ngalert/migration/template_test.go index 88786af5e46..689ec1b95bc 100644 --- a/pkg/services/ngalert/migration/template_test.go +++ b/pkg/services/ngalert/migration/template_test.go @@ -303,6 +303,12 @@ func TestMigrateTmpl(t *testing.T) { expected: withDeduplicateMap("{{$mergedLabels.instance}}{{` is down ${`}}{{$mergedLabels.nestedVar}}}"), vars: true, }, + { + name: "edge cases", + input: "Test test 123 \n$(metric)\n${.}\n${}\n${Condition[0]}", + expected: withDeduplicateMap("Test test 123 \n$(metric)\n{{index $mergedLabels \".\"}}\n${}\n{{index $mergedLabels \"Condition[0]\"}}"), + vars: true, + }, } for _, tc := range cases { diff --git a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go new file mode 100644 index 00000000000..a914e0876ab --- /dev/null +++ b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go @@ -0,0 +1,85 @@ +package remote + +import ( + "context" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" +) + +type RemotePrimaryForkedAlertmanager struct { + internal notifier.Alertmanager + remote notifier.Alertmanager +} + +func NewRemotePrimaryForkedAlertmanager(internal, remote notifier.Alertmanager) *RemotePrimaryForkedAlertmanager { + return &RemotePrimaryForkedAlertmanager{ + internal: internal, + remote: remote, + } +} + +func (fam *RemotePrimaryForkedAlertmanager) ApplyConfig(ctx context.Context, config *models.AlertConfiguration) error { + return nil +} + +func (fam *RemotePrimaryForkedAlertmanager) SaveAndApplyConfig(ctx context.Context, config *apimodels.PostableUserConfig) error { + return nil +} + +func (fam *RemotePrimaryForkedAlertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error { + return nil +} + +func (fam *RemotePrimaryForkedAlertmanager) GetStatus() apimodels.GettableStatus { + return apimodels.GettableStatus{} +} + +func (fam *RemotePrimaryForkedAlertmanager) CreateSilence(ctx context.Context, silence *apimodels.PostableSilence) (string, error) { + return "", nil +} + +func (fam *RemotePrimaryForkedAlertmanager) DeleteSilence(ctx context.Context, id string) error { + return nil +} + +func (fam *RemotePrimaryForkedAlertmanager) GetSilence(ctx context.Context, id string) (apimodels.GettableSilence, error) { + return apimodels.GettableSilence{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) ListSilences(ctx context.Context, filter []string) (apimodels.GettableSilences, error) { + return apimodels.GettableSilences{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) GetAlerts(ctx context.Context, active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) { + return apimodels.GettableAlerts{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) GetAlertGroups(ctx context.Context, active, silenced, inhibited bool, filter []string, receiver string) (apimodels.AlertGroups, error) { + return apimodels.AlertGroups{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) PutAlerts(ctx context.Context, alerts apimodels.PostableAlerts) error { + return nil +} + +func (fam *RemotePrimaryForkedAlertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) { + return []apimodels.Receiver{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*notifier.TestReceiversResult, error) { + return ¬ifier.TestReceiversResult{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*notifier.TestTemplatesResults, error) { + return ¬ifier.TestTemplatesResults{}, nil +} + +func (fam *RemotePrimaryForkedAlertmanager) CleanUp() {} + +func (fam *RemotePrimaryForkedAlertmanager) StopAndWait() {} + +func (fam *RemotePrimaryForkedAlertmanager) Ready() bool { + return false +} diff --git a/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go b/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go new file mode 100644 index 00000000000..0b10a6dbcde --- /dev/null +++ b/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go @@ -0,0 +1,85 @@ +package remote + +import ( + "context" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" +) + +type RemoteSecondaryForkedAlertmanager struct { + internal notifier.Alertmanager + remote notifier.Alertmanager +} + +func NewRemoteSecondaryForkedAlertmanager(internal, remote notifier.Alertmanager) *RemoteSecondaryForkedAlertmanager { + return &RemoteSecondaryForkedAlertmanager{ + internal: internal, + remote: remote, + } +} + +func (fam *RemoteSecondaryForkedAlertmanager) ApplyConfig(ctx context.Context, config *models.AlertConfiguration) error { + return nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) SaveAndApplyConfig(ctx context.Context, config *apimodels.PostableUserConfig) error { + return nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error { + return nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) GetStatus() apimodels.GettableStatus { + return apimodels.GettableStatus{} +} + +func (fam *RemoteSecondaryForkedAlertmanager) CreateSilence(ctx context.Context, silence *apimodels.PostableSilence) (string, error) { + return "", nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) DeleteSilence(ctx context.Context, id string) error { + return nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) GetSilence(ctx context.Context, id string) (apimodels.GettableSilence, error) { + return apimodels.GettableSilence{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) ListSilences(ctx context.Context, filter []string) (apimodels.GettableSilences, error) { + return apimodels.GettableSilences{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) GetAlerts(ctx context.Context, active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) { + return apimodels.GettableAlerts{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) GetAlertGroups(ctx context.Context, active, silenced, inhibited bool, filter []string, receiver string) (apimodels.AlertGroups, error) { + return apimodels.AlertGroups{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) PutAlerts(ctx context.Context, alerts apimodels.PostableAlerts) error { + return nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) { + return []apimodels.Receiver{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*notifier.TestReceiversResult, error) { + return ¬ifier.TestReceiversResult{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*notifier.TestTemplatesResults, error) { + return ¬ifier.TestTemplatesResults{}, nil +} + +func (fam *RemoteSecondaryForkedAlertmanager) CleanUp() {} + +func (fam *RemoteSecondaryForkedAlertmanager) StopAndWait() {} + +func (fam *RemoteSecondaryForkedAlertmanager) Ready() bool { + return false +} diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index faf3f09f287..4a70e127fdf 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -39,20 +38,17 @@ type DBstore struct { SQLStore db.DB Logger log.Logger FolderService folder.Service - AccessControl accesscontrol.AccessControl DashboardService dashboards.DashboardService } func ProvideDBStore( - cfg *setting.Cfg, featureToggles featuremgmt.FeatureToggles, sqlstore db.DB, folderService folder.Service, - access accesscontrol.AccessControl, dashboards dashboards.DashboardService) (*DBstore, error) { + cfg *setting.Cfg, featureToggles featuremgmt.FeatureToggles, sqlstore db.DB, folderService folder.Service, dashboards dashboards.DashboardService) (*DBstore, error) { store := DBstore{ Cfg: cfg.UnifiedAlerting, FeatureToggles: featureToggles, SQLStore: sqlstore, Logger: log.New("ngalert.dbstore"), FolderService: folderService, - AccessControl: access, DashboardService: dashboards, } if err := folderService.RegisterService(store); err != nil { diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index d63b06b126e..a99c002d29a 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -63,8 +63,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg) folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus) - ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, - ac, &dashboards.FakeDashboardService{}) + ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, &dashboards.FakeDashboardService{}) require.NoError(tb, err) ng, err := ngalert.ProvideService( cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 3093e39db1b..83d1288b4ab 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -80,7 +80,11 @@ func (dc *DatasourceProvisioner) provisionDataSources(ctx context.Context, cfg * updateCmd := createUpdateCommand(ds, dataSource.ID) dc.log.Debug("updating datasource from configuration", "name", updateCmd.Name, "uid", updateCmd.UID) if _, err := dc.store.UpdateDataSource(ctx, updateCmd); err != nil { - return err + if errors.Is(err, datasources.ErrDataSourceUpdatingOldVersion) { + dc.log.Debug("ignoring old version of datasource", "name", updateCmd.Name, "uid", updateCmd.UID) + } else { + return err + } } } } diff --git a/pkg/services/provisioning/datasources/types.go b/pkg/services/provisioning/datasources/types.go index 2e58df3a016..5088278cc85 100644 --- a/pkg/services/provisioning/datasources/types.go +++ b/pkg/services/provisioning/datasources/types.go @@ -243,6 +243,7 @@ func createUpdateCommand(ds *upsertDataSourceFromConfig, id int64) *datasources. return &datasources.UpdateDataSourceCommand{ ID: id, + Version: ds.Version, UID: ds.UID, OrgID: ds.OrgID, Name: ds.Name, diff --git a/pkg/services/provisioning/datasources/types_test.go b/pkg/services/provisioning/datasources/types_test.go index 75820d6b128..5cf0592f6b7 100644 --- a/pkg/services/provisioning/datasources/types_test.go +++ b/pkg/services/provisioning/datasources/types_test.go @@ -13,3 +13,11 @@ func TestUIDFromNames(t *testing.T) { require.Equal(t, safeUIDFromName("AAA"), "PCB1AD2119D8FAFB6") }) } + +func TestCreateUpdateCommand(t *testing.T) { + t.Run("includes the version in the command", func(t *testing.T) { + ds := &upsertDataSourceFromConfig{OrgID: 1, Version: 1, Name: "test"} + cmd := createUpdateCommand(ds, 1) + require.Equal(t, 1, cmd.Version) + }) +} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index f1b1ffb0b64..873295b3db3 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -267,7 +267,6 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error SQLStore: ps.SQLStore, Logger: ps.log, FolderService: nil, // we don't use it yet - AccessControl: ps.ac, DashboardService: ps.dashboardService, } ruleService := provisioning.NewAlertRuleService( diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index c866797c3fd..c2806f1a090 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -480,8 +480,7 @@ func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService _, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}) require.NoError(t, err) m := metrics.NewNGAlert(prometheus.NewRegistry()) - ruleStore, err := ngstore.ProvideDBStore(sqlStore.Cfg, featuremgmt.WithFeatures(), sqlStore, &foldertest.FakeService{}, - &acmock.Mock{}, &dashboards.FakeDashboardService{}) + ruleStore, err := ngstore.ProvideDBStore(sqlStore.Cfg, featuremgmt.WithFeatures(), sqlStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}) require.NoError(t, err) _, err = ngalert.ProvideService( sqlStore.Cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotaService, diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index cae6134a2b6..b3c9fcc88d7 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -112,7 +112,10 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf azureURL = ub.BuildMetricsURL() // POST requests are only supported at the subscription level filterInBody = false - resourceUri := ub.buildResourceURI() + resourceUri, err := ub.buildResourceURI() + if err != nil { + return nil, err + } if resourceUri != nil { resourceMap[*resourceUri] = dataquery.AzureMonitorResource{ResourceGroup: resourceGroup, ResourceName: resourceName} } @@ -125,7 +128,7 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf MetricNamespace: azJSONModel.MetricNamespace, ResourceName: r.ResourceName, } - resourceUri := ub.buildResourceURI() + resourceUri, _ := ub.buildResourceURI() if resourceUri != nil { resourceMap[*resourceUri] = r } diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go index 2d38ce835a4..5785b94f976 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go @@ -15,11 +15,12 @@ type urlBuilder struct { ResourceGroup *string MetricNamespace *string ResourceName *string + MetricDefinition *string } -func (params *urlBuilder) buildResourceURI() *string { +func (params *urlBuilder) buildResourceURI() (*string, error) { if params.ResourceURI != nil && *params.ResourceURI != "" { - return params.ResourceURI + return params.ResourceURI, nil } subscription := params.Subscription @@ -28,11 +29,16 @@ func (params *urlBuilder) buildResourceURI() *string { subscription = params.DefaultSubscription } - if params.MetricNamespace == nil || *params.MetricNamespace == "" { - return nil + metricNamespace := params.MetricNamespace + + if metricNamespace == nil || *metricNamespace == "" { + if params.MetricDefinition == nil || *params.MetricDefinition == "" { + return nil, fmt.Errorf("no metricNamespace or metricDefiniton value provided") + } + metricNamespace = params.MetricDefinition } - metricNamespaceArray := strings.Split(*params.MetricNamespace, "/") + metricNamespaceArray := strings.Split(*metricNamespace, "/") var resourceNameArray []string if params.ResourceName != nil && *params.ResourceName != "" { resourceNameArray = strings.Split(*params.ResourceName, "/") @@ -40,7 +46,7 @@ func (params *urlBuilder) buildResourceURI() *string { provider := metricNamespaceArray[0] metricNamespaceArray = metricNamespaceArray[1:] - if strings.HasPrefix(strings.ToLower(*params.MetricNamespace), "microsoft.storage/storageaccounts/") && + if strings.HasPrefix(strings.ToLower(*metricNamespace), "microsoft.storage/storageaccounts/") && params.ResourceName != nil && !strings.HasSuffix(*params.ResourceName, "default") { resourceNameArray = append(resourceNameArray, "default") @@ -64,7 +70,7 @@ func (params *urlBuilder) buildResourceURI() *string { } resourceURI := strings.Join(urlArray, "/") - return &resourceURI + return &resourceURI, nil } // BuildMetricsURL checks the metric properties to see which form of the url @@ -74,7 +80,7 @@ func (params *urlBuilder) BuildMetricsURL() string { // Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI if resourceURI == nil || *resourceURI == "" { - resourceURI = params.buildResourceURI() + resourceURI, _ = params.buildResourceURI() } return fmt.Sprintf("%s/providers/microsoft.insights/metrics", *resourceURI) diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go index e16385f6bfb..09e9a3a9b71 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go @@ -91,18 +91,56 @@ func TestURLBuilder(t *testing.T) { url := ub.BuildMetricsURL() assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics", url) }) + }) + }) +} - t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { - ub := &urlBuilder{ - DefaultSubscription: strPtr("default-sub"), - ResourceGroup: strPtr("rg"), - MetricNamespace: strPtr("Microsoft.Storage/storageAccounts/blobServices"), - ResourceName: strPtr("rn1"), +func TestBuildResourceURI(t *testing.T) { + t.Run("AzureMonitor Resource URI Builder", func(t *testing.T) { + t.Run("when there is no resource uri", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: strPtr("default-sub"), + MetricDefinition: strPtr("Microsoft.Web/serverFarms"), + ResourceGroup: strPtr("rg"), + ResourceName: strPtr("rn1"), + } + + result, err := ub.buildResourceURI() + if err != nil { + return + } + url := *result + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Web/serverFarms/rn1", url) + }) + + t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: strPtr("default-sub"), + ResourceGroup: strPtr("rg"), + MetricNamespace: strPtr("Microsoft.Storage/storageAccounts/blobServices"), + ResourceName: strPtr("rn1"), + } + + result, err := ub.buildResourceURI() + if err != nil { + return + } + url := *result + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default", url) + }) + + t.Run("when metricDefinition or metricNamespace is not defined an error is thrown", func(t *testing.T) { + ub := &urlBuilder{} + + _, err := ub.buildResourceURI() + if err == nil { + t.Errorf("Expected an error, but got nil") + } else { + expectedErrorMessage := "no metricNamespace or metricDefiniton value provided" + if err.Error() != expectedErrorMessage { + t.Errorf("Expected error message %s, but got %s", expectedErrorMessage, err.Error()) } - - url := *ub.buildResourceURI() - assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default", url) - }) + } }) }) } diff --git a/pkg/tsdb/cloudwatch/log_query.go b/pkg/tsdb/cloudwatch/log_query.go index 1b9bc517b3b..575421f23c2 100644 --- a/pkg/tsdb/cloudwatch/log_query.go +++ b/pkg/tsdb/cloudwatch/log_query.go @@ -205,14 +205,16 @@ func groupResults(results *data.Frame, groupingFieldNames []string, fromSyncQuer if fromSyncQuery { // remove grouping indices newFrame.Fields = removeFieldsByIndex(newFrame.Fields, removeFieldIndices) + groupLabels := generateLabels(groupingFields, i) // set the group key as the display name for sync queries - for i := 1; i < len(newFrame.Fields); i++ { - valueField := newFrame.Fields[i] + for j := 1; j < len(newFrame.Fields); j++ { + valueField := newFrame.Fields[j] if valueField.Config == nil { valueField.Config = &data.FieldConfig{} } valueField.Config.DisplayNameFromDS = groupKey + valueField.Labels = groupLabels } } @@ -275,10 +277,21 @@ func generateGroupKey(fields []*data.Field, row int) string { } } } - return groupKey } +func generateLabels(fields []*data.Field, row int) data.Labels { + labels := data.Labels{} + for _, field := range fields { + if strField, ok := field.At(row).(*string); ok { + if strField != nil { + labels[field.Name] = *strField + } + } + } + return labels +} + func numericFieldToStringField(field *data.Field) (*data.Field, error) { if !field.Type().Numeric() { return nil, fmt.Errorf("field is not numeric") diff --git a/pkg/tsdb/cloudwatch/log_query_test.go b/pkg/tsdb/cloudwatch/log_query_test.go index 125c6c31577..1a72ded1e09 100644 --- a/pkg/tsdb/cloudwatch/log_query_test.go +++ b/pkg/tsdb/cloudwatch/log_query_test.go @@ -583,7 +583,7 @@ func TestGroupingResultsWithFromSyncQueryTrue(t *testing.T) { Name: "fakelog-a1", Fields: []*data.Field{ data.NewField("@timestamp", data.Labels{}, []*time.Time{&timeA, &timeB}), - data.NewField("count", data.Labels{}, []*string{ + data.NewField("count", data.Labels{"@log": "fakelog-a", "stream": "1"}, []*string{ aws.String("100"), aws.String("57"), }), @@ -594,7 +594,7 @@ func TestGroupingResultsWithFromSyncQueryTrue(t *testing.T) { Name: "fakelog-b1", Fields: []*data.Field{ data.NewField("@timestamp", data.Labels{}, []*time.Time{&timeA, &timeB}), - data.NewField("count", data.Labels{}, []*string{ + data.NewField("count", data.Labels{"@log": "fakelog-b", "stream": "1"}, []*string{ aws.String("150"), aws.String("62"), }), diff --git a/pkg/util/filepath.go b/pkg/util/filepath.go index 1034245ac59..527457aadbb 100644 --- a/pkg/util/filepath.go +++ b/pkg/util/filepath.go @@ -21,7 +21,7 @@ type WalkFunc func(resolvedPath string, info os.FileInfo, err error) error // can detect infinite loops while following sym links. // It solves the issue where your WalkFunc needs a path relative to the symbolic link // (resolving links within walkfunc loses the path to the symbolic link for each traversal). -func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walkFn WalkFunc) error { +func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, followDistFolder bool, walkFn WalkFunc) error { info, err := os.Lstat(path) if err != nil { return err @@ -34,7 +34,7 @@ func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walk symlinkPathsFollowed = make(map[string]bool, 8) } } - return walk(path, info, resolvedPath, symlinkPathsFollowed, walkFn) + return walk(path, info, resolvedPath, symlinkPathsFollowed, followDistFolder, walkFn) } // walk walks the path. It is a helper/sibling function to Walk. @@ -43,7 +43,7 @@ func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walk // // If resolvedPath is "", then we are not following symbolic links. // If symlinkPathsFollowed is not nil, then we need to detect infinite loop. -func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollowed map[string]bool, walkFn WalkFunc) error { +func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollowed map[string]bool, followDistFolder bool, walkFn WalkFunc) error { if info == nil { return errors.New("walk: Nil FileInfo passed") } @@ -81,7 +81,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow if err != nil { return err } - return walk(path, info2, path2, symlinkPathsFollowed, walkFn) + return walk(path, info2, path2, symlinkPathsFollowed, followDistFolder, walkFn) } else if info.IsDir() { list, err := os.ReadDir(path) if err != nil { @@ -102,12 +102,13 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow subFiles = append(subFiles, subFile{path: path2, resolvedPath: resolvedPath2, fileInfo: fileInfo}) } - if containsDistFolder(subFiles) { + if containsDistFolder(subFiles) && followDistFolder { err := walk( filepath.Join(path, "dist"), info, filepath.Join(resolvedPath, "dist"), symlinkPathsFollowed, + followDistFolder, walkFn) if err != nil { @@ -115,7 +116,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow } } else { for _, p := range subFiles { - err = walk(p.path, p.fileInfo, p.resolvedPath, symlinkPathsFollowed, walkFn) + err = walk(p.path, p.fileInfo, p.resolvedPath, symlinkPathsFollowed, followDistFolder, walkFn) if err != nil { return err diff --git a/public/api-merged.json b/public/api-merged.json index b61b4a4aeb0..07fc7c71426 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -1,14 +1,7 @@ { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "schemes": [ - "http", - "https" - ], + "consumes": ["application/json"], + "produces": ["application/json"], + "schemes": ["http", "https"], "swagger": "2.0", "info": { "description": "The Grafana backend exposes an HTTP API, the same API is used by the frontend to do\neverything from saving dashboards, creating users and updating data sources.", @@ -25,9 +18,7 @@ "/access-control/assignments/search": { "post": { "description": "Returns the result of the search through access-control role assignments.\n\nYou need to have a permission with action `teams.roles:read` on scope `teams:*`\nand a permission with action `users.roles:read` on scope `users:*`.", - "tags": [ - "enterprise" - ], + "tags": ["enterprise"], "summary": "Debug permissions.", "operationId": "searchResult", "responses": { @@ -49,10 +40,7 @@ "/access-control/roles": { "get": { "description": "Gets all existing roles. The response contains all global and organization local roles, for the organization which user is signed in.\n\nYou need to have a permission with action `roles:read` and scope `roles:*`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Get all roles.", "operationId": "listRoles", "parameters": [ @@ -76,10 +64,7 @@ }, "post": { "description": "Creates a new custom role and maps given permissions to that role. Note that roles with the same prefix as Fixed Roles can’t be created.\n\nYou need to have a permission with action `roles:write` and scope `permissions:type:delegate`. `permissions:type:delegate` scope ensures that users can only create custom roles with the same, or a subset of permissions which the user has.\nFor example, if a user does not have required permissions for creating users, they won’t be able to create a custom role which allows to do that. This is done to prevent escalation of privileges.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Create a new custom role.", "operationId": "createRole", "parameters": [ @@ -111,10 +96,7 @@ "/access-control/roles/{roleUID}": { "get": { "description": "Get a role for the given UID.\n\nYou need to have a permission with action `roles:read` and scope `roles:*`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Get a role.", "operationId": "getRole", "parameters": [ @@ -139,10 +121,7 @@ }, "put": { "description": "You need to have a permission with action `roles:write` and scope `permissions:type:delegate`. `permissions:type:delegate` scope ensures that users can only create custom roles with the same, or a subset of permissions which the user has.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Update a custom role.", "operationId": "updateRole", "parameters": [ @@ -181,10 +160,7 @@ }, "delete": { "description": "Delete a role with the given UID, and it’s permissions. If the role is assigned to a built-in role, the deletion operation will fail, unless force query param is set to true, and in that case all assignments will also be deleted.\n\nYou need to have a permission with action `roles:delete` and scope `permissions:type:delegate`. `permissions:type:delegate` scope ensures that users can only delete a custom role with the same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to delete a custom role which allows to do that.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Delete a custom role.", "operationId": "deleteRole", "parameters": [ @@ -224,10 +200,7 @@ "/access-control/roles/{roleUID}/assignments": { "get": { "description": "Get role assignments for the role with the given UID.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Get role assignments.", "operationId": "getRoleAssignments", "parameters": [ @@ -255,10 +228,7 @@ }, "put": { "description": "Set role assignments for the role with the given UID.\n\nYou need to have a permission with action `teams.roles:add` and `teams.roles:remove` and scope `permissions:type:delegate`, and `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Set role assignments.", "operationId": "setRoleAssignments", "parameters": [ @@ -296,10 +266,7 @@ "/access-control/status": { "get": { "description": "Returns an indicator to check if fine-grained access control is enabled or not.\n\nYou need to have a permission with action `status:accesscontrol` and scope `services:accesscontrol`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Get status.", "operationId": "getAccessControlStatus", "responses": { @@ -321,10 +288,7 @@ "/access-control/teams/{teamId}/roles": { "get": { "description": "You need to have a permission with action `teams.roles:read` and scope `teams:id:\u003cteam ID\u003e`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Get team roles.", "operationId": "listTeamRoles", "parameters": [ @@ -353,10 +317,7 @@ }, "put": { "description": "You need to have a permission with action `teams.roles:add` and `teams.roles:remove` and scope `permissions:type:delegate` for each.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Update team role.", "operationId": "setTeamRoles", "parameters": [ @@ -388,10 +349,7 @@ }, "post": { "description": "You need to have a permission with action `teams.roles:add` and scope `permissions:type:delegate`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Add team role.", "operationId": "addTeamRole", "parameters": [ @@ -433,10 +391,7 @@ "/access-control/teams/{teamId}/roles/{roleUID}": { "delete": { "description": "You need to have a permission with action `teams.roles:remove` and scope `permissions:type:delegate`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Remove team role.", "operationId": "removeTeamRole", "parameters": [ @@ -476,10 +431,7 @@ "/access-control/users/{userId}/roles": { "get": { "description": "Lists the roles that have been directly assigned to a given user. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.\n\nYou need to have a permission with action `users.roles:read` and scope `users:id:\u003cuser ID\u003e`.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "List roles assigned to a user.", "operationId": "listUserRoles", "parameters": [ @@ -508,10 +460,7 @@ }, "put": { "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Set user role assignments.", "operationId": "setUserRoles", "parameters": [ @@ -551,10 +500,7 @@ }, "post": { "description": "Assign a role to a specific user. For bulk updates consider Set user role assignments.\n\nYou need to have a permission with action `users.roles:add` and scope `permissions:type:delegate`. `permissions:type:delegate` scope ensures that users can only assign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign a role which will allow to do that. This is done to prevent escalation of privileges.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Add a user role assignment.", "operationId": "addUserRole", "parameters": [ @@ -593,10 +539,7 @@ "/access-control/users/{userId}/roles/{roleUID}": { "delete": { "description": "Revoke a role from a user. For bulk updates consider Set user role assignments.\n\nYou need to have a permission with action `users.roles:remove` and scope `permissions:type:delegate`. `permissions:type:delegate` scope ensures that users can only unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to unassign a role which will allow to do that. This is done to prevent escalation of privileges.", - "tags": [ - "access_control", - "enterprise" - ], + "tags": ["access_control", "enterprise"], "summary": "Remove a user role assignment.", "operationId": "removeUserRole", "parameters": [ @@ -642,10 +585,7 @@ "/admin/ldap-sync-status": { "get": { "description": "You need to have a permission with action `ldap.status:read`.", - "tags": [ - "ldap_debug", - "enterprise" - ], + "tags": ["ldap_debug", "enterprise"], "summary": "Returns the current state of the LDAP background sync integration.", "operationId": "getSyncStatus", "responses": { @@ -672,9 +612,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.config:reload`.", - "tags": [ - "admin_ldap" - ], + "tags": ["admin_ldap"], "summary": "Reloads the LDAP configuration.", "operationId": "reloadLDAPCfg", "responses": { @@ -701,9 +639,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.status:read`.", - "tags": [ - "admin_ldap" - ], + "tags": ["admin_ldap"], "summary": "Attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not.", "operationId": "getLDAPStatus", "responses": { @@ -730,9 +666,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:sync`.", - "tags": [ - "admin_ldap" - ], + "tags": ["admin_ldap"], "summary": "Enables a single Grafana user to be synchronized against LDAP.", "operationId": "postSyncUserWithLDAP", "parameters": [ @@ -768,9 +702,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `ldap.user:read`.", - "tags": [ - "admin_ldap" - ], + "tags": ["admin_ldap"], "summary": "Finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced.", "operationId": "getUserFromLDAP", "parameters": [ @@ -804,9 +736,7 @@ "basic": [] } ], - "tags": [ - "admin" - ], + "tags": ["admin"], "summary": "Pause/unpause all (legacy) alerts.", "operationId": "pauseAllAlerts", "parameters": [ @@ -837,10 +767,7 @@ }, "/admin/provisioning/access-control/reload": { "post": { - "tags": [ - "access_control_provisioning", - "enterprise" - ], + "tags": ["access_control_provisioning", "enterprise"], "summary": "You need to have a permission with action `provisioning:reload` with scope `provisioners:accesscontrol`.", "operationId": "adminProvisioningReloadAccessControl", "responses": { @@ -864,9 +791,7 @@ } ], "description": "Reloads the provisioning config files for dashboards again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:dashboards`.", - "tags": [ - "admin_provisioning" - ], + "tags": ["admin_provisioning"], "summary": "Reload dashboard provisioning configurations.", "operationId": "adminProvisioningReloadDashboards", "responses": { @@ -893,9 +818,7 @@ } ], "description": "Reloads the provisioning config files for datasources again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:datasources`.", - "tags": [ - "admin_provisioning" - ], + "tags": ["admin_provisioning"], "summary": "Reload datasource provisioning configurations.", "operationId": "adminProvisioningReloadDatasources", "responses": { @@ -922,9 +845,7 @@ } ], "description": "Reloads the provisioning config files for legacy alert notifiers again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:notifications`.", - "tags": [ - "admin_provisioning" - ], + "tags": ["admin_provisioning"], "summary": "Reload legacy alert notifier provisioning configurations.", "operationId": "adminProvisioningReloadNotifications", "responses": { @@ -951,9 +872,7 @@ } ], "description": "Reloads the provisioning config files for plugins again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:plugin`.", - "tags": [ - "admin_provisioning" - ], + "tags": ["admin_provisioning"], "summary": "Reload plugin provisioning configurations.", "operationId": "adminProvisioningReloadPlugins", "responses": { @@ -980,9 +899,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `settings:read` and scopes: `settings:*`, `settings:auth.saml:` and `settings:auth.saml:enabled` (property level).", - "tags": [ - "admin" - ], + "tags": ["admin"], "summary": "Fetch settings.", "operationId": "adminGetSettings", "responses": { @@ -1001,9 +918,7 @@ "/admin/stats": { "get": { "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `server:stats:read`.", - "tags": [ - "admin" - ], + "tags": ["admin"], "summary": "Fetch Grafana Stats.", "operationId": "adminGetStats", "responses": { @@ -1030,9 +945,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:create`.\nNote that OrgId is an optional parameter that can be used to assign a new user to a different organization when `auto_assign_org` is set to `true`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Create new user.", "operationId": "adminCreateUser", "parameters": [ @@ -1075,9 +988,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global.users:*`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Delete global User.", "operationId": "adminDeleteUser", "parameters": [ @@ -1116,9 +1027,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global.users:*`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Return a list of all auth tokens (devices) that the user currently have logged in from.", "operationId": "adminGetUserAuthTokens", "parameters": [ @@ -1154,9 +1063,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global.users:1` (userIDScope).", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Disable user.", "operationId": "adminDisableUser", "parameters": [ @@ -1195,9 +1102,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global.users:1` (userIDScope).", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Enable user.", "operationId": "adminEnableUser", "parameters": [ @@ -1236,9 +1141,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global.users:*`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in and will be required to authenticate again upon next activity.", "operationId": "adminLogoutUser", "parameters": [ @@ -1280,9 +1183,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global.users:*`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Set password for user.", "operationId": "adminUpdateUserPassword", "parameters": [ @@ -1324,9 +1225,7 @@ "/admin/users/{user_id}/permissions": { "put": { "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global.users:*`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Set permissions for user.", "operationId": "adminUpdateUserPermissions", "parameters": [ @@ -1373,9 +1272,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global.users:1` (userIDScope).", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Fetch user quota.", "operationId": "getUserQuota", "parameters": [ @@ -1414,9 +1311,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global.users:1` (userIDScope).", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Update user quota.", "operationId": "updateUserQuota", "parameters": [ @@ -1469,9 +1364,7 @@ } ], "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global.users:*`.", - "tags": [ - "admin_users" - ], + "tags": ["admin_users"], "summary": "Revoke auth token for user.", "operationId": "adminRevokeUserAuthToken", "parameters": [ @@ -1516,9 +1409,7 @@ "/alert-notifications": { "get": { "description": "Returns all notification channels that the authenticated user has permission to view.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Get all notification channels.", "operationId": "getAlertNotificationChannels", "responses": { @@ -1538,9 +1429,7 @@ }, "post": { "description": "You can find the full list of [supported notifiers](https://grafana.com/docs/grafana/latest/alerting/old-alerting/notifications/#list-of-supported-notifiers) on the alert notifiers page.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Create notification channel.", "operationId": "createAlertNotificationChannel", "parameters": [ @@ -1575,9 +1464,7 @@ "/alert-notifications/lookup": { "get": { "description": "Returns all notification channels, but with less detailed information. Accessible by any authenticated user and is mainly used by providing alert notification channels in Grafana UI when configuring alert rule.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Get all notification channels (lookup).", "operationId": "getAlertNotificationLookup", "responses": { @@ -1599,9 +1486,7 @@ "/alert-notifications/test": { "post": { "description": "Sends a test notification to the channel.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Test notification channel.", "operationId": "notificationChannelTest", "parameters": [ @@ -1639,9 +1524,7 @@ "/alert-notifications/uid/{notification_channel_uid}": { "get": { "description": "Returns the notification channel given the notification channel UID.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Get notification channel by UID.", "operationId": "getAlertNotificationChannelByUID", "parameters": [ @@ -1672,9 +1555,7 @@ }, "put": { "description": "Updates an existing notification channel identified by uid.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Update notification channel by UID.", "operationId": "updateAlertNotificationChannelByUID", "parameters": [ @@ -1713,9 +1594,7 @@ }, "delete": { "description": "Deletes an existing notification channel identified by UID.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Delete alert notification by UID.", "operationId": "deleteAlertNotificationChannelByUID", "parameters": [ @@ -1748,9 +1627,7 @@ "/alert-notifications/{notification_channel_id}": { "get": { "description": "Returns the notification channel given the notification channel ID.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Get notification channel by ID.", "operationId": "getAlertNotificationChannelByID", "parameters": [ @@ -1782,9 +1659,7 @@ }, "put": { "description": "Updates an existing notification channel identified by ID.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Update notification channel by ID.", "operationId": "updateAlertNotificationChannel", "parameters": [ @@ -1824,9 +1699,7 @@ }, "delete": { "description": "Deletes an existing notification channel identified by ID.", - "tags": [ - "legacy_alerts_notification_channels" - ], + "tags": ["legacy_alerts_notification_channels"], "summary": "Delete alert notification by ID.", "operationId": "deleteAlertNotificationChannel", "parameters": [ @@ -1859,9 +1732,7 @@ }, "/alerts": { "get": { - "tags": [ - "legacy_alerts" - ], + "tags": ["legacy_alerts"], "summary": "Get legacy alerts.", "operationId": "getAlerts", "parameters": [ @@ -1888,15 +1759,7 @@ "in": "query" }, { - "enum": [ - "all", - "no_data", - "paused", - "alerting", - "ok", - "pending", - "unknown" - ], + "enum": ["all", "no_data", "paused", "alerting", "ok", "pending", "unknown"], "type": "string", "description": "Return alerts with one or more of the following alert states", "name": "state", @@ -1951,9 +1814,7 @@ }, "/alerts/states-for-dashboard": { "get": { - "tags": [ - "legacy_alerts" - ], + "tags": ["legacy_alerts"], "summary": "Get alert states for a dashboard.", "operationId": "getDashboardStates", "parameters": [ @@ -1980,9 +1841,7 @@ }, "/alerts/test": { "post": { - "tags": [ - "legacy_alerts" - ], + "tags": ["legacy_alerts"], "summary": "Test alert.", "operationId": "testAlert", "parameters": [ @@ -2016,9 +1875,7 @@ "/alerts/{alert_id}": { "get": { "description": "“evalMatches” data in the response is cached in the db when and only when the state of the alert changes (e.g. transitioning from “ok” to “alerting” state).\nIf data from one server triggers the alert first and, before that server is seen leaving alerting state, a second server also enters a state that would trigger the alert, the second server will not be visible in “evalMatches” data.", - "tags": [ - "legacy_alerts" - ], + "tags": ["legacy_alerts"], "summary": "Get alert by ID.", "operationId": "getAlertByID", "parameters": [ @@ -2044,9 +1901,7 @@ }, "/alerts/{alert_id}/pause": { "post": { - "tags": [ - "legacy_alerts" - ], + "tags": ["legacy_alerts"], "summary": "Pause/unpause alert by id.", "operationId": "pauseAlert", "parameters": [ @@ -2087,9 +1942,7 @@ "/annotations": { "get": { "description": "Starting in Grafana v6.4 regions annotations are now returned in one entity that now includes the timeEnd property.", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Find Annotations.", "operationId": "getAnnotations", "parameters": [ @@ -2159,10 +2012,7 @@ "in": "query" }, { - "enum": [ - "alert", - "annotation" - ], + "enum": ["alert", "annotation"], "type": "string", "description": "Return alerts or user created annotations", "name": "type", @@ -2189,9 +2039,7 @@ }, "post": { "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Create Annotation.", "operationId": "postAnnotation", "parameters": [ @@ -2226,9 +2074,7 @@ "/annotations/graphite": { "post": { "description": "Creates an annotation by using Graphite-compatible event format. The `when` and `data` fields are optional. If `when` is not specified then the current time will be used as annotation’s timestamp. The `tags` field can also be in prior to Graphite `0.10.0` format (string with multiple tags being separated by a space).", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Create Annotation in Graphite format.", "operationId": "postGraphiteAnnotation", "parameters": [ @@ -2262,9 +2108,7 @@ }, "/annotations/mass-delete": { "post": { - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Delete multiple annotations.", "operationId": "massDeleteAnnotations", "parameters": [ @@ -2293,9 +2137,7 @@ "/annotations/tags": { "get": { "description": "Find all the event tags created in the annotations.", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Find Annotations Tags.", "operationId": "getAnnotationTags", "parameters": [ @@ -2328,9 +2170,7 @@ }, "/annotations/{annotation_id}": { "get": { - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Get Annotation by ID.", "operationId": "getAnnotationByID", "parameters": [ @@ -2355,9 +2195,7 @@ }, "put": { "description": "Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the Patch Annotation operation.", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Update Annotation.", "operationId": "updateAnnotation", "parameters": [ @@ -2396,9 +2234,7 @@ }, "delete": { "description": "Deletes the annotation that matches the specified ID.", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Delete Annotation By ID.", "operationId": "deleteAnnotationByID", "parameters": [ @@ -2426,9 +2262,7 @@ }, "patch": { "description": "Updates one or more properties of an annotation that matches the specified ID.\nThis operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties.\nThis is available in Grafana 6.0.0-beta2 and above.", - "tags": [ - "annotations" - ], + "tags": ["annotations"], "summary": "Patch Annotation.", "operationId": "patchAnnotation", "parameters": [ @@ -2468,9 +2302,7 @@ }, "/api/v1/provisioning/alert-rules": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get all the alert rules.", "operationId": "RouteGetAlertRules", "responses": { @@ -2483,12 +2315,8 @@ } }, "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Create a new alert rule.", "operationId": "RoutePostAlertRule", "parameters": [ @@ -2523,9 +2351,7 @@ }, "/api/v1/provisioning/alert-rules/export": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Export all alert rules in provisioning file format.", "operationId": "RouteGetAlertRulesExport", "parameters": [ @@ -2580,9 +2406,7 @@ }, "/api/v1/provisioning/alert-rules/{UID}": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get a specific alert rule by UID.", "operationId": "RouteGetAlertRule", "parameters": [ @@ -2607,12 +2431,8 @@ } }, "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Update an existing alert rule.", "operationId": "RoutePutAlertRule", "parameters": [ @@ -2652,9 +2472,7 @@ } }, "delete": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Delete a specific alert rule by UID.", "operationId": "RouteDeleteAlertRule", "parameters": [ @@ -2675,14 +2493,8 @@ }, "/api/v1/provisioning/alert-rules/{UID}/export": { "get": { - "produces": [ - "application/json", - "application/yaml", - "text/yaml" - ], - "tags": [ - "provisioning" - ], + "produces": ["application/json", "application/yaml", "text/yaml"], + "tags": ["provisioning"], "summary": "Export an alert rule in provisioning file format.", "operationId": "RouteGetAlertRuleExport", "parameters": [ @@ -2723,9 +2535,7 @@ }, "/api/v1/provisioning/contact-points": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get all the contact points.", "operationId": "RouteGetContactpoints", "parameters": [ @@ -2746,12 +2556,8 @@ } }, "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Create a contact point.", "operationId": "RoutePostContactpoints", "parameters": [ @@ -2781,9 +2587,7 @@ }, "/api/v1/provisioning/contact-points/export": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Export all contact points in provisioning file format.", "operationId": "RouteGetContactpointsExport", "parameters": [ @@ -2833,12 +2637,8 @@ }, "/api/v1/provisioning/contact-points/{UID}": { "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Update an existing contact point.", "operationId": "RoutePutContactpoint", "parameters": [ @@ -2873,12 +2673,8 @@ } }, "delete": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Delete a contact point.", "operationId": "RouteDeleteContactpoints", "parameters": [ @@ -2899,9 +2695,7 @@ }, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get a rule group.", "operationId": "RouteGetAlertRuleGroup", "parameters": [ @@ -2931,12 +2725,8 @@ } }, "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Update the interval of a rule group.", "operationId": "RoutePutAlertRuleGroup", "parameters": [ @@ -2978,14 +2768,8 @@ }, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export": { "get": { - "produces": [ - "application/json", - "application/yaml", - "text/yaml" - ], - "tags": [ - "provisioning" - ], + "produces": ["application/json", "application/yaml", "text/yaml"], + "tags": ["provisioning"], "summary": "Export an alert rule group in provisioning file format.", "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ @@ -3031,9 +2815,7 @@ }, "/api/v1/provisioning/mute-timings": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get all the mute timings.", "operationId": "RouteGetMuteTimings", "responses": { @@ -3046,12 +2828,8 @@ } }, "post": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Create a new mute timing.", "operationId": "RoutePostMuteTiming", "parameters": [ @@ -3081,9 +2859,7 @@ }, "/api/v1/provisioning/mute-timings/{name}": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get a mute timing.", "operationId": "RouteGetMuteTiming", "parameters": [ @@ -3108,12 +2884,8 @@ } }, "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Replace an existing mute timing.", "operationId": "RoutePutMuteTiming", "parameters": [ @@ -3148,9 +2920,7 @@ } }, "delete": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Delete a mute timing.", "operationId": "RouteDeleteMuteTiming", "parameters": [ @@ -3171,9 +2941,7 @@ }, "/api/v1/provisioning/policies": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get the notification policy tree.", "operationId": "RouteGetPolicyTree", "responses": { @@ -3186,12 +2954,8 @@ } }, "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Sets the notification policy tree.", "operationId": "RoutePutPolicyTree", "parameters": [ @@ -3220,12 +2984,8 @@ } }, "delete": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Clears the notification policy tree.", "operationId": "RouteResetPolicyTree", "responses": { @@ -3240,9 +3000,7 @@ }, "/api/v1/provisioning/policies/export": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Export the notification policy tree in provisioning file format.", "operationId": "RouteGetPolicyTreeExport", "responses": { @@ -3263,9 +3021,7 @@ }, "/api/v1/provisioning/templates": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get all notification templates.", "operationId": "RouteGetTemplates", "responses": { @@ -3283,9 +3039,7 @@ }, "/api/v1/provisioning/templates/{name}": { "get": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Get a notification template.", "operationId": "RouteGetTemplate", "parameters": [ @@ -3310,12 +3064,8 @@ } }, "put": { - "consumes": [ - "application/json" - ], - "tags": [ - "provisioning" - ], + "consumes": ["application/json"], + "tags": ["provisioning"], "summary": "Updates an existing notification template.", "operationId": "RoutePutTemplate", "parameters": [ @@ -3350,9 +3100,7 @@ } }, "delete": { - "tags": [ - "provisioning" - ], + "tags": ["provisioning"], "summary": "Delete a template.", "operationId": "RouteDeleteTemplate", "parameters": [ @@ -3374,9 +3122,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.", - "tags": [ - "api_keys" - ], + "tags": ["api_keys"], "summary": "Get auth keys.", "operationId": "getAPIkeys", "parameters": [ @@ -3408,9 +3154,7 @@ }, "post": { "description": "Will return details of the created API key.", - "tags": [ - "api_keys" - ], + "tags": ["api_keys"], "summary": "Creates an API key.", "operationId": "addAPIkey", "deprecated": true, @@ -3449,9 +3193,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.", - "tags": [ - "api_keys" - ], + "tags": ["api_keys"], "summary": "Delete API key.", "operationId": "deleteAPIkey", "deprecated": true, @@ -3485,9 +3227,7 @@ }, "/dashboard/snapshots": { "get": { - "tags": [ - "snapshots" - ], + "tags": ["snapshots"], "summary": "List snapshots.", "operationId": "searchDashboardSnapshots", "parameters": [ @@ -3518,13 +3258,8 @@ }, "/dashboards/calculate-diff": { "post": { - "produces": [ - "application/json", - "text/html" - ], - "tags": [ - "dashboards" - ], + "produces": ["application/json", "text/html"], + "tags": ["dashboards"], "summary": "Perform diff on two dashboards.", "operationId": "calculateDashboardDiff", "parameters": [ @@ -3541,10 +3276,7 @@ "diffType": { "description": "The type of diff to return\nDescription:\n`basic`\n`json`", "type": "string", - "enum": [ - "basic", - "json" - ] + "enum": ["basic", "json"] }, "new": { "$ref": "#/definitions/CalculateDiffTarget" @@ -3572,9 +3304,7 @@ "/dashboards/db": { "post": { "description": "Creates a new dashboard or updates an existing dashboard.", - "tags": [ - "dashboards" - ], + "tags": ["dashboards"], "summary": "Create / Update dashboard", "operationId": "postDashboard", "parameters": [ @@ -3617,9 +3347,7 @@ }, "/dashboards/home": { "get": { - "tags": [ - "dashboards" - ], + "tags": ["dashboards"], "summary": "Get home dashboard.", "operationId": "getHomeDashboard", "responses": { @@ -3638,9 +3366,7 @@ "/dashboards/id/{DashboardID}/permissions": { "get": { "description": "Please refer to [updated API](#/dashboard_permissions/getDashboardPermissionsListByUID) instead", - "tags": [ - "dashboard_permissions" - ], + "tags": ["dashboard_permissions"], "summary": "Gets all existing permissions for the given dashboard.", "operationId": "getDashboardPermissionsListByID", "deprecated": true, @@ -3673,9 +3399,7 @@ }, "post": { "description": "Please refer to [updated API](#/dashboard_permissions/updateDashboardPermissionsByUID) instead\n\nThis operation will remove existing permissions if they’re not included in the request.", - "tags": [ - "dashboard_permissions" - ], + "tags": ["dashboard_permissions"], "summary": "Updates permissions for a dashboard.", "operationId": "updateDashboardPermissionsByID", "deprecated": true, @@ -3721,9 +3445,7 @@ "/dashboards/id/{DashboardID}/restore": { "post": { "description": "Please refer to [updated API](#/dashboard_versions/restoreDashboardVersionByUID) instead", - "tags": [ - "dashboard_versions" - ], + "tags": ["dashboard_versions"], "summary": "Restore a dashboard to a given dashboard version.", "operationId": "restoreDashboardVersionByID", "deprecated": true, @@ -3766,9 +3488,7 @@ "/dashboards/id/{DashboardID}/versions": { "get": { "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionsByUID) instead", - "tags": [ - "dashboard_versions" - ], + "tags": ["dashboard_versions"], "summary": "Gets all existing versions for the dashboard.", "operationId": "getDashboardVersionsByID", "deprecated": true, @@ -3803,9 +3523,7 @@ "/dashboards/id/{DashboardID}/versions/{DashboardVersionID}": { "get": { "description": "Please refer to [updated API](#/dashboard_versions/getDashboardVersionByUID) instead", - "tags": [ - "dashboard_versions" - ], + "tags": ["dashboard_versions"], "summary": "Get a specific dashboard version.", "operationId": "getDashboardVersionByID", "deprecated": true, @@ -3846,9 +3564,7 @@ }, "/dashboards/import": { "post": { - "tags": [ - "dashboards" - ], + "tags": ["dashboards"], "summary": "Import dashboard.", "operationId": "importDashboard", "parameters": [ @@ -3886,9 +3602,7 @@ "/dashboards/public-dashboards": { "get": { "description": "Get list of public dashboards", - "tags": [ - "dashboard_public" - ], + "tags": ["dashboard_public"], "operationId": "listPublicDashboards", "responses": { "200": { @@ -3908,9 +3622,7 @@ }, "/dashboards/tags": { "get": { - "tags": [ - "dashboards" - ], + "tags": ["dashboards"], "summary": "Get all dashboards tags of an organisation.", "operationId": "getDashboardTags", "responses": { @@ -3926,42 +3638,10 @@ } } }, - "/dashboards/trim": { - "post": { - "tags": [ - "dashboards" - ], - "summary": "Trim defaults from dashboard.", - "operationId": "trimDashboard", - "parameters": [ - { - "name": "Body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/TrimDashboardCommand" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/trimDashboardResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, "/dashboards/uid/{dashboardUid}/public-dashboards": { "get": { "description": "Get public dashboard by dashboardUid", - "tags": [ - "dashboard_public" - ], + "tags": ["dashboard_public"], "operationId": "getPublicDashboard", "parameters": [ { @@ -3994,12 +3674,8 @@ }, "post": { "description": "Create public dashboard for a dashboard", - "produces": [ - "application/json" - ], - "tags": [ - "dashboard_public" - ], + "produces": ["application/json"], + "tags": ["dashboard_public"], "operationId": "createPublicDashboard", "parameters": [ { @@ -4039,9 +3715,7 @@ "/dashboards/uid/{dashboardUid}/public-dashboards/{uid}": { "delete": { "description": "Delete public dashboard for a dashboard", - "tags": [ - "dashboard_public" - ], + "tags": ["dashboard_public"], "operationId": "deletePublicDashboard", "parameters": [ { @@ -4077,12 +3751,8 @@ }, "patch": { "description": "Update public dashboard for a dashboard", - "produces": [ - "application/json" - ], - "tags": [ - "dashboard_public" - ], + "produces": ["application/json"], + "tags": ["dashboard_public"], "operationId": "updatePublicDashboard", "parameters": [ { @@ -4128,9 +3798,7 @@ "/dashboards/uid/{uid}": { "get": { "description": "Will return the dashboard given the dashboard unique identifier (uid).", - "tags": [ - "dashboards" - ], + "tags": ["dashboards"], "summary": "Get dashboard by uid.", "operationId": "getDashboardByUID", "parameters": [ @@ -4161,9 +3829,7 @@ }, "delete": { "description": "Will delete the dashboard given the specified unique identifier (uid).", - "tags": [ - "dashboards" - ], + "tags": ["dashboards"], "summary": "Delete dashboard by uid.", "operationId": "deleteDashboardByUID", "parameters": [ @@ -4195,9 +3861,7 @@ }, "/dashboards/uid/{uid}/permissions": { "get": { - "tags": [ - "dashboard_permissions" - ], + "tags": ["dashboard_permissions"], "summary": "Gets all existing permissions for the given dashboard.", "operationId": "getDashboardPermissionsListByUID", "parameters": [ @@ -4228,9 +3892,7 @@ }, "post": { "description": "This operation will remove existing permissions if they’re not included in the request.", - "tags": [ - "dashboard_permissions" - ], + "tags": ["dashboard_permissions"], "summary": "Updates permissions for a dashboard.", "operationId": "updateDashboardPermissionsByUID", "parameters": [ @@ -4273,9 +3935,7 @@ }, "/dashboards/uid/{uid}/restore": { "post": { - "tags": [ - "dashboard_versions" - ], + "tags": ["dashboard_versions"], "summary": "Restore a dashboard to a given dashboard version using UID.", "operationId": "restoreDashboardVersionByUID", "parameters": [ @@ -4315,9 +3975,7 @@ }, "/dashboards/uid/{uid}/versions": { "get": { - "tags": [ - "dashboard_versions" - ], + "tags": ["dashboard_versions"], "summary": "Gets all existing versions for the dashboard using UID.", "operationId": "getDashboardVersionsByUID", "parameters": [ @@ -4365,9 +4023,7 @@ }, "/dashboards/uid/{uid}/versions/{DashboardVersionID}": { "get": { - "tags": [ - "dashboard_versions" - ], + "tags": ["dashboard_versions"], "summary": "Get a specific dashboard version using UID.", "operationId": "getDashboardVersionByUID", "parameters": [ @@ -4407,9 +4063,7 @@ "/datasources": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scope: `datasources:*`.", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Get all data sources.", "operationId": "getDataSources", "responses": { @@ -4429,9 +4083,7 @@ }, "post": { "description": "By defining `password` and `basicAuthPassword` under secureJsonData property\nGrafana encrypts them securely as an encrypted blob in the database.\nThe response then lists the encrypted fields under secureJsonFields.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:create`", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Create a data source.", "operationId": "addDataSource", "parameters": [ @@ -4465,9 +4117,7 @@ }, "/datasources/correlations": { "get": { - "tags": [ - "correlations" - ], + "tags": ["correlations"], "summary": "Gets all correlations.", "operationId": "getCorrelations", "parameters": [ @@ -4518,9 +4168,7 @@ "/datasources/id/{name}": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Get data source Id by Name.", "operationId": "getDataSourceIdByName", "parameters": [ @@ -4553,9 +4201,7 @@ "/datasources/name/{name}": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Get a single data source by Name.", "operationId": "getDataSourceByName", "parameters": [ @@ -4583,9 +4229,7 @@ }, "delete": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:name:*` and `datasources:name:test_datasource` (single data source).", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Delete an existing data source by name.", "operationId": "deleteDataSourceByName", "parameters": [ @@ -4618,9 +4262,7 @@ "/datasources/proxy/uid/{uid}/{datasource_proxy_route}": { "get": { "description": "Proxies all calls to the actual data source.", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Data source proxy GET calls.", "operationId": "datasourceProxyGETByUIDcalls", "parameters": [ @@ -4660,9 +4302,7 @@ }, "post": { "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Data source proxy POST calls.", "operationId": "datasourceProxyPOSTByUIDcalls", "parameters": [ @@ -4711,9 +4351,7 @@ }, "delete": { "description": "Proxies all calls to the actual data source.", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Data source proxy DELETE calls.", "operationId": "datasourceProxyDELETEByUIDcalls", "parameters": [ @@ -4755,9 +4393,7 @@ "/datasources/proxy/{id}/{datasource_proxy_route}": { "get": { "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyGETByUIDcalls) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Data source proxy GET calls.", "operationId": "datasourceProxyGETcalls", "deprecated": true, @@ -4798,9 +4434,7 @@ }, "post": { "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined\n\nPlease refer to [updated API](#/datasources/datasourceProxyPOSTByUIDcalls) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Data source proxy POST calls.", "operationId": "datasourceProxyPOSTcalls", "deprecated": true, @@ -4850,9 +4484,7 @@ }, "delete": { "description": "Proxies all calls to the actual data source.\n\nPlease refer to [updated API](#/datasources/datasourceProxyDELETEByUIDcalls) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Data source proxy DELETE calls.", "operationId": "datasourceProxyDELETEcalls", "deprecated": true, @@ -4894,9 +4526,7 @@ }, "/datasources/uid/{sourceUID}/correlations": { "get": { - "tags": [ - "correlations" - ], + "tags": ["correlations"], "summary": "Gets all correlations originating from the given data source.", "operationId": "getCorrelationsBySourceUID", "parameters": [ @@ -4923,9 +4553,7 @@ } }, "post": { - "tags": [ - "correlations" - ], + "tags": ["correlations"], "summary": "Add correlation.", "operationId": "createCorrelation", "parameters": [ @@ -4968,9 +4596,7 @@ }, "/datasources/uid/{sourceUID}/correlations/{correlationUID}": { "get": { - "tags": [ - "correlations" - ], + "tags": ["correlations"], "summary": "Gets a correlation.", "operationId": "getCorrelation", "parameters": [ @@ -5003,9 +4629,7 @@ } }, "patch": { - "tags": [ - "correlations" - ], + "tags": ["correlations"], "summary": "Updates a correlation.", "operationId": "updateCorrelation", "parameters": [ @@ -5054,9 +4678,7 @@ "/datasources/uid/{uid}": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Get a single data source by UID.", "operationId": "getDataSourceByUID", "parameters": [ @@ -5090,9 +4712,7 @@ }, "put": { "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Update an existing data source.", "operationId": "updateDataSourceByUID", "parameters": [ @@ -5128,9 +4748,7 @@ }, "delete": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:kLtEtcRGk` (single data source).", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Delete an existing data source by UID.", "operationId": "deleteDataSourceByUID", "parameters": [ @@ -5162,9 +4780,7 @@ }, "/datasources/uid/{uid}/correlations/{correlationUID}": { "delete": { - "tags": [ - "correlations" - ], + "tags": ["correlations"], "summary": "Delete a correlation.", "operationId": "deleteCorrelation", "parameters": [ @@ -5202,9 +4818,7 @@ }, "/datasources/uid/{uid}/health": { "get": { - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Sends a health check request to the plugin datasource identified by the UID.", "operationId": "checkDatasourceHealthWithUID", "parameters": [ @@ -5236,9 +4850,7 @@ }, "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { "get": { - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Fetch data source resources.", "operationId": "callDatasourceResourceWithUID", "parameters": [ @@ -5280,10 +4892,7 @@ "/datasources/{datasourceId}/disable-permissions": { "post": { "description": "Disables permissions for the data source with the given id. All existing permissions will be removed and anyone will be able to query the data source.\n\nYou need to have a permission with action `datasources.permissions:toggle` and scopes `datasources:*`, `datasources:id:*`, `datasources:id:1` (single data source).\n\nDeprecated: true.", - "tags": [ - "datasource_permissions", - "enterprise" - ], + "tags": ["datasource_permissions", "enterprise"], "summary": "Disable permissions for a data source.", "operationId": "disablePermissions", "parameters": [ @@ -5319,10 +4928,7 @@ "/datasources/{datasourceId}/enable-permissions": { "post": { "description": "Enables permissions for the data source with the given id.\nNo one except Org Admins will be able to query the data source until permissions have been added\nwhich permit certain users or teams to query the data source.\n\nYou need to have a permission with action `datasources.permissions:toggle` and scopes `datasources:*`, `datasources:id:*`, `datasources:id:1` (single data source).\n\nDeprecated: true.", - "tags": [ - "datasource_permissions", - "enterprise" - ], + "tags": ["datasource_permissions", "enterprise"], "summary": "Enable permissions for a data source.", "operationId": "enablePermissions", "parameters": [ @@ -5358,10 +4964,7 @@ "/datasources/{datasourceId}/permissions": { "get": { "description": "Gets all existing permissions for the data source with the given id.\n\nYou need to have a permission with action `datasources.permissions:read` and scopes `datasources:*`, `datasources:id:*`, `datasources:id:1` (single data source).\n\nDeprecated: true.\nDeprecated. Please use GET /api/access-control/datasources/:uid", - "tags": [ - "datasource_permissions", - "enterprise" - ], + "tags": ["datasource_permissions", "enterprise"], "summary": "Get permissions for a data source.", "operationId": "getAllPermissions", "parameters": [ @@ -5392,10 +4995,7 @@ }, "post": { "description": "You need to have a permission with action `datasources.permissions:read` and scopes `datasources:*`, `datasources:id:*`, `datasources:id:1` (single data source).\n\nDeprecated: true.\nDeprecated. Please use POST /api/access-control/datasources/:uid/users/:id, /api/access-control/datasources/:uid/teams/:id or /api/access-control/datasources/:uid/buildInRoles/:id", - "tags": [ - "datasource_permissions", - "enterprise" - ], + "tags": ["datasource_permissions", "enterprise"], "summary": "Add permissions for a data source.", "operationId": "addPermission", "parameters": [ @@ -5451,10 +5051,7 @@ "/datasources/{datasourceId}/permissions/{permissionId}": { "delete": { "description": "Removes the permission with the given permissionId for the data source with the given id.\n\nYou need to have a permission with action `datasources.permissions:delete` and scopes `datasources:*`, `datasources:id:*`, `datasources:id:1` (single data source).\n\nDeprecated: true.\nDeprecated. Please use POST /api/access-control/datasources/:uid/users/:id, /api/access-control/datasources/:uid/teams/:id or /api/access-control/datasources/:uid/buildInRoles/:id", - "tags": [ - "datasource_permissions", - "enterprise" - ], + "tags": ["datasource_permissions", "enterprise"], "summary": "Remove permission for a data source.", "operationId": "deletePermissions", "parameters": [ @@ -5490,9 +5087,7 @@ "/datasources/{id}": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/getDataSourceByUID) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Get a single data source by Id.", "operationId": "getDataSourceByID", "deprecated": true, @@ -5527,9 +5122,7 @@ }, "put": { "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/updateDataSourceByUID) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Update an existing data source by its sequential ID.", "operationId": "updateDataSourceByID", "deprecated": true, @@ -5566,9 +5159,7 @@ }, "delete": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/deleteDataSourceByUID) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Delete an existing data source by id.", "operationId": "deleteDataSourceByID", "deprecated": true, @@ -5602,9 +5193,7 @@ "/datasources/{id}/health": { "get": { "description": "Please refer to [updated API](#/datasources/checkDatasourceHealthWithUID) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Sends a health check request to the plugin datasource identified by the ID.", "operationId": "checkDatasourceHealthByID", "deprecated": true, @@ -5638,9 +5227,7 @@ "/datasources/{id}/resources/{datasource_proxy_route}": { "get": { "description": "Please refer to [updated API](#/datasources/callDatasourceResourceWithUID) instead", - "tags": [ - "datasources" - ], + "tags": ["datasources"], "summary": "Fetch data source resources by Id.", "operationId": "callDatasourceResourceByID", "deprecated": true, @@ -5683,9 +5270,7 @@ "/ds/query": { "post": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:query`.", - "tags": [ - "ds" - ], + "tags": ["ds"], "summary": "DataSource query metrics with expressions.", "operationId": "queryMetricsWithExpressions", "parameters": [ @@ -5723,9 +5308,7 @@ "/folders": { "get": { "description": "Returns all folders that the authenticated user has permission to view.\nIf nested folders are enabled, it expects an additional query parameter with the parent folder UID\nand returns the immediate subfolders that the authenticated user has permission to view.\nIf the parameter is not supplied then it returns immediate subfolders under the root\nthat the authenticated user has permission to view.", - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Get all folders.", "operationId": "getFolders", "parameters": [ @@ -5769,9 +5352,7 @@ }, "post": { "description": "If nested folders are enabled then it additionally expects the parent folder UID.", - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Create folder.", "operationId": "createFolder", "parameters": [ @@ -5809,9 +5390,7 @@ "/folders/id/{folder_id}": { "get": { "description": "Returns the folder identified by id. This is deprecated.\nPlease refer to [updated API](#/folders/getFolderByUID) instead", - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Get folder by id.", "operationId": "getFolderByID", "deprecated": true, @@ -5845,9 +5424,7 @@ }, "/folders/{folder_uid}": { "get": { - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Get folder by uid.", "operationId": "getFolderByUID", "parameters": [ @@ -5877,9 +5454,7 @@ } }, "put": { - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Update folder.", "operationId": "updateFolder", "parameters": [ @@ -5925,9 +5500,7 @@ }, "delete": { "description": "Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.\nIf nested folders are enabled then it also deletes all the subfolders.", - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Delete folder.", "operationId": "deleteFolder", "parameters": [ @@ -5969,9 +5542,7 @@ }, "/folders/{folder_uid}/counts": { "get": { - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Gets the count of each descendant of a folder by kind. The folder is identified by UID.", "operationId": "getFolderDescendantCounts", "parameters": [ @@ -6003,9 +5574,7 @@ }, "/folders/{folder_uid}/move": { "post": { - "tags": [ - "folders" - ], + "tags": ["folders"], "summary": "Move folder.", "operationId": "moveFolder", "parameters": [ @@ -6045,9 +5614,7 @@ }, "/folders/{folder_uid}/permissions": { "get": { - "tags": [ - "folder_permissions" - ], + "tags": ["folder_permissions"], "summary": "Gets all existing permissions for the folder with the given `uid`.", "operationId": "getFolderPermissionList", "parameters": [ @@ -6077,9 +5644,7 @@ } }, "post": { - "tags": [ - "folder_permissions" - ], + "tags": ["folder_permissions"], "summary": "Updates permissions for a folder. This operation will remove existing permissions if they’re not included in the request.", "operationId": "updateFolderPermissions", "parameters": [ @@ -6120,9 +5685,7 @@ "/library-elements": { "get": { "description": "Returns a list of all library elements the authenticated user has permission to view.\nUse the `perPage` query parameter to control the maximum number of library elements returned; the default limit is `100`.\nYou can also use the `page` query parameter to fetch library elements from any page other than the first one.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Get all library elements.", "operationId": "getLibraryElements", "parameters": [ @@ -6133,10 +5696,7 @@ "in": "query" }, { - "enum": [ - 1, - 2 - ], + "enum": [1, 2], "type": "integer", "format": "int64", "description": "Kind of element to search for.", @@ -6144,10 +5704,7 @@ "in": "query" }, { - "enum": [ - "alpha-asc", - "alpha-desc" - ], + "enum": ["alpha-asc", "alpha-desc"], "type": "string", "description": "Sort order of elements.", "name": "sortDirection", @@ -6202,9 +5759,7 @@ }, "post": { "description": "Creates a new library element.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Create library element.", "operationId": "createLibraryElement", "parameters": [ @@ -6242,9 +5797,7 @@ "/library-elements/name/{library_element_name}": { "get": { "description": "Returns a library element with the given name.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Get library element by name.", "operationId": "getLibraryElementByName", "parameters": [ @@ -6274,9 +5827,7 @@ "/library-elements/{library_element_uid}": { "get": { "description": "Returns a library element with the given UID.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Get library element by UID.", "operationId": "getLibraryElementByUID", "parameters": [ @@ -6307,9 +5858,7 @@ }, "delete": { "description": "Deletes an existing library element as specified by the UID. This operation cannot be reverted.\nYou cannot delete a library element that is connected. This operation cannot be reverted.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Delete library element.", "operationId": "deleteLibraryElementByUID", "parameters": [ @@ -6343,9 +5892,7 @@ }, "patch": { "description": "Updates an existing library element identified by uid.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Update library element.", "operationId": "updateLibraryElement", "parameters": [ @@ -6392,9 +5939,7 @@ "/library-elements/{library_element_uid}/connections/": { "get": { "description": "Returns a list of connections for a library element based on the UID specified.", - "tags": [ - "library_elements" - ], + "tags": ["library_elements"], "summary": "Get library element connections.", "operationId": "getLibraryElementConnections", "parameters": [ @@ -6426,10 +5971,7 @@ }, "/licensing/check": { "get": { - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Check license availability.", "operationId": "getStatus", "responses": { @@ -6442,10 +5984,7 @@ "/licensing/custom-permissions": { "get": { "description": "You need to have a permission with action `licensing.reports:read`.", - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Get custom permissions report.", "operationId": "getCustomPermissionsReport", "responses": { @@ -6461,13 +6000,8 @@ "/licensing/custom-permissions-csv": { "get": { "description": "You need to have a permission with action `licensing.reports:read`.", - "produces": [ - "text/csv" - ], - "tags": [ - "licensing", - "enterprise" - ], + "produces": ["text/csv"], + "tags": ["licensing", "enterprise"], "summary": "Get custom permissions report in CSV format.", "operationId": "getCustomPermissionsCSV", "responses": { @@ -6483,10 +6017,7 @@ "/licensing/refresh-stats": { "get": { "description": "You need to have a permission with action `licensing:read`.", - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Refresh license stats.", "operationId": "refreshLicenseStats", "responses": { @@ -6502,10 +6033,7 @@ "/licensing/token": { "get": { "description": "You need to have a permission with action `licensing:read`.", - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Get license token.", "operationId": "getLicenseToken", "responses": { @@ -6516,10 +6044,7 @@ }, "post": { "description": "You need to have a permission with action `licensing:update`.", - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Create license token.", "operationId": "postLicenseToken", "parameters": [ @@ -6543,10 +6068,7 @@ }, "delete": { "description": "Removes the license stored in the Grafana database. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:delete`.", - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Remove license from database.", "operationId": "deleteLicenseToken", "parameters": [ @@ -6584,10 +6106,7 @@ "/licensing/token/renew": { "post": { "description": "Manually ask license issuer for a new token. Available in Grafana Enterprise v7.4+.\n\nYou need to have a permission with action `licensing:update`.", - "tags": [ - "licensing", - "enterprise" - ], + "tags": ["licensing", "enterprise"], "summary": "Manually force license refresh.", "operationId": "postRenewLicenseToken", "parameters": [ @@ -6615,10 +6134,7 @@ }, "/logout/saml": { "get": { - "tags": [ - "saml", - "enterprise" - ], + "tags": ["saml", "enterprise"], "summary": "GetLogout initiates single logout process.", "operationId": "getSAMLLogout", "responses": { @@ -6636,9 +6152,7 @@ }, "/org": { "get": { - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Get current Organization.", "operationId": "getCurrentOrg", "responses": { @@ -6657,9 +6171,7 @@ } }, "put": { - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Update current Organization.", "operationId": "updateCurrentOrg", "parameters": [ @@ -6693,9 +6205,7 @@ }, "/org/address": { "put": { - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Update current Organization's address.", "operationId": "updateCurrentOrgAddress", "parameters": [ @@ -6729,9 +6239,7 @@ }, "/org/invites": { "get": { - "tags": [ - "org_invites" - ], + "tags": ["org_invites"], "summary": "Get pending invites.", "operationId": "getPendingOrgInvites", "responses": { @@ -6750,9 +6258,7 @@ } }, "post": { - "tags": [ - "org_invites" - ], + "tags": ["org_invites"], "summary": "Add invite.", "operationId": "addOrgInvite", "parameters": [ @@ -6789,9 +6295,7 @@ }, "/org/invites/{invitation_code}/revoke": { "delete": { - "tags": [ - "org_invites" - ], + "tags": ["org_invites"], "summary": "Revoke invite.", "operationId": "revokeInvite", "parameters": [ @@ -6823,9 +6327,7 @@ }, "/org/preferences": { "get": { - "tags": [ - "org_preferences" - ], + "tags": ["org_preferences"], "summary": "Get Current Org Prefs.", "operationId": "getOrgPreferences", "responses": { @@ -6844,9 +6346,7 @@ } }, "put": { - "tags": [ - "org_preferences" - ], + "tags": ["org_preferences"], "summary": "Update Current Org Prefs.", "operationId": "updateOrgPreferences", "parameters": [ @@ -6878,9 +6378,7 @@ } }, "patch": { - "tags": [ - "org_preferences" - ], + "tags": ["org_preferences"], "summary": "Patch Current Org Prefs.", "operationId": "patchOrgPreferences", "parameters": [ @@ -6915,9 +6413,7 @@ "/org/quotas": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", - "tags": [ - "getCurrentOrg" - ], + "tags": ["getCurrentOrg"], "summary": "Fetch Organization quota.", "operationId": "getCurrentOrgQuota", "responses": { @@ -6942,9 +6438,7 @@ "/org/users": { "get": { "description": "Returns all org users within the current organization. Accessible to users with org admin role.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Get all users within the current organization.", "operationId": "getOrgUsersForCurrentOrg", "responses": { @@ -6964,9 +6458,7 @@ }, "post": { "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Add a new user to the current organization.", "operationId": "addOrgUserToCurrentOrg", "parameters": [ @@ -6998,9 +6490,7 @@ "/org/users/lookup": { "get": { "description": "Returns all org users within the current organization, but with less detailed information.\nAccessible to users with org admin role, admin in any folder or admin of any team.\nMainly used by Grafana UI for providing list of users when adding team members and when editing folder/dashboard permissions.", - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Get all users within the current organization (lookup)", "operationId": "getOrgUsersForCurrentOrgLookup", "parameters": [ @@ -7035,9 +6525,7 @@ "/org/users/{user_id}": { "delete": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Delete user in current organization.", "operationId": "removeOrgUserForCurrentOrg", "parameters": [ @@ -7069,9 +6557,7 @@ }, "patch": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", - "tags": [ - "org" - ], + "tags": ["org"], "summary": "Updates the given user.", "operationId": "updateOrgUserForCurrentOrg", "parameters": [ @@ -7117,9 +6603,7 @@ "basic": [] } ], - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Search all Organizations.", "operationId": "searchOrgs", "parameters": [ @@ -7170,9 +6654,7 @@ }, "post": { "description": "Only works if [users.allow_org_create](https://grafana.com/docs/grafana/latest/administration/configuration/#allow_org_create) is set.", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Create Organization.", "operationId": "createOrg", "parameters": [ @@ -7211,9 +6693,7 @@ "basic": [] } ], - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Get Organization by ID.", "operationId": "getOrgByName", "parameters": [ @@ -7247,9 +6727,7 @@ "basic": [] } ], - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Get Organization by ID.", "operationId": "getOrgByID", "parameters": [ @@ -7282,9 +6760,7 @@ "basic": [] } ], - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Update Organization.", "operationId": "updateOrg", "parameters": [ @@ -7328,9 +6804,7 @@ "basic": [] } ], - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Delete Organization.", "operationId": "deleteOrgByID", "parameters": [ @@ -7366,9 +6840,7 @@ }, "/orgs/{org_id}/address": { "put": { - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Update Organization's address.", "operationId": "updateOrgAddress", "parameters": [ @@ -7410,9 +6882,7 @@ "/orgs/{org_id}/quotas": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope).", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Fetch Organization quota.", "operationId": "getOrgQuota", "parameters": [ @@ -7451,9 +6921,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:write` and scope `org:id:1` (orgIDScope).", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Update user quota.", "operationId": "updateOrgQuota", "parameters": [ @@ -7506,9 +6974,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Get Users in Organization.", "operationId": "getOrgUsers", "parameters": [ @@ -7537,9 +7003,7 @@ }, "post": { "description": "Adds a global user to the current organization.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:add` with scope `users:*`.", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Add a new user to the current organization.", "operationId": "addOrgUser", "parameters": [ @@ -7583,9 +7047,7 @@ } ], "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:read` with scope `users:*`.", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Search Users in Organization.", "operationId": "searchOrgUsers", "parameters": [ @@ -7616,9 +7078,7 @@ "/orgs/{org_id}/users/{user_id}": { "delete": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users:remove` with scope `users:*`.", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Delete user in current organization.", "operationId": "removeOrgUser", "parameters": [ @@ -7657,9 +7117,7 @@ }, "patch": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `org.users.role:update` with scope `users:*`.", - "tags": [ - "orgs" - ], + "tags": ["orgs"], "summary": "Update Users in Organization.", "operationId": "updateOrgUser", "parameters": [ @@ -7707,9 +7165,7 @@ }, "/playlists": { "get": { - "tags": [ - "playlists" - ], + "tags": ["playlists"], "summary": "Get playlists.", "operationId": "searchPlaylists", "parameters": [ @@ -7736,9 +7192,7 @@ } }, "post": { - "tags": [ - "playlists" - ], + "tags": ["playlists"], "summary": "Create playlist.", "operationId": "createPlaylist", "parameters": [ @@ -7772,9 +7226,7 @@ }, "/playlists/{uid}": { "get": { - "tags": [ - "playlists" - ], + "tags": ["playlists"], "summary": "Get playlist.", "operationId": "getPlaylist", "parameters": [ @@ -7804,9 +7256,7 @@ } }, "put": { - "tags": [ - "playlists" - ], + "tags": ["playlists"], "summary": "Update playlist.", "operationId": "updatePlaylist", "parameters": [ @@ -7844,9 +7294,7 @@ } }, "delete": { - "tags": [ - "playlists" - ], + "tags": ["playlists"], "summary": "Delete playlist.", "operationId": "deletePlaylist", "parameters": [ @@ -7878,9 +7326,7 @@ }, "/playlists/{uid}/items": { "get": { - "tags": [ - "playlists" - ], + "tags": ["playlists"], "summary": "Get playlist items.", "operationId": "getPlaylistItems", "parameters": [ @@ -7913,9 +7359,7 @@ "/public/dashboards/{accessToken}": { "get": { "description": "Get public dashboard for view", - "tags": [ - "dashboard_public" - ], + "tags": ["dashboard_public"], "operationId": "viewPublicDashboard", "parameters": [ { @@ -7950,9 +7394,7 @@ "/public/dashboards/{accessToken}/annotations": { "get": { "description": "Get annotations for a public dashboard", - "tags": [ - "dashboard_public" - ], + "tags": ["dashboard_public"], "operationId": "getPublicAnnotations", "parameters": [ { @@ -7987,9 +7429,7 @@ "/public/dashboards/{accessToken}/panels/{panelId}/query": { "post": { "description": "Get results for a given panel on a public dashboard", - "tags": [ - "dashboard_public" - ], + "tags": ["dashboard_public"], "operationId": "queryPublicDashboard", "parameters": [ { @@ -8031,9 +7471,7 @@ "/query-history": { "get": { "description": "Returns a list of queries in the query history that matches the search criteria.\nQuery history search supports pagination. Use the `limit` parameter to control the maximum number of queries returned; the default limit is 100.\nYou can also use the `page` query parameter to fetch queries from any page other than the first one.", - "tags": [ - "query_history" - ], + "tags": ["query_history"], "summary": "Query history search.", "operationId": "searchQueries", "parameters": [ @@ -8060,10 +7498,7 @@ "in": "query" }, { - "enum": [ - "time-desc", - "time-asc" - ], + "enum": ["time-desc", "time-asc"], "type": "string", "default": "time-desc", "description": "Sort method", @@ -8113,9 +7548,7 @@ }, "post": { "description": "Adds new query to query history.", - "tags": [ - "query_history" - ], + "tags": ["query_history"], "summary": "Add query to query history.", "operationId": "createQuery", "parameters": [ @@ -8147,9 +7580,7 @@ "/query-history/star/{query_history_uid}": { "post": { "description": "Adds star to query in query history as specified by the UID.", - "tags": [ - "query_history" - ], + "tags": ["query_history"], "summary": "Add star to query in query history.", "operationId": "starQuery", "parameters": [ @@ -8174,9 +7605,7 @@ }, "delete": { "description": "Removes star from query in query history as specified by the UID.", - "tags": [ - "query_history" - ], + "tags": ["query_history"], "summary": "Remove star to query in query history.", "operationId": "unstarQuery", "parameters": [ @@ -8203,9 +7632,7 @@ "/query-history/{query_history_uid}": { "delete": { "description": "Deletes an existing query in query history as specified by the UID. This operation cannot be reverted.", - "tags": [ - "query_history" - ], + "tags": ["query_history"], "summary": "Delete query in query history.", "operationId": "deleteQuery", "parameters": [ @@ -8230,9 +7657,7 @@ }, "patch": { "description": "Updates comment for query in query history as specified by the UID.", - "tags": [ - "query_history" - ], + "tags": ["query_history"], "summary": "Update comment for query in query history.", "operationId": "patchQueryComment", "parameters": [ @@ -8269,10 +7694,7 @@ }, "/recording-rules": { "get": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Lists all rules in the database: active or deleted.", "operationId": "listRecordingRules", "responses": { @@ -8294,10 +7716,7 @@ } }, "put": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Update the active status of a rule.", "operationId": "updateRecordingRule", "parameters": [ @@ -8329,10 +7748,7 @@ } }, "post": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Create a recording rule that is then registered and started.", "operationId": "createRecordingRule", "parameters": [ @@ -8366,10 +7782,7 @@ }, "/recording-rules/test": { "post": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Test a recording rule.", "operationId": "testCreateRecordingRule", "parameters": [ @@ -8406,10 +7819,7 @@ }, "/recording-rules/writer": { "get": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Return the prometheus remote write target.", "operationId": "getRecordingRuleWriteTarget", "responses": { @@ -8432,10 +7842,7 @@ }, "post": { "description": "It returns a 422 if there is not an existing prometheus data source configured.", - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Create a remote write target.", "operationId": "createRecordingRuleWriteTarget", "parameters": [ @@ -8470,10 +7877,7 @@ } }, "delete": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Delete the remote write target.", "operationId": "deleteRecordingRuleWriteTarget", "responses": { @@ -8497,10 +7901,7 @@ }, "/recording-rules/{recordingRuleID}": { "delete": { - "tags": [ - "recording_rules", - "enterprise" - ], + "tags": ["recording_rules", "enterprise"], "summary": "Delete removes the rule from the registry and stops it.", "operationId": "deleteRecordingRule", "parameters": [ @@ -8534,10 +7935,7 @@ "/reports": { "get": { "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:*`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "List reports.", "operationId": "getReports", "responses": { @@ -8557,10 +7955,7 @@ }, "post": { "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports.admin:create`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Create a report.", "operationId": "createReport", "parameters": [ @@ -8598,10 +7993,7 @@ "/reports/email": { "post": { "description": "Generate and send a report. This API waits for the report to be generated before returning. We recommend that you set the client’s timeout to at least 60 seconds. Available to org admins only and with a valid license.\n\nOnly available in Grafana Enterprise v7.0+.\nThis API endpoint is experimental and may be deprecated in a future release. On deprecation, a migration strategy will be provided and the endpoint will remain functional until the next major release of Grafana.\n\nYou need to have a permission with action `reports:send`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Send a report.", "operationId": "sendReport", "parameters": [ @@ -8639,13 +8031,8 @@ "/reports/render/pdf/{dashboardID}": { "get": { "description": "Please refer to [reports enterprise](#/reports/renderReportPDFs) instead. This will be removed in Grafana 10.", - "produces": [ - "application/pdf" - ], - "tags": [ - "reports", - "enterprise" - ], + "produces": ["application/pdf"], + "tags": ["reports", "enterprise"], "summary": "Render report for dashboard.", "operationId": "renderReportPDF", "deprecated": true, @@ -8714,13 +8101,8 @@ "/reports/render/pdfs": { "get": { "description": "Available to all users and with a valid license.", - "produces": [ - "application/pdf" - ], - "tags": [ - "reports", - "enterprise" - ], + "produces": ["application/pdf"], + "tags": ["reports", "enterprise"], "summary": "Render report for multiple dashboards.", "operationId": "renderReportPDFs", "parameters": [ @@ -8759,10 +8141,7 @@ "/reports/settings": { "get": { "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:read`x.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Get settings.", "operationId": "getReportSettings", "responses": { @@ -8782,10 +8161,7 @@ }, "post": { "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.settings:write`xx.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Save settings.", "operationId": "saveReportSettings", "parameters": [ @@ -8820,10 +8196,7 @@ "/reports/test-email": { "post": { "description": "Available to org admins only and with a valid license.\n\nYou need to have a permission with action `reports:send`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Send test report via email.", "operationId": "sendTestEmail", "parameters": [ @@ -8861,10 +8234,7 @@ "/reports/{id}": { "get": { "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports:read` with scope `reports:id:\u003creport ID\u003e`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Get a report.", "operationId": "getReport", "parameters": [ @@ -8899,10 +8269,7 @@ }, "put": { "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.admin:write` with scope `reports:id:\u003creport ID\u003e`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Update a report.", "operationId": "updateReport", "parameters": [ @@ -8945,10 +8312,7 @@ }, "delete": { "description": "Available to org admins only and with a valid or expired license.\n\nYou need to have a permission with action `reports.delete` with scope `reports:id:\u003creport ID\u003e`.", - "tags": [ - "reports", - "enterprise" - ], + "tags": ["reports", "enterprise"], "summary": "Delete a report.", "operationId": "deleteReport", "parameters": [ @@ -8984,10 +8348,7 @@ }, "/saml/acs": { "post": { - "tags": [ - "saml", - "enterprise" - ], + "tags": ["saml", "enterprise"], "summary": "It performs Assertion Consumer Service (ACS).", "operationId": "postACS", "parameters": [ @@ -9012,13 +8373,8 @@ }, "/saml/metadata": { "get": { - "produces": [ - "application/xml;application/samlmetadata+xml" - ], - "tags": [ - "saml", - "enterprise" - ], + "produces": ["application/xml;application/samlmetadata+xml"], + "tags": ["saml", "enterprise"], "summary": "It exposes the SP (Grafana's) metadata for the IdP's consumption.", "operationId": "getMetadata", "responses": { @@ -9031,10 +8387,7 @@ "/saml/slo": { "get": { "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", - "tags": [ - "saml", - "enterprise" - ], + "tags": ["saml", "enterprise"], "summary": "It performs Single Logout (SLO) callback.", "operationId": "getSLO", "responses": { @@ -9054,10 +8407,7 @@ }, "post": { "description": "There might be two possible requests:\n1. Logout response (callback) when Grafana initiates single logout and IdP returns response to logout request.\n2. Logout request when another SP initiates single logout and IdP sends logout request to the Grafana,\nor in case of IdP-initiated logout.", - "tags": [ - "saml", - "enterprise" - ], + "tags": ["saml", "enterprise"], "summary": "It performs Single Logout (SLO) callback.", "operationId": "postSLO", "parameters": [ @@ -9090,9 +8440,7 @@ }, "/search": { "get": { - "tags": [ - "search" - ], + "tags": ["search"], "operationId": "search", "parameters": [ { @@ -9112,10 +8460,7 @@ "in": "query" }, { - "enum": [ - "dash-folder", - "dash-db" - ], + "enum": ["dash-folder", "dash-db"], "type": "string", "description": "Type to search for, dash-folder or dash-db", "name": "type", @@ -9180,10 +8525,7 @@ "in": "query" }, { - "enum": [ - "Edit", - "View" - ], + "enum": ["Edit", "View"], "type": "string", "default": "View", "description": "Set to `Edit` to return dashboards/folders that the user can edit", @@ -9191,10 +8533,7 @@ "in": "query" }, { - "enum": [ - "alpha-asc", - "alpha-desc" - ], + "enum": ["alpha-asc", "alpha-desc"], "type": "string", "default": "alpha-asc", "description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.", @@ -9220,9 +8559,7 @@ }, "/search/sorting": { "get": { - "tags": [ - "search" - ], + "tags": ["search"], "summary": "List search sorting options.", "operationId": "listSortOptions", "responses": { @@ -9238,9 +8575,7 @@ "/serviceaccounts": { "post": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:*`\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "Create service account", "operationId": "createServiceAccount", "parameters": [ @@ -9274,9 +8609,7 @@ "/serviceaccounts/search": { "get": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:*`", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "Search service accounts with paging", "operationId": "searchOrgServiceAccountsWithPaging", "parameters": [ @@ -9330,9 +8663,7 @@ "/serviceaccounts/{serviceAccountId}": { "get": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "Get single serviceaccount by Id", "operationId": "retrieveServiceAccount", "parameters": [ @@ -9367,9 +8698,7 @@ }, "delete": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "Delete service account", "operationId": "deleteServiceAccount", "parameters": [ @@ -9401,9 +8730,7 @@ }, "patch": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "Update service account", "operationId": "updateServiceAccount", "parameters": [ @@ -9447,9 +8774,7 @@ "/serviceaccounts/{serviceAccountId}/tokens": { "get": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:read` scope: `global:serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "Get service account tokens", "operationId": "listTokens", "parameters": [ @@ -9481,9 +8806,7 @@ }, "post": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "CreateNewToken adds a token to a service account", "operationId": "createToken", "parameters": [ @@ -9530,9 +8853,7 @@ "/serviceaccounts/{serviceAccountId}/tokens/{tokenId}": { "delete": { "description": "Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):\naction: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)\n\nRequires basic authentication and that the authenticated user is a Grafana Admin.", - "tags": [ - "service_accounts" - ], + "tags": ["service_accounts"], "summary": "DeleteToken deletes service account tokens", "operationId": "deleteToken", "parameters": [ @@ -9576,9 +8897,7 @@ "/signing-keys/keys": { "get": { "description": "Required permissions\nNone", - "tags": [ - "signing_keys" - ], + "tags": ["signing_keys"], "summary": "Get JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys)", "operationId": "retrieveJWKS", "responses": { @@ -9593,9 +8912,7 @@ }, "/snapshot/shared-options": { "get": { - "tags": [ - "snapshots" - ], + "tags": ["snapshots"], "summary": "Get snapshot sharing settings.", "operationId": "getSharingOptions", "responses": { @@ -9611,9 +8928,7 @@ "/snapshots": { "post": { "description": "Snapshot public mode should be enabled or authentication is required.", - "tags": [ - "snapshots" - ], + "tags": ["snapshots"], "summary": "When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.", "operationId": "createDashboardSnapshot", "parameters": [ @@ -9645,9 +8960,7 @@ "/snapshots-delete/{deleteKey}": { "get": { "description": "Snapshot public mode should be enabled or authentication is required.", - "tags": [ - "snapshots" - ], + "tags": ["snapshots"], "summary": "Delete Snapshot by deleteKey.", "operationId": "deleteDashboardSnapshotByDeleteKey", "parameters": [ @@ -9679,9 +8992,7 @@ }, "/snapshots/{key}": { "get": { - "tags": [ - "snapshots" - ], + "tags": ["snapshots"], "summary": "Get Snapshot by Key.", "operationId": "getDashboardSnapshot", "parameters": [ @@ -9708,9 +9019,7 @@ } }, "delete": { - "tags": [ - "snapshots" - ], + "tags": ["snapshots"], "summary": "Delete Snapshot by Key.", "operationId": "deleteDashboardSnapshot", "parameters": [ @@ -9739,9 +9048,7 @@ }, "/teams": { "post": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Add Team.", "operationId": "createTeam", "parameters": [ @@ -9775,9 +9082,7 @@ }, "/teams/search": { "get": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Team Search With Paging.", "operationId": "searchTeams", "parameters": [ @@ -9826,10 +9131,7 @@ }, "/teams/{teamId}/groups": { "get": { - "tags": [ - "sync_team_groups", - "enterprise" - ], + "tags": ["sync_team_groups", "enterprise"], "summary": "Get External Groups.", "operationId": "getTeamGroupsApi", "parameters": [ @@ -9863,10 +9165,7 @@ } }, "post": { - "tags": [ - "sync_team_groups", - "enterprise" - ], + "tags": ["sync_team_groups", "enterprise"], "summary": "Add External Group.", "operationId": "addTeamGroupApi", "parameters": [ @@ -9908,10 +9207,7 @@ } }, "delete": { - "tags": [ - "sync_team_groups", - "enterprise" - ], + "tags": ["sync_team_groups", "enterprise"], "summary": "Remove External Group.", "operationId": "removeTeamGroupApiQuery", "parameters": [ @@ -9952,9 +9248,7 @@ }, "/teams/{team_id}": { "get": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Get Team By ID.", "operationId": "getTeamByID", "parameters": [ @@ -9984,9 +9278,7 @@ } }, "put": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Update Team.", "operationId": "updateTeam", "parameters": [ @@ -10027,9 +9319,7 @@ } }, "delete": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Delete Team By ID.", "operationId": "deleteTeamByID", "parameters": [ @@ -10061,9 +9351,7 @@ }, "/teams/{team_id}/members": { "get": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Get Team Members.", "operationId": "getTeamMembers", "parameters": [ @@ -10093,9 +9381,7 @@ } }, "post": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Add Team Member.", "operationId": "addTeamMember", "parameters": [ @@ -10135,9 +9421,7 @@ }, "/teams/{team_id}/members/{user_id}": { "put": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Update Team Member.", "operationId": "updateTeamMember", "parameters": [ @@ -10182,9 +9466,7 @@ } }, "delete": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Remove Member From Team.", "operationId": "removeTeamMember", "parameters": [ @@ -10223,9 +9505,7 @@ }, "/teams/{team_id}/preferences": { "get": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Get Team Preferences.", "operationId": "getTeamPreferences", "parameters": [ @@ -10249,9 +9529,7 @@ } }, "put": { - "tags": [ - "teams" - ], + "tags": ["teams"], "summary": "Update Team Preferences.", "operationId": "updateTeamPreferences", "parameters": [ @@ -10289,9 +9567,7 @@ "/user": { "get": { "description": "Get (current authenticated user)", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "operationId": "getSignedInUser", "responses": { "200": { @@ -10312,9 +9588,7 @@ } }, "put": { - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Update signed in User.", "operationId": "updateSignedInUser", "parameters": [ @@ -10347,9 +9621,7 @@ "/user/auth-tokens": { "get": { "description": "Return a list of all auth tokens (devices) that the actual user currently have logged in from.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Auth tokens of the actual User.", "operationId": "getUserAuthTokens", "responses": { @@ -10370,9 +9642,7 @@ }, "/user/helpflags/clear": { "get": { - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Clear user help flag.", "operationId": "clearHelpFlags", "responses": { @@ -10393,9 +9663,7 @@ }, "/user/helpflags/{flag_id}": { "put": { - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Set user help flag.", "operationId": "setHelpFlag", "parameters": [ @@ -10430,9 +9698,7 @@ } ], "description": "Return a list of all organizations of the current user.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Organizations of the actual User.", "operationId": "getSignedInUserOrgList", "responses": { @@ -10459,9 +9725,7 @@ } ], "description": "Changes the password for the user.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Change Password.", "operationId": "changeUserPassword", "parameters": [ @@ -10496,9 +9760,7 @@ }, "/user/preferences": { "get": { - "tags": [ - "user_preferences" - ], + "tags": ["user_preferences"], "summary": "Get user preferences.", "operationId": "getUserPreferences", "responses": { @@ -10515,9 +9777,7 @@ }, "put": { "description": "Omitting a key (`theme`, `homeDashboardId`, `timezone`) will cause the current value to be replaced with the system default value.", - "tags": [ - "user_preferences" - ], + "tags": ["user_preferences"], "summary": "Update user preferences.", "operationId": "updateUserPreferences", "parameters": [ @@ -10546,9 +9806,7 @@ } }, "patch": { - "tags": [ - "user_preferences" - ], + "tags": ["user_preferences"], "summary": "Patch user preferences.", "operationId": "patchUserPreferences", "parameters": [ @@ -10579,9 +9837,7 @@ }, "/user/quotas": { "get": { - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Fetch user quota.", "operationId": "getUserQuotas", "responses": { @@ -10606,9 +9862,7 @@ "/user/revoke-auth-token": { "post": { "description": "Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Revoke an auth token of the actual User.", "operationId": "revokeUserAuthToken", "parameters": [ @@ -10643,9 +9897,7 @@ "/user/stars/dashboard/uid/{dashboard_uid}": { "post": { "description": "Stars the given Dashboard for the actual user.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Star a dashboard.", "operationId": "starDashboardByUID", "parameters": [ @@ -10676,9 +9928,7 @@ }, "delete": { "description": "Deletes the starring of the given Dashboard for the actual user.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Unstar a dashboard.", "operationId": "unstarDashboardByUID", "parameters": [ @@ -10711,9 +9961,7 @@ "/user/stars/dashboard/{dashboard_id}": { "post": { "description": "Stars the given Dashboard for the actual user.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Star a dashboard.", "operationId": "starDashboard", "deprecated": true, @@ -10745,9 +9993,7 @@ }, "delete": { "description": "Deletes the starring of the given Dashboard for the actual user.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Unstar a dashboard.", "operationId": "unstarDashboard", "deprecated": true, @@ -10781,9 +10027,7 @@ "/user/teams": { "get": { "description": "Return a list of all teams that the current user is member of.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Teams that the actual User is member of.", "operationId": "getSignedInUserTeamList", "responses": { @@ -10805,9 +10049,7 @@ "/user/using/{org_id}": { "post": { "description": "Switch user context to the given organization.", - "tags": [ - "signed_in_user" - ], + "tags": ["signed_in_user"], "summary": "Switch user context for signed in user.", "operationId": "userSetUsingOrg", "parameters": [ @@ -10841,9 +10083,7 @@ "/users": { "get": { "description": "Returns all users that the authenticated user has permission to view, admin permission required.", - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Get users.", "operationId": "searchUsers", "parameters": [ @@ -10882,9 +10122,7 @@ }, "/users/lookup": { "get": { - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Get user by login or email.", "operationId": "getUserByLoginOrEmail", "parameters": [ @@ -10917,9 +10155,7 @@ }, "/users/search": { "get": { - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Get users with paging.", "operationId": "searchUsersWithPaging", "responses": { @@ -10943,9 +10179,7 @@ }, "/users/{user_id}": { "get": { - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Get user by id.", "operationId": "getUserByID", "parameters": [ @@ -10977,9 +10211,7 @@ }, "put": { "description": "Update the user identified by id.", - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Update user.", "operationId": "updateUser", "parameters": [ @@ -11022,9 +10254,7 @@ "/users/{user_id}/orgs": { "get": { "description": "Get organizations for user identified by id.", - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Get organizations for user.", "operationId": "getUserOrgList", "parameters": [ @@ -11058,9 +10288,7 @@ "/users/{user_id}/teams": { "get": { "description": "Get teams for user identified by id.", - "tags": [ - "users" - ], + "tags": ["users"], "summary": "Get teams for user.", "operationId": "getUserTeams", "parameters": [ @@ -11140,12 +10368,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] }, "secondsToLive": { "type": "integer", @@ -11212,12 +10435,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] }, "sendEmail": { "type": "boolean" @@ -11232,12 +10450,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] } } }, @@ -11472,12 +10685,7 @@ "Alert": { "type": "object", "title": "Alert has info for an alert.", - "required": [ - "labels", - "annotations", - "state", - "value" - ], + "required": ["labels", "annotations", "state", "value"], "properties": { "activeAt": { "type": "string", @@ -11500,9 +10708,7 @@ "AlertDiscovery": { "type": "object", "title": "AlertDiscovery has info for all active alerts.", - "required": [ - "alerts" - ], + "required": ["alerts"], "properties": { "alerts": { "type": "array", @@ -11721,9 +10927,7 @@ }, "AlertResponse": { "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "data": { "$ref": "#/definitions/AlertDiscovery" @@ -11763,11 +10967,7 @@ }, "execErrState": { "type": "string", - "enum": [ - "OK", - "Alerting", - "Error" - ] + "enum": ["OK", "Alerting", "Error"] }, "for": { "$ref": "#/definitions/Duration" @@ -11783,11 +10983,7 @@ }, "noDataState": { "type": "string", - "enum": [ - "Alerting", - "NoData", - "OK" - ] + "enum": ["Alerting", "NoData", "OK"] }, "panelId": { "type": "integer", @@ -11967,15 +11163,7 @@ "AlertingRule": { "description": "adapted from cortex", "type": "object", - "required": [ - "name", - "query", - "health", - "type", - "state", - "annotations", - "activeAt" - ], + "required": ["name", "query", "health", "type", "state", "annotations", "activeAt"], "properties": { "activeAt": { "type": "string", @@ -12045,11 +11233,7 @@ "properties": { "alertmanagersChoice": { "type": "string", - "enum": [ - "all", - "internal", - "external" - ] + "enum": ["all", "internal", "external"] }, "numExternalAlertmanagers": { "type": "integer", @@ -12232,12 +11416,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] } } }, @@ -12334,11 +11513,7 @@ }, "no_data_state": { "type": "string", - "enum": [ - "Alerting", - "NoData", - "OK" - ] + "enum": ["Alerting", "NoData", "OK"] }, "title": { "type": "string" @@ -12864,11 +12039,7 @@ }, "CorrelationConfig": { "type": "object", - "required": [ - "field", - "type", - "target" - ], + "required": ["field", "type", "target"], "properties": { "field": { "description": "Field used to attach the correlation link", @@ -13018,9 +12189,7 @@ }, "CreateDashboardSnapshotCommand": { "type": "object", - "required": [ - "dashboard" - ], + "required": ["dashboard"], "properties": { "dashboard": { "$ref": "#/definitions/Json" @@ -13085,10 +12254,7 @@ "description": "Kind of element to create, Use 1 for library panels or 2 for c.\nDescription:\n1 - library panels\n2 - library variables", "type": "integer", "format": "int64", - "enum": [ - 1, - 2 - ] + "enum": [1, 2] }, "model": { "description": "The JSON model for the library element.", @@ -13189,9 +12355,7 @@ "CreateQueryInQueryHistoryCommand": { "description": "CreateQueryInQueryHistoryCommand is the command for adding query history", "type": "object", - "required": [ - "queries" - ], + "required": ["queries"], "properties": { "datasourceUid": { "description": "UID of the data source for which are queries stored.", @@ -13252,12 +12416,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ], + "enum": ["None", "Viewer", "Editor", "Admin"], "example": "Admin" } } @@ -13352,12 +12511,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] }, "slug": { "type": "string" @@ -13411,12 +12565,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] }, "teamId": { "type": "integer", @@ -13970,9 +13119,7 @@ }, "DiscoveryBase": { "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "error": { "type": "string" @@ -14067,10 +13214,7 @@ "EmbeddedContactPoint": { "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", "type": "object", - "required": [ - "type", - "settings" - ], + "required": ["type", "settings"], "properties": { "disableResolveMessage": { "type": "boolean", @@ -14158,9 +13302,7 @@ }, "ErrorResponseBody": { "type": "object", - "required": [ - "message" - ], + "required": ["message"], "properties": { "error": { "description": "Error An optional detailed description of the actual error. Only included if running in developer mode.", @@ -14937,11 +14079,7 @@ }, "exec_err_state": { "type": "string", - "enum": [ - "OK", - "Alerting", - "Error" - ] + "enum": ["OK", "Alerting", "Error"] }, "id": { "type": "integer", @@ -14963,11 +14101,7 @@ }, "no_data_state": { "type": "string", - "enum": [ - "Alerting", - "NoData", - "OK" - ] + "enum": ["Alerting", "NoData", "OK"] }, "orgId": { "type": "integer", @@ -15028,11 +14162,7 @@ "properties": { "alertmanagersChoice": { "type": "string", - "enum": [ - "all", - "internal", - "external" - ] + "enum": ["all", "internal", "external"] } } }, @@ -15061,12 +14191,7 @@ }, "GettableStatus": { "type": "object", - "required": [ - "cluster", - "config", - "uptime", - "versionInfo" - ], + "required": ["cluster", "config", "uptime", "versionInfo"], "properties": { "cluster": { "$ref": "#/definitions/clusterStatus" @@ -16034,11 +15159,7 @@ }, "MetricRequest": { "type": "object", - "required": [ - "from", - "to", - "queries" - ], + "required": ["from", "to", "queries"], "properties": { "debug": { "type": "boolean" @@ -16646,10 +15767,7 @@ "description": "Kind of element to create, Use 1 for library panels or 2 for c.\nDescription:\n1 - library panels\n2 - library variables", "type": "integer", "format": "int64", - "enum": [ - 1, - 2 - ] + "enum": [1, 2] }, "model": { "description": "The JSON model for the library element.", @@ -16695,17 +15813,11 @@ }, "theme": { "type": "string", - "enum": [ - "light", - "dark" - ] + "enum": ["light", "dark"] }, "timezone": { "type": "string", - "enum": [ - "utc", - "browser" - ] + "enum": ["utc", "browser"] }, "weekStart": { "type": "string" @@ -16912,9 +16024,7 @@ }, "PostAnnotationsCmd": { "type": "object", - "required": [ - "text" - ], + "required": ["text"], "properties": { "dashboardId": { "type": "integer", @@ -17131,9 +16241,7 @@ }, "PostableExtendedRuleNodeExtended": { "type": "object", - "required": [ - "rule" - ], + "required": ["rule"], "properties": { "folderTitle": { "type": "string", @@ -17203,22 +16311,14 @@ }, "exec_err_state": { "type": "string", - "enum": [ - "OK", - "Alerting", - "Error" - ] + "enum": ["OK", "Alerting", "Error"] }, "is_paused": { "type": "boolean" }, "no_data_state": { "type": "string", - "enum": [ - "Alerting", - "NoData", - "OK" - ] + "enum": ["Alerting", "NoData", "OK"] }, "title": { "type": "string" @@ -17233,11 +16333,7 @@ "properties": { "alertmanagersChoice": { "type": "string", - "enum": [ - "all", - "internal", - "external" - ] + "enum": ["all", "internal", "external"] } } }, @@ -17328,10 +16424,7 @@ "conditions": [ { "evaluator": { - "params": [ - 0, - 0 - ], + "params": [0, 0], "type": "gt" }, "operator": { @@ -17369,11 +16462,7 @@ }, "execErrState": { "type": "string", - "enum": [ - "OK", - "Alerting", - "Error" - ] + "enum": ["OK", "Alerting", "Error"] }, "folderUID": { "type": "string", @@ -17401,11 +16490,7 @@ }, "noDataState": { "type": "string", - "enum": [ - "Alerting", - "NoData", - "OK" - ] + "enum": ["Alerting", "NoData", "OK"] }, "orgID": { "type": "integer", @@ -18287,12 +17372,7 @@ "Rule": { "description": "adapted from cortex", "type": "object", - "required": [ - "name", - "query", - "health", - "type" - ], + "required": ["name", "query", "health", "type"], "properties": { "evaluationTime": { "type": "number", @@ -18324,9 +17404,7 @@ }, "RuleDiscovery": { "type": "object", - "required": [ - "groups" - ], + "required": ["groups"], "properties": { "groups": { "type": "array", @@ -18345,12 +17423,7 @@ }, "RuleGroup": { "type": "object", - "required": [ - "name", - "file", - "rules", - "interval" - ], + "required": ["name", "file", "rules", "interval"], "properties": { "evaluationTime": { "type": "number", @@ -18411,9 +17484,7 @@ }, "RuleResponse": { "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "data": { "$ref": "#/definitions/RuleDiscovery" @@ -19428,12 +18499,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] }, "status": { "$ref": "#/definitions/TempUserStatus" @@ -19569,10 +18635,7 @@ "kind": { "description": "Kind of template error that occurred.", "type": "string", - "enum": [ - "invalid_template", - "execution_error" - ] + "enum": ["invalid_template", "execution_error"] }, "message": { "description": "Error message.", @@ -19860,10 +18923,7 @@ }, "type": { "type": "string", - "enum": [ - "regex", - "logfmt" - ] + "enum": ["regex", "logfmt"] } } }, @@ -20194,12 +19254,7 @@ "properties": { "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] } } }, @@ -20249,18 +19304,11 @@ }, "theme": { "type": "string", - "enum": [ - "light", - "dark", - "system" - ] + "enum": ["light", "dark", "system"] }, "timezone": { "type": "string", - "enum": [ - "utc", - "browser" - ] + "enum": ["utc", "browser"] }, "weekStart": { "type": "string" @@ -20349,12 +19397,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] }, "serviceAccountId": { "type": "integer", @@ -20429,12 +19472,7 @@ }, "role": { "type": "string", - "enum": [ - "None", - "Viewer", - "Editor", - "Admin" - ] + "enum": ["None", "Viewer", "Editor", "Admin"] } } }, @@ -20755,9 +19793,7 @@ "alert": { "description": "Alert alert", "type": "object", - "required": [ - "labels" - ], + "required": ["labels"], "properties": { "generatorURL": { "description": "generator URL\nFormat: uri", @@ -20771,11 +19807,7 @@ }, "alertGroup": { "type": "object", - "required": [ - "alerts", - "labels", - "receiver" - ], + "required": ["alerts", "labels", "receiver"], "properties": { "alerts": { "description": "alerts", @@ -20802,11 +19834,7 @@ "alertStatus": { "description": "AlertStatus alert status", "type": "object", - "required": [ - "inhibitedBy", - "silencedBy", - "state" - ], + "required": ["inhibitedBy", "silencedBy", "state"], "properties": { "inhibitedBy": { "description": "inhibited by", @@ -20825,18 +19853,14 @@ "state": { "description": "state", "type": "string", - "enum": [ - "[unprocessed active suppressed]" - ] + "enum": ["[unprocessed active suppressed]"] } } }, "alertmanagerConfig": { "description": "AlertmanagerConfig alertmanager config", "type": "object", - "required": [ - "original" - ], + "required": ["original"], "properties": { "original": { "description": "original", @@ -20847,12 +19871,7 @@ "alertmanagerStatus": { "description": "AlertmanagerStatus alertmanager status", "type": "object", - "required": [ - "cluster", - "config", - "uptime", - "versionInfo" - ], + "required": ["cluster", "config", "uptime", "versionInfo"], "properties": { "cluster": { "$ref": "#/definitions/clusterStatus" @@ -20873,9 +19892,7 @@ "clusterStatus": { "description": "ClusterStatus cluster status", "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "name": { "description": "name", @@ -20891,25 +19908,14 @@ "status": { "description": "status", "type": "string", - "enum": [ - "[ready settling disabled]" - ] + "enum": ["[ready settling disabled]"] } } }, "gettableAlert": { "description": "GettableAlert gettable alert", "type": "object", - "required": [ - "labels", - "annotations", - "endsAt", - "fingerprint", - "receivers", - "startsAt", - "status", - "updatedAt" - ], + "required": ["labels", "annotations", "endsAt", "fingerprint", "receivers", "startsAt", "status", "updatedAt"], "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -20961,16 +19967,7 @@ }, "gettableSilence": { "type": "object", - "required": [ - "comment", - "createdBy", - "endsAt", - "matchers", - "startsAt", - "id", - "status", - "updatedAt" - ], + "required": ["comment", "createdBy", "endsAt", "matchers", "startsAt", "id", "status", "updatedAt"], "properties": { "comment": { "description": "comment", @@ -21015,10 +20012,7 @@ }, "integration": { "type": "object", - "required": [ - "name", - "sendResolved" - ], + "required": ["name", "sendResolved"], "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -21053,11 +20047,7 @@ "matcher": { "description": "Matcher matcher", "type": "object", - "required": [ - "isRegex", - "name", - "value" - ], + "required": ["isRegex", "name", "value"], "properties": { "isEqual": { "description": "is equal", @@ -21095,10 +20085,7 @@ "peerStatus": { "description": "PeerStatus peer status", "type": "object", - "required": [ - "address", - "name" - ], + "required": ["address", "name"], "properties": { "address": { "description": "address", @@ -21122,9 +20109,7 @@ "postableAlert": { "description": "PostableAlert postable alert", "type": "object", - "required": [ - "labels" - ], + "required": ["labels"], "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -21159,13 +20144,7 @@ "postableSilence": { "description": "PostableSilence postable silence", "type": "object", - "required": [ - "comment", - "createdBy", - "endsAt", - "matchers", - "startsAt" - ], + "required": ["comment", "createdBy", "endsAt", "matchers", "startsAt"], "properties": { "comment": { "description": "comment", @@ -21197,10 +20176,7 @@ "publicError": { "description": "PublicError is derived from Error and only contains information\navailable to the end user.", "type": "object", - "required": [ - "statusCode", - "messageId" - ], + "required": ["statusCode", "messageId"], "properties": { "extra": { "description": "Extra Additional information about the error", @@ -21225,11 +20201,7 @@ "receiver": { "description": "Receiver receiver", "type": "object", - "required": [ - "active", - "integrations", - "name" - ], + "required": ["active", "integrations", "name"], "properties": { "active": { "description": "active", @@ -21251,13 +20223,7 @@ "silence": { "description": "Silence silence", "type": "object", - "required": [ - "comment", - "createdBy", - "endsAt", - "matchers", - "startsAt" - ], + "required": ["comment", "createdBy", "endsAt", "matchers", "startsAt"], "properties": { "comment": { "description": "comment", @@ -21285,30 +20251,19 @@ "silenceStatus": { "description": "SilenceStatus silence status", "type": "object", - "required": [ - "state" - ], + "required": ["state"], "properties": { "state": { "description": "state", "type": "string", - "enum": [ - "[expired active pending]" - ] + "enum": ["[expired active pending]"] } } }, "versionInfo": { "description": "VersionInfo version info", "type": "object", - "required": [ - "branch", - "buildDate", - "buildUser", - "goVersion", - "revision", - "version" - ], + "required": ["branch", "buildDate", "buildUser", "goVersion", "revision", "version"], "properties": { "branch": { "description": "branch", @@ -21488,12 +20443,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id", - "name", - "message", - "datasource" - ], + "required": ["id", "name", "message", "datasource"], "properties": { "datasource": { "$ref": "#/definitions/DataSource" @@ -21521,10 +20471,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "orgId", - "message" - ], + "required": ["orgId", "message"], "properties": { "message": { "description": "Message Message of the created org.", @@ -21625,10 +20572,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id", - "message" - ], + "required": ["id", "message"], "properties": { "id": { "description": "ID Identifier of the deleted notification channel.", @@ -21653,11 +20597,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id", - "title", - "message" - ], + "required": ["id", "title", "message"], "properties": { "id": { "description": "ID Identifier of the deleted dashboard.", @@ -21682,10 +20622,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id", - "message" - ], + "required": ["id", "message"], "properties": { "id": { "description": "ID Identifier of the deleted data source.", @@ -21705,11 +20642,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id", - "title", - "message" - ], + "required": ["id", "title", "message"], "properties": { "id": { "description": "ID Identifier of the deleted folder.", @@ -21917,9 +20850,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id" - ], + "required": ["id"], "properties": { "id": { "description": "ID Identifier of the data source.", @@ -22372,10 +21303,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "alertId", - "message" - ], + "required": ["alertId", "message"], "properties": { "alertId": { "type": "integer", @@ -22395,10 +21323,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "alertsAffected", - "message" - ], + "required": ["alertsAffected", "message"], "properties": { "alertsAffected": { "description": "AlertsAffected is the number of the affected alerts.", @@ -22425,10 +21350,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "id", - "message" - ], + "required": ["id", "message"], "properties": { "id": { "description": "ID Identifier of the created annotation.", @@ -22447,14 +21369,7 @@ "description": "(empty)", "schema": { "type": "object", - "required": [ - "status", - "title", - "version", - "id", - "uid", - "url" - ], + "required": ["status", "title", "version", "id", "uid", "url"], "properties": { "folderUid": { "description": "FolderUID The unique identifier (uid) of the folder the dashboard belongs to.", @@ -22831,4 +21746,4 @@ "name": "service_accounts" } ] -} \ No newline at end of file +} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx index dff065c4242..fae86e4e576 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx @@ -5,8 +5,7 @@ import { useLocation } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { CustomScrollbar, Icon, IconButton, useStyles2 } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; +import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { t } from 'app/core/internationalization'; import { useSelector } from 'app/types'; diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/public/app/core/components/GraphNG/GraphNG.tsx similarity index 92% rename from packages/grafana-ui/src/components/GraphNG/GraphNG.tsx rename to public/app/core/components/GraphNG/GraphNG.tsx index d75ab59252b..9ebfa499c81 100644 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/public/app/core/components/GraphNG/GraphNG.tsx @@ -16,24 +16,16 @@ import { TimeZone, } from '@grafana/data'; import { VizLegendOptions } from '@grafana/schema'; - -import { Themeable2 } from '../../types'; -import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; -import { VizLayout } from '../VizLayout/VizLayout'; -import { UPlotChart } from '../uPlot/Plot'; -import { AxisProps } from '../uPlot/config/UPlotAxisBuilder'; -import { Renderers, UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; -import { ScaleProps } from '../uPlot/config/UPlotScaleBuilder'; -import { findMidPointYPosition, pluginLog } from '../uPlot/utils'; +import { Themeable2, PanelContext, PanelContextRoot, VizLayout } from '@grafana/ui'; +import { UPlotChart } from '@grafana/ui/src/components/uPlot/Plot'; +import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; +import { Renderers, UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; +import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; +import { findMidPointYPosition, pluginLog } from '@grafana/ui/src/components/uPlot/utils'; import { GraphNGLegendEvent, XYFieldMatchers } from './types'; import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; -/** - * @internal -- not a public API - */ -export const FIXED_UNIT = '__fixed'; - /** * @internal -- not a public API */ diff --git a/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..09f70e81c44 --- /dev/null +++ b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -0,0 +1,245 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` +{ + "axes": [ + { + "filter": undefined, + "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "gap": 5, + "grid": { + "show": true, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "incrs": undefined, + "labelGap": 0, + "rotate": undefined, + "scale": "x", + "show": true, + "side": 2, + "size": [Function], + "space": [Function], + "splits": undefined, + "stroke": "rgb(204, 204, 220)", + "ticks": { + "show": true, + "size": 4, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "timeZone": "utc", + "values": [Function], + }, + { + "filter": undefined, + "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "gap": 5, + "grid": { + "show": true, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "incrs": undefined, + "labelGap": 0, + "rotate": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "side": 3, + "size": [Function], + "space": [Function], + "splits": undefined, + "stroke": "rgb(204, 204, 220)", + "ticks": { + "show": false, + "size": 4, + "stroke": "rgb(204, 204, 220)", + "width": 1, + }, + "timeZone": undefined, + "values": [Function], + }, + ], + "cursor": { + "dataIdx": [Function], + "drag": { + "setScale": false, + }, + "focus": { + "prox": 30, + }, + "points": { + "fill": [Function], + "size": [Function], + "stroke": [Function], + "width": [Function], + }, + "sync": { + "filters": { + "pub": [Function], + }, + "key": "__global_", + "scales": [ + "x", + "__fixed/na-na/na-na/auto/linear/na/number", + ], + }, + }, + "focus": { + "alpha": 1, + }, + "hooks": {}, + "legend": { + "show": false, + }, + "mode": 1, + "ms": 1, + "padding": [ + [Function], + [Function], + [Function], + [Function], + ], + "scales": { + "__fixed/na-na/na-na/auto/linear/na/number": { + "asinh": undefined, + "auto": true, + "dir": 1, + "distr": 1, + "log": undefined, + "ori": 1, + "range": [Function], + "time": undefined, + }, + "x": { + "auto": false, + "dir": 1, + "ori": 0, + "range": [Function], + "time": true, + }, + }, + "select": undefined, + "series": [ + { + "value": [Function], + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/components/GraphNG/hooks.ts b/public/app/core/components/GraphNG/hooks.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/hooks.ts rename to public/app/core/components/GraphNG/hooks.ts diff --git a/packages/grafana-ui/src/components/GraphNG/types.ts b/public/app/core/components/GraphNG/types.ts similarity index 85% rename from packages/grafana-ui/src/components/GraphNG/types.ts rename to public/app/core/components/GraphNG/types.ts index 534cd9edf4d..e642fbd8aac 100644 --- a/packages/grafana-ui/src/components/GraphNG/types.ts +++ b/public/app/core/components/GraphNG/types.ts @@ -1,6 +1,5 @@ import { DataFrameFieldIndex, FieldMatcher } from '@grafana/data'; - -import { SeriesVisibilityChangeMode } from '../PanelChrome'; +import { SeriesVisibilityChangeMode } from '@grafana/ui'; /** * Event being triggered when the user interact with the Graph legend. diff --git a/public/app/core/components/GraphNG/utils.test.ts b/public/app/core/components/GraphNG/utils.test.ts new file mode 100644 index 00000000000..9675cd7ca56 --- /dev/null +++ b/public/app/core/components/GraphNG/utils.test.ts @@ -0,0 +1,522 @@ +import { + createTheme, + DashboardCursorSync, + DataFrame, + DefaultTimeZone, + EventBusSrv, + FieldColorModeId, + FieldConfig, + FieldMatcherID, + fieldMatchers, + FieldType, + getDefaultTimeRange, + MutableDataFrame, +} from '@grafana/data'; +import { + BarAlignment, + GraphDrawStyle, + GraphFieldConfig, + GraphGradientMode, + LineInterpolation, + VisibilityMode, + StackingMode, +} from '@grafana/schema'; + +import { preparePlotConfigBuilder } from '../TimeSeries/utils'; + +import { preparePlotFrame } from './utils'; + +function mockDataFrame() { + const df1 = new MutableDataFrame({ + refId: 'A', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 3] }], + }); + const df2 = new MutableDataFrame({ + refId: 'B', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }], + }); + + const f1Config: FieldConfig = { + displayName: 'Metric 1', + color: { + mode: FieldColorModeId.Fixed, + }, + decimals: 2, + custom: { + drawStyle: GraphDrawStyle.Line, + gradientMode: GraphGradientMode.Opacity, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + spanNulls: false, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'A', + mode: StackingMode.Normal, + }, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + color: { + mode: FieldColorModeId.Fixed, + }, + decimals: 2, + custom: { + drawStyle: GraphDrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'A', + mode: StackingMode.Normal, + }, + }, + }; + + const f3Config: FieldConfig = { + displayName: 'Metric 3', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.Line, + gradientMode: GraphGradientMode.Opacity, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + spanNulls: false, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.Normal, + }, + }, + }; + const f4Config: FieldConfig = { + displayName: 'Metric 4', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.Normal, + }, + }, + }; + const f5Config: FieldConfig = { + displayName: 'Metric 4', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.None, + }, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + }); + df2.addField({ + name: 'metric3', + type: FieldType.number, + config: f3Config, + }); + df2.addField({ + name: 'metric4', + type: FieldType.number, + config: f4Config, + }); + df2.addField({ + name: 'metric5', + type: FieldType.number, + config: f5Config, + }); + + return preparePlotFrame([df1, df2], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); +} + +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + DefaultTimeZone: 'utc', +})); + +describe('GraphNG utils', () => { + test('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + const result = preparePlotConfigBuilder({ + frame: frame!, + theme: createTheme(), + timeZones: [DefaultTimeZone], + getTimeRange: getDefaultTimeRange, + eventBus: new EventBusSrv(), + sync: () => DashboardCursorSync.Tooltip, + allFrames: [frame!], + }).getConfig(); + expect(result).toMatchSnapshot(); + }); + + test('preparePlotFrame appends min bar spaced nulls when > 1 bar series', () => { + const df1: DataFrame = { + name: 'A', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [1, 2, 4, 6, 100], // should find smallest delta === 1 from here + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: [1, 1, 1, 1, 1], + }, + ], + }; + + const df2: DataFrame = { + name: 'B', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [30, 40, 50, 90, 100], // should be appended with two smallest-delta increments + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: [2, 2, 2, 2, 2], // bar series should be appended with nulls + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: [3, 3, 3, 3, 3], // line series should be appended with undefineds + }, + ], + }; + + const df3: DataFrame = { + name: 'C', + length: 2, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [1, 1.1], // should not trip up on smaller deltas of non-bars + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: [4, 4], + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + hideFrom: { + viz: true, // should ignore hidden bar series + }, + }, + }, + values: [4, 4], + }, + ], + }; + + let aligndFrame = preparePlotFrame([df1, df2, df3], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); + + expect(aligndFrame).toMatchInlineSnapshot(` + { + "fields": [ + { + "config": {}, + "name": "time", + "state": { + "nullThresholdApplied": true, + "origin": { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + "type": "time", + "values": [ + 1, + 1.1, + 2, + 4, + 6, + 30, + 40, + 50, + 90, + 100, + 101, + 102, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": { + "name": "A", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + "type": "number", + "values": [ + 1, + undefined, + 1, + 1, + 1, + undefined, + undefined, + undefined, + undefined, + 1, + null, + null, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": { + "name": "B", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 1, + }, + }, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + undefined, + 2, + 2, + 2, + 2, + 2, + null, + null, + ], + }, + { + "config": { + "custom": { + "drawStyle": "line", + }, + }, + "labels": { + "name": "B", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 2, + "frameIndex": 1, + }, + }, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + undefined, + 3, + 3, + 3, + 3, + 3, + undefined, + undefined, + ], + }, + { + "config": { + "custom": { + "drawStyle": "line", + }, + }, + "labels": { + "name": "C", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 2, + }, + }, + "type": "number", + "values": [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "hideFrom": { + "viz": true, + }, + }, + }, + "labels": { + "name": "C", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 2, + "frameIndex": 2, + }, + }, + "type": "number", + "values": [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + ], + "length": 12, + } + `); + }); +}); diff --git a/public/app/core/components/GraphNG/utils.ts b/public/app/core/components/GraphNG/utils.ts new file mode 100644 index 00000000000..030ea9722db --- /dev/null +++ b/public/app/core/components/GraphNG/utils.ts @@ -0,0 +1,140 @@ +import { DataFrame, Field, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; +import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; +import { nullToUndefThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullToUndefThreshold'; +import { GraphDrawStyle } from '@grafana/schema'; + +import { XYFieldMatchers } from './types'; + +function isVisibleBarField(f: Field) { + return ( + f.type === FieldType.number && f.config.custom?.drawStyle === GraphDrawStyle.Bars && !f.config.custom?.hideFrom?.viz + ); +} + +export function getRefField(frame: DataFrame, refFieldName?: string | null) { + return frame.fields.find((field) => { + // note: getFieldDisplayName() would require full DF[] + return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time; + }); +} + +// will mutate the DataFrame's fields' values +function applySpanNullsThresholds(frame: DataFrame, refFieldName?: string | null) { + const refField = getRefField(frame, refFieldName); + + let refValues = refField?.values; + + for (let i = 0; i < frame.fields.length; i++) { + let field = frame.fields[i]; + + if (field === refField || isVisibleBarField(field)) { + continue; + } + + let spanNulls = field.config.custom?.spanNulls; + + if (typeof spanNulls === 'number') { + if (spanNulls !== -1 && refValues) { + field.values = nullToUndefThreshold(refValues, field.values, spanNulls); + } + } + } + + return frame; +} + +export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) { + let xField: Field; + loop: for (let frame of frames) { + for (let field of frame.fields) { + if (dimFields.x(field, frame, frames)) { + xField = field; + break loop; + } + } + } + + // apply null insertions at interval + frames = frames.map((frame) => { + if (!xField?.state?.nullThresholdApplied) { + return applyNullInsertThreshold({ + frame, + refFieldName: xField.name, + refFieldPseudoMin: timeRange?.from.valueOf(), + refFieldPseudoMax: timeRange?.to.valueOf(), + }); + } else { + return frame; + } + }); + + let numBarSeries = 0; + + frames.forEach((frame) => { + frame.fields.forEach((f) => { + if (isVisibleBarField(f)) { + // prevent minesweeper-expansion of nulls (gaps) when joining bars + // since bar width is determined from the minimum distance between non-undefined values + // (this strategy will still retain any original pre-join nulls, though) + f.config.custom = { + ...f.config.custom, + spanNulls: -1, + }; + + numBarSeries++; + } + }); + }); + + // to make bar widths of all series uniform (equal to narrowest bar series), find smallest distance between x points + let minXDelta = Infinity; + + if (numBarSeries > 1) { + frames.forEach((frame) => { + if (!frame.fields.some(isVisibleBarField)) { + return; + } + + const xVals = xField.values; + + for (let i = 0; i < xVals.length; i++) { + if (i > 0) { + minXDelta = Math.min(minXDelta, xVals[i] - xVals[i - 1]); + } + } + }); + } + + let alignedFrame = outerJoinDataFrames({ + frames, + joinBy: dimFields.x, + keep: dimFields.y, + keepOriginIndices: true, + }); + + if (alignedFrame) { + alignedFrame = applySpanNullsThresholds(alignedFrame, xField!.name); + + // append 2 null vals at minXDelta to bar series + if (minXDelta !== Infinity) { + alignedFrame.fields.forEach((f, fi) => { + let vals = f.values; + + if (fi === 0) { + let lastVal = vals[vals.length - 1]; + vals.push(lastVal + minXDelta, lastVal + 2 * minXDelta); + } else if (isVisibleBarField(f)) { + vals.push(null, null); + } else { + vals.push(undefined, undefined); + } + }); + + alignedFrame.length += 2; + } + + return alignedFrame; + } + + return null; +} diff --git a/public/app/core/components/TimeSeries/TimeSeries.tsx b/public/app/core/components/TimeSeries/TimeSeries.tsx new file mode 100644 index 00000000000..4441bca98f3 --- /dev/null +++ b/public/app/core/components/TimeSeries/TimeSeries.tsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; + +import { DataFrame, TimeRange } from '@grafana/data'; +import { PanelContextRoot } from '@grafana/ui/src/components/PanelChrome/PanelContext'; +import { hasVisibleLegendSeries, PlotLegend } from '@grafana/ui/src/components/uPlot/PlotLegend'; +import { UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; +import { withTheme2 } from '@grafana/ui/src/themes/ThemeContext'; + +import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; + +import { preparePlotConfigBuilder } from './utils'; + +const propsToDiff: Array = ['legend', 'options', 'theme']; + +type TimeSeriesProps = Omit; + +export class UnthemedTimeSeries extends Component { + static contextType = PanelContextRoot; + declare context: React.ContextType; + + prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + const { eventBus, eventsScope, sync } = this.context; + const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; + + return preparePlotConfigBuilder({ + frame: alignedFrame, + theme, + timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], + getTimeRange, + eventBus, + sync, + allFrames, + renderers, + tweakScale, + tweakAxis, + eventsScope, + }); + }; + + renderLegend = (config: UPlotConfigBuilder) => { + const { legend, frames } = this.props; + + if (!config || (legend && !legend.showLegend) || !hasVisibleLegendSeries(config, frames)) { + return null; + } + + return ; + }; + + render() { + return ( + + ); + } +} + +export const TimeSeries = withTheme2(UnthemedTimeSeries); +TimeSeries.displayName = 'TimeSeries'; diff --git a/public/app/core/components/TimeSeries/utils.test.ts b/public/app/core/components/TimeSeries/utils.test.ts new file mode 100644 index 00000000000..583358c7c4c --- /dev/null +++ b/public/app/core/components/TimeSeries/utils.test.ts @@ -0,0 +1,274 @@ +import { EventBus, FieldType } from '@grafana/data'; +import { getTheme } from '@grafana/ui'; + +import { preparePlotConfigBuilder } from './utils'; + +describe('when fill below to option is used', () => { + let eventBus: EventBus; + // eslint-disable-next-line + let renderers: any[]; + // eslint-disable-next-line + let tests: any; + + beforeEach(() => { + eventBus = { + publish: jest.fn(), + getStream: jest.fn(), + subscribe: jest.fn(), + removeAllListeners: jest.fn(), + newScopedBus: jest.fn(), + }; + renderers = []; + + tests = [ + { + alignedFrame: { + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test1', custom: { fillBelowTo: 'Test2' }, min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { displayNameFromDS: 'Test2', min: 0, max: 100 }, + values: [4, 5, 6], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test2', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + allFrames: [ + { + name: 'Test1', + refId: 'A', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test1', custom: { fillBelowTo: 'Test2' }, min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + ], + length: 2, + }, + { + name: 'Test2', + refId: 'B', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 1 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test2', min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test2', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 2, + }, + ], + expectedResult: 1, + }, + { + alignedFrame: { + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { custom: { fillBelowTo: 'below_value1' } }, + values: [1, 2, 3], + name: 'value1', + state: { multipleFrames: true, displayName: 'value1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { custom: { fillBelowTo: 'below_value2' } }, + values: [4, 5, 6], + name: 'value2', + state: { multipleFrames: true, displayName: 'value2', origin: { fieldIndex: 2, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value1', + state: { multipleFrames: true, displayName: 'below_value1', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value2', + state: { multipleFrames: true, displayName: 'below_value2', origin: { fieldIndex: 2, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 5, + }, + allFrames: [ + { + refId: 'A', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { custom: { fillBelowTo: 'below_value1' } }, + values: [1, 2, 3], + name: 'value1', + state: { multipleFrames: true, displayName: 'value1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { custom: { fillBelowTo: 'below_value2' } }, + values: [4, 5, 6], + name: 'value2', + state: { multipleFrames: true, displayName: 'value2', origin: { fieldIndex: 2, frameIndex: 0 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + { + refId: 'B', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 1 } }, + type: FieldType.time, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value1', + state: { multipleFrames: true, displayName: 'below_value1', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value2', + state: { multipleFrames: true, displayName: 'below_value2', origin: { fieldIndex: 2, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + ], + expectedResult: 2, + }, + ]; + }); + + it('should verify if fill below to is set then builder bands are set', () => { + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); + + it('should verify if fill below to is not set then builder bands are empty', () => { + tests[0].alignedFrame.fields[1].config.custom.fillBelowTo = undefined; + tests[0].allFrames[0].fields[1].config.custom.fillBelowTo = undefined; + tests[1].alignedFrame.fields[1].config.custom.fillBelowTo = undefined; + tests[1].alignedFrame.fields[2].config.custom.fillBelowTo = undefined; + tests[1].allFrames[0].fields[1].config.custom.fillBelowTo = undefined; + tests[1].allFrames[0].fields[2].config.custom.fillBelowTo = undefined; + tests[0].expectedResult = 0; + tests[1].expectedResult = 0; + + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); + + it('should verify if fill below to is set and field name is overriden then builder bands are set', () => { + tests[0].alignedFrame.fields[2].config.displayName = 'newName'; + tests[0].alignedFrame.fields[2].state.displayName = 'newName'; + tests[0].allFrames[1].fields[1].config.displayName = 'newName'; + tests[0].allFrames[1].fields[1].state.displayName = 'newName'; + + tests[1].alignedFrame.fields[3].config.displayName = 'newName'; + tests[1].alignedFrame.fields[3].state.displayName = 'newName'; + tests[1].allFrames[1].fields[1].config.displayName = 'newName'; + tests[1].allFrames[1].fields[1].state.displayName = 'newName'; + + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); +}); diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts new file mode 100644 index 00000000000..36da06299da --- /dev/null +++ b/public/app/core/components/TimeSeries/utils.ts @@ -0,0 +1,669 @@ +import { isNumber } from 'lodash'; +import uPlot from 'uplot'; + +import { + DashboardCursorSync, + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + DataHoverPayload, + FieldConfig, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldSeriesColor, + getFieldDisplayName, + getDisplayProcessor, + FieldColorModeId, + DecimalCount, +} from '@grafana/data'; +// eslint-disable-next-line import/order +import { + AxisPlacement, + GraphDrawStyle, + GraphFieldConfig, + GraphTresholdsStyleMode, + VisibilityMode, + ScaleDirection, + ScaleOrientation, + StackingMode, + GraphTransform, + AxisColorMode, + GraphGradientMode, +} from '@grafana/schema'; + +// unit lookup needed to determine if we want power-of-2 or power-of-10 axis ticks +// see categories.ts is @grafana/data +const IEC_UNITS = new Set([ + 'bytes', + 'bits', + 'kbytes', + 'mbytes', + 'gbytes', + 'tbytes', + 'pbytes', + 'binBps', + 'binbps', + 'KiBs', + 'Kibits', + 'MiBs', + 'Mibits', + 'GiBs', + 'Gibits', + 'TiBs', + 'Tibits', + 'PiBs', + 'Pibits', +]); + +const BIN_INCRS = Array(53); + +for (let i = 0; i < BIN_INCRS.length; i++) { + BIN_INCRS[i] = 2 ** i; +} + +import { UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; +import { getScaleGradientFn } from '@grafana/ui/src/components/uPlot/config/gradientFills'; +import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal'; +import { getStackingGroups, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils'; + +const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals)); + +const defaultConfig: GraphFieldConfig = { + drawStyle: GraphDrawStyle.Line, + showPoints: VisibilityMode.Auto, + axisPlacement: AxisPlacement.Auto, +}; + +export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ + sync?: () => DashboardCursorSync; +}> = ({ + frame, + theme, + timeZones, + getTimeRange, + eventBus, + sync, + allFrames, + renderers, + tweakScale = (opts) => opts, + tweakAxis = (opts) => opts, + eventsScope = '__global_', +}) => { + const builder = new UPlotConfigBuilder(timeZones[0]); + + let alignedFrame: DataFrame; + + builder.setPrepData((frames) => { + // cache alignedFrame + alignedFrame = frames[0]; + + return preparePlotData2(frames[0], builder.getStackingGroups()); + }); + + // X is the first field in the aligned frame + const xField = frame.fields[0]; + if (!xField) { + return builder; // empty frame with no options + } + + const xScaleKey = 'x'; + let xScaleUnit = '_x'; + let yScaleKey = ''; + + const xFieldAxisPlacement = + xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden; + const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden; + + if (xField.type === FieldType.time) { + xScaleUnit = 'time'; + builder.addScale({ + scaleKey: xScaleKey, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + isTime: true, + range: () => { + const r = getTimeRange(); + return [r.from.valueOf(), r.to.valueOf()]; + }, + }); + + // filters first 2 ticks to make space for timezone labels + const filterTicks: uPlot.Axis.Filter | undefined = + timeZones.length > 1 + ? (u, splits) => { + return splits.map((v, i) => (i < 2 ? null : v)); + } + : undefined; + + for (let i = 0; i < timeZones.length; i++) { + const timeZone = timeZones[i]; + builder.addAxis({ + scaleKey: xScaleKey, + isTime: true, + placement: xFieldAxisPlacement, + show: xFieldAxisShow, + label: xField.config.custom?.axisLabel, + timeZone, + theme, + grid: { show: i === 0 && xField.config.custom?.axisGridShow }, + filter: filterTicks, + }); + } + + // render timezone labels + if (timeZones.length > 1) { + builder.addHook('drawAxes', (u: uPlot) => { + u.ctx.save(); + + u.ctx.fillStyle = theme.colors.text.primary; + u.ctx.textAlign = 'left'; + u.ctx.textBaseline = 'bottom'; + + let i = 0; + u.axes.forEach((a) => { + if (a.side === 2) { + //@ts-ignore + let cssBaseline: number = a._pos + a._size; + u.ctx.fillText(timeZones[i], u.bbox.left, cssBaseline * uPlot.pxRatio); + i++; + } + }); + + u.ctx.restore(); + }); + } + } else { + // Not time! + if (xField.config.unit) { + xScaleUnit = xField.config.unit; + } + + builder.addScale({ + scaleKey: xScaleKey, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + range: (u, dataMin, dataMax) => [xField.config.min ?? dataMin, xField.config.max ?? dataMax], + }); + + builder.addAxis({ + scaleKey: xScaleKey, + placement: xFieldAxisPlacement, + show: xFieldAxisShow, + label: xField.config.custom?.axisLabel, + theme, + grid: { show: xField.config.custom?.axisGridShow }, + formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), + }); + } + + let customRenderedFields = + renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? []; + + let indexByName: Map | undefined; + + for (let i = 1; i < frame.fields.length; i++) { + const field = frame.fields[i]; + + const config: FieldConfig = { + ...field.config, + custom: { + ...defaultConfig, + ...field.config.custom, + }, + }; + + const customConfig: GraphFieldConfig = config.custom!; + + if (field === xField || (field.type !== FieldType.number && field.type !== FieldType.enum)) { + continue; + } + + let fmt = field.display ?? defaultFormatter; + if (field.config.custom?.stacking?.mode === StackingMode.Percent) { + fmt = getDisplayProcessor({ + field: { + ...field, + config: { + ...field.config, + unit: 'percentunit', + }, + }, + theme, + }); + } + const scaleKey = buildScaleKey(config, field.type); + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale( + tweakScale( + { + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + distribution: customConfig.scaleDistribution?.type, + log: customConfig.scaleDistribution?.log, + linearThreshold: customConfig.scaleDistribution?.linearThreshold, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + centeredZero: customConfig.axisCenteredZero, + range: + customConfig.stacking?.mode === StackingMode.Percent + ? (u: uPlot, dataMin: number, dataMax: number) => { + dataMin = dataMin < 0 ? -1 : 0; + dataMax = dataMax > 0 ? 1 : 0; + return [dataMin, dataMax]; + } + : field.type === FieldType.enum + ? (u: uPlot, dataMin: number, dataMax: number) => { + // this is the exhaustive enum (stable) + let len = field.config.type!.enum!.text!.length; + + return [-1, len]; + + // these are only values that are present + // return [dataMin - 1, dataMax + 1] + } + : undefined, + decimals: field.config.decimals, + }, + field + ) + ); + + if (!yScaleKey) { + yScaleKey = scaleKey; + } + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let axisColor: uPlot.Axis.Stroke | undefined; + + if (customConfig.axisColorMode === AxisColorMode.Series) { + if ( + colorMode.isByValue && + field.config.custom?.gradientMode === GraphGradientMode.Scheme && + colorMode.id === FieldColorModeId.Thresholds + ) { + axisColor = getScaleGradientFn(1, theme, colorMode, field.config.thresholds); + } else { + axisColor = seriesColor; + } + } + + const axisDisplayOptions = { + border: { + show: customConfig.axisBorderShow || false, + width: 1 / devicePixelRatio, + stroke: axisColor || theme.colors.text.primary, + }, + ticks: { + show: customConfig.axisBorderShow || false, + stroke: axisColor || theme.colors.text.primary, + }, + color: axisColor || theme.colors.text.primary, + }; + + let incrs: uPlot.Axis.Incrs | undefined; + + // TODO: these will be dynamic with frame updates, so need to accept getYTickLabels() + let values: uPlot.Axis.Values | undefined; + let splits: uPlot.Axis.Splits | undefined; + + if (IEC_UNITS.has(config.unit!)) { + incrs = BIN_INCRS; + } else if (field.type === FieldType.enum) { + let text = field.config.type!.enum!.text!; + splits = text.map((v: string, i: number) => i); + values = text; + } + + builder.addAxis( + tweakAxis( + { + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement: customConfig.axisPlacement ?? AxisPlacement.Auto, + formatValue: (v, decimals) => formattedValueToString(fmt(v, decimals)), + theme, + grid: { show: customConfig.axisGridShow }, + decimals: field.config.decimals, + distr: customConfig.scaleDistribution?.type, + splits, + values, + incrs, + ...axisDisplayOptions, + }, + field + ) + ); + } + + const showPoints = + customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints; + + let pointsFilter: uPlot.Series.Points.Filter = () => null; + + if (customConfig.spanNulls !== true) { + pointsFilter = (u, seriesIdx, show, gaps) => { + let filtered = []; + + let series = u.series[seriesIdx]; + + if (!show && gaps && gaps.length) { + const [firstIdx, lastIdx] = series.idxs!; + const xData = u.data[0]; + const yData = u.data[seriesIdx]; + const firstPos = Math.round(u.valToPos(xData[firstIdx], 'x', true)); + const lastPos = Math.round(u.valToPos(xData[lastIdx], 'x', true)); + + if (gaps[0][0] === firstPos) { + filtered.push(firstIdx); + } + + // show single points between consecutive gaps that share end/start + for (let i = 0; i < gaps.length; i++) { + let thisGap = gaps[i]; + let nextGap = gaps[i + 1]; + + if (nextGap && thisGap[1] === nextGap[0]) { + // approx when data density is > 1pt/px, since gap start/end pixels are rounded + let approxIdx = u.posToIdx(thisGap[1], true); + + if (yData[approxIdx] == null) { + // scan left/right alternating to find closest index with non-null value + for (let j = 1; j < 100; j++) { + if (yData[approxIdx + j] != null) { + approxIdx += j; + break; + } + if (yData[approxIdx - j] != null) { + approxIdx -= j; + break; + } + } + } + + filtered.push(approxIdx); + } + } + + if (gaps[gaps.length - 1][1] === lastPos) { + filtered.push(lastIdx); + } + } + + return filtered.length ? filtered : null; + }; + } + + let { fillOpacity } = customConfig; + + let pathBuilder: uPlot.Series.PathBuilder | null = null; + let pointsBuilder: uPlot.Series.Points.Show | null = null; + + if (field.state?.origin) { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } + + const originFrame = allFrames[field.state.origin.frameIndex]; + const originField = originFrame?.fields[field.state.origin.fieldIndex]; + + const dispName = getFieldDisplayName(originField ?? field, originFrame, allFrames); + + // disable default renderers + if (customRenderedFields.indexOf(dispName) >= 0) { + pathBuilder = () => null; + pointsBuilder = () => undefined; + } else if (customConfig.transform === GraphTransform.Constant) { + // patch some monkeys! + const defaultBuilder = uPlot.paths!.linear!(); + + pathBuilder = (u, seriesIdx) => { + //eslint-disable-next-line + const _data: any[] = (u as any)._data; // uplot.AlignedData not exposed in types + + // the data we want the line renderer to pull is x at each plot edge with paired flat y values + + const r = getTimeRange(); + let xData = [r.from.valueOf(), r.to.valueOf()]; + let firstY = _data[seriesIdx].find((v: number | null | undefined) => v != null); + let yData = [firstY, firstY]; + let fauxData = _data.slice(); + fauxData[0] = xData; + fauxData[seriesIdx] = yData; + + //eslint-disable-next-line + return defaultBuilder( + { + ...u, + _data: fauxData, + } as any, + seriesIdx, + 0, + 1 + ); + }; + } + + if (customConfig.fillBelowTo) { + const fillBelowToField = frame.fields.find( + (f) => + customConfig.fillBelowTo === f.name || + customConfig.fillBelowTo === f.config?.displayNameFromDS || + customConfig.fillBelowTo === getFieldDisplayName(f, frame, allFrames) + ); + + const fillBelowDispName = fillBelowToField + ? getFieldDisplayName(fillBelowToField, frame, allFrames) + : customConfig.fillBelowTo; + + const t = indexByName.get(dispName); + const b = indexByName.get(fillBelowDispName); + if (isNumber(b) && isNumber(t)) { + builder.addBand({ + series: [t, b], + fill: undefined, // using null will have the band use fill options from `t` + }); + + if (!fillOpacity) { + fillOpacity = 35; // default from flot + } + } else { + fillOpacity = 0; + } + } + } + + let dynamicSeriesColor: ((seriesIdx: number) => string | undefined) | undefined = undefined; + + if (colorMode.id === FieldColorModeId.Thresholds) { + dynamicSeriesColor = (seriesIdx) => getFieldSeriesColor(alignedFrame.fields[seriesIdx], theme).color; + } + + builder.addSeries({ + pathBuilder, + pointsBuilder, + scaleKey, + showPoints, + pointsFilter, + colorMode, + fillOpacity, + theme, + dynamicSeriesColor, + drawStyle: customConfig.drawStyle!, + lineColor: customConfig.lineColor ?? seriesColor, + lineWidth: customConfig.lineWidth, + lineInterpolation: customConfig.lineInterpolation, + lineStyle: customConfig.lineStyle, + barAlignment: customConfig.barAlignment, + barWidthFactor: customConfig.barWidthFactor, + barMaxWidth: customConfig.barMaxWidth, + pointSize: customConfig.pointSize, + spanNulls: customConfig.spanNulls || false, + show: !customConfig.hideFrom?.viz, + gradientMode: customConfig.gradientMode, + thresholds: config.thresholds, + hardMin: field.config.min, + hardMax: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: field.state?.origin, + }); + + // Render thresholds in graph + if (customConfig.thresholdsStyle && config.thresholds) { + const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off; + if (thresholdDisplay !== GraphTresholdsStyleMode.Off) { + builder.addThresholds({ + config: customConfig.thresholdsStyle, + thresholds: config.thresholds, + scaleKey, + theme, + hardMin: field.config.min, + hardMax: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + }); + } + } + } + + let stackingGroups = getStackingGroups(frame); + + builder.setStackingGroups(stackingGroups); + + // hook up custom/composite renderers + renderers?.forEach((r) => { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } + let fieldIndices: Record = {}; + + for (let key in r.fieldMap) { + let dispName = r.fieldMap[key]; + fieldIndices[key] = indexByName.get(dispName)!; + } + + r.init(builder, fieldIndices); + }); + + builder.scaleKeys = [xScaleKey, yScaleKey]; + + // if hovered value is null, how far we may scan left/right to hover nearest non-null + const hoverProximityPx = 15; + + let cursor: Partial = { + // this scans left and right from cursor position to find nearest data index with value != null + // TODO: do we want to only scan past undefined values, but halt at explicit null values? + dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => { + let seriesData = self.data[seriesIdx]; + + if (seriesData[hoveredIdx] == null) { + let nonNullLft = null, + nonNullRgt = null, + i; + + i = hoveredIdx; + while (nonNullLft == null && i-- > 0) { + if (seriesData[i] != null) { + nonNullLft = i; + } + } + + i = hoveredIdx; + while (nonNullRgt == null && i++ < seriesData.length) { + if (seriesData[i] != null) { + nonNullRgt = i; + } + } + + let xVals = self.data[0]; + + let curPos = self.valToPos(cursorXVal, 'x'); + let rgtPos = nonNullRgt == null ? Infinity : self.valToPos(xVals[nonNullRgt], 'x'); + let lftPos = nonNullLft == null ? -Infinity : self.valToPos(xVals[nonNullLft], 'x'); + + let lftDelta = curPos - lftPos; + let rgtDelta = rgtPos - curPos; + + if (lftDelta <= rgtDelta) { + if (lftDelta <= hoverProximityPx) { + hoveredIdx = nonNullLft!; + } + } else { + if (rgtDelta <= hoverProximityPx) { + hoveredIdx = nonNullRgt!; + } + } + } + + return hoveredIdx; + }, + }; + + if (sync && sync() !== DashboardCursorSync.Off) { + const payload: DataHoverPayload = { + point: { + [xScaleKey]: null, + [yScaleKey]: null, + }, + data: frame, + }; + + const hoverEvent = new DataHoverEvent(payload); + cursor.sync = { + key: eventsScope, + filters: { + pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { + if (sync && sync() === DashboardCursorSync.Off) { + return false; + } + + payload.rowIndex = dataIdx; + if (x < 0 && y < 0) { + payload.point[xScaleUnit] = null; + payload.point[yScaleKey] = null; + eventBus.publish(new DataHoverClearEvent()); + } else { + // convert the points + payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); + payload.point[yScaleKey] = src.posToVal(y, yScaleKey); + payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip + eventBus.publish(hoverEvent); + hoverEvent.payload.down = undefined; + } + return true; + }, + }, + scales: [xScaleKey, yScaleKey], + // match: [() => true, (a, b) => a === b], + }; + } + + builder.setSync(); + builder.setCursor(cursor); + + return builder; +}; + +export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map { + const originNames = new Map(); + frame.fields.forEach((field, i) => { + const origin = field.state?.origin; + if (origin) { + const origField = allFrames[origin.frameIndex]?.fields[origin.fieldIndex]; + if (origField) { + originNames.set(getFieldDisplayName(origField, allFrames[origin.frameIndex], allFrames), i); + } + } + }); + return originNames; +} diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index ac5e2e62769..e904530a82e 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -2,16 +2,9 @@ import React from 'react'; import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data'; import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; -import { - PanelContext, - PanelContextRoot, - GraphNG, - GraphNGProps, - UPlotConfigBuilder, - VizLayout, - VizLegend, - VizLegendItem, -} from '@grafana/ui'; +import { PanelContext, PanelContextRoot, UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui'; + +import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG'; import { preparePlotConfigBuilder, TimelineMode } from './utils'; diff --git a/public/app/core/components/TimelineChart/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts index 18ecfdbf33d..13fdd76fb4d 100644 --- a/public/app/core/components/TimelineChart/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -3,7 +3,7 @@ import uPlot, { Series } from 'uplot'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; -import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG'; +import { FIXED_UNIT } from '@grafana/ui'; import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute'; import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree'; import { FieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen'; diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 02048cca457..a372190209b 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -23,6 +23,8 @@ import { TimeRange, } from '@grafana/data'; import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; +import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; +import { nullToValue } from '@grafana/data/src/transformations/transformers/nulls/nullToValue'; import { VizLegendOptions, AxisPlacement, @@ -40,8 +42,6 @@ import { UPlotConfigPrepFn, VizLegendItem, } from '@grafana/ui'; -import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; -import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue'; import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types'; import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils'; diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index fc53cd974c7..7e5889e6945 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -21,7 +21,7 @@ export function getNavTitle(navId: string | undefined) { case 'import': return t('nav.create-import.title', 'Import dashboard'); case 'alert': - return t('nav.create-alert.title', 'Create alert rule'); + return t('nav.create-alert.title', 'New alert rule'); case 'starred': return t('nav.starred.title', 'Starred'); case 'starred-empty': diff --git a/public/app/features/admin/Users/OrgUnits.tsx b/public/app/features/admin/Users/OrgUnits.tsx index 08a58be8854..5da20ac22c3 100644 --- a/public/app/features/admin/Users/OrgUnits.tsx +++ b/public/app/features/admin/Users/OrgUnits.tsx @@ -1,8 +1,7 @@ import React, { forwardRef, PropsWithChildren } from 'react'; import { IconName } from '@grafana/data'; -import { Icon, Tooltip, Box } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; +import { Icon, Tooltip, Box, Stack } from '@grafana/ui'; import { Unit } from 'app/types'; type OrgUnitProps = { units?: Unit[]; icon: IconName }; diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 0e3df7d60a7..34a58210fab 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -3,20 +3,20 @@ import React, { useEffect, useMemo, useState } from 'react'; import { OrgRole } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { - Button, - ConfirmModal, - Icon, - Tooltip, - CellProps, - Tag, - InteractiveTable, - Column, - FetchDataFunc, - Pagination, Avatar, Box, + Button, + CellProps, + Column, + ConfirmModal, + FetchDataFunc, + Icon, + InteractiveTable, + Pagination, + Stack, + Tag, + Tooltip, } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index 7713855e4e1..745efbd925a 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -1,18 +1,18 @@ import React, { useMemo } from 'react'; import { - InteractiveTable, + Avatar, CellProps, - Tooltip, - Icon, - Tag, - Pagination, Column, FetchDataFunc, + Icon, + InteractiveTable, + Pagination, + Stack, + Tag, Text, - Avatar, + Tooltip, } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { UserDTO } from 'app/types'; diff --git a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.test.tsx b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.test.tsx index e1baefbd61d..5a552704cb4 100644 --- a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.test.tsx +++ b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.test.tsx @@ -39,7 +39,7 @@ describe('Analytics', () => { }); render(); - const button = screen.getByText('Create alert rule from this panel'); + const button = screen.getByText('New alert rule'); button.addEventListener('click', (event) => event.preventDefault(), false); diff --git a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx index cac61a795d4..4ab427e8341 100644 --- a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx +++ b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx @@ -30,7 +30,7 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) = ); if (loading) { - return ; + return ; } if (!formValues) { @@ -54,7 +54,7 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) = className={className} data-testid="create-alert-rule-button" > - Create alert rule from this panel + New alert rule ); }; diff --git a/public/app/features/connections/__mocks__/store.navIndex.mock.ts b/public/app/features/connections/__mocks__/store.navIndex.mock.ts index 99ba0cd4fca..64de68c6948 100644 --- a/public/app/features/connections/__mocks__/store.navIndex.mock.ts +++ b/public/app/features/connections/__mocks__/store.navIndex.mock.ts @@ -157,7 +157,7 @@ export const navIndex: NavIndex = { }, { id: 'alert', - text: 'Create alert rule', + text: 'New alert rule', subTitle: 'Create an alert rule', icon: 'plus', url: '/alerting/new', @@ -215,7 +215,7 @@ export const navIndex: NavIndex = { }, alert: { id: 'alert', - text: 'Create alert rule', + text: 'New alert rule', subTitle: 'Create an alert rule', icon: 'plus', url: '/alerting/new', diff --git a/public/app/features/correlations/Forms/TransformationsEditor.tsx b/public/app/features/correlations/Forms/TransformationsEditor.tsx index 3f811738137..8003ee2836c 100644 --- a/public/app/features/correlations/Forms/TransformationsEditor.tsx +++ b/public/app/features/correlations/Forms/TransformationsEditor.tsx @@ -3,7 +3,7 @@ import { compact, fill } from 'lodash'; import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { GrafanaTheme2, SupportedTransformationType } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Button, @@ -19,6 +19,8 @@ import { useStyles2, } from '@grafana/ui'; +import { getSupportedTransTypeDetails, getTransformOptions } from './types'; + type Props = { readOnly: boolean }; const getStyles = (theme: GrafanaTheme2) => ({ @@ -95,7 +97,7 @@ export const TransformationsEditor = (props: Props) => { const newValueDetails = getSupportedTransTypeDetails(value.value); - if (newValueDetails.showExpression) { + if (newValueDetails.expressionDetails.show) { setValue( `config.transformations.${index}.expression`, keptVals[index]?.expression || '' @@ -104,7 +106,7 @@ export const TransformationsEditor = (props: Props) => { setValue(`config.transformations.${index}.expression`, ''); } - if (newValueDetails.showMapValue) { + if (newValueDetails.mapValueDetails.show) { setValue( `config.transformations.${index}.mapValue`, keptVals[index]?.mapValue || '' @@ -160,7 +162,7 @@ export const TransformationsEditor = (props: Props) => { @@ -184,7 +186,7 @@ export const TransformationsEditor = (props: Props) => { { readOnly={readOnly} disabled={ !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) - .showExpression + .expressionDetails.show } id={`config.transformations.${fieldVal.id}.expression`} /> @@ -221,7 +223,8 @@ export const TransformationsEditor = (props: Props) => { defaultValue={fieldVal.mapValue} readOnly={readOnly} disabled={ - !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)).showMapValue + !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) + .mapValueDetails.show } id={`config.transformations.${fieldVal.id}.mapValue`} /> @@ -266,48 +269,3 @@ export const TransformationsEditor = (props: Props) => { ); }; - -interface SupportedTransformationTypeDetails { - label: string; - value: string; - description?: string; - showExpression: boolean; - showMapValue: boolean; - requireExpression?: boolean; -} - -function getSupportedTransTypeDetails(transType: SupportedTransformationType): SupportedTransformationTypeDetails { - switch (transType) { - case SupportedTransformationType.Logfmt: - return { - label: 'Logfmt', - value: SupportedTransformationType.Logfmt, - description: 'Parse provided field with logfmt to get variables', - showExpression: false, - showMapValue: false, - }; - case SupportedTransformationType.Regex: - return { - label: 'Regular expression', - value: SupportedTransformationType.Regex, - description: - 'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value.', - showExpression: true, - showMapValue: true, - requireExpression: true, - }; - default: - return { label: transType, value: transType, showExpression: false, showMapValue: false }; - } -} - -const getTransformOptions = () => { - return Object.values(SupportedTransformationType).map((transformationType) => { - const transType = getSupportedTransTypeDetails(transformationType); - return { - label: transType.label, - value: transType.value, - description: transType.description, - }; - }); -}; diff --git a/public/app/features/correlations/Forms/types.ts b/public/app/features/correlations/Forms/types.ts index d3e10e581c4..747ef8f97ba 100644 --- a/public/app/features/correlations/Forms/types.ts +++ b/public/app/features/correlations/Forms/types.ts @@ -17,3 +17,67 @@ export type TransformationDTO = { expression?: string; mapValue?: string; }; + +export interface TransformationFieldDetails { + show: boolean; + required?: boolean; + helpText?: string; +} + +interface SupportedTransformationTypeDetails { + label: string; + value: SupportedTransformationType; + description?: string; + expressionDetails: TransformationFieldDetails; + mapValueDetails: TransformationFieldDetails; +} + +export function getSupportedTransTypeDetails( + transType: SupportedTransformationType +): SupportedTransformationTypeDetails { + switch (transType) { + case SupportedTransformationType.Logfmt: + return { + label: 'Logfmt', + value: SupportedTransformationType.Logfmt, + description: 'Parse provided field with logfmt to get variables', + expressionDetails: { show: false }, + mapValueDetails: { show: false }, + }; + case SupportedTransformationType.Regex: + return { + label: 'Regular expression', + value: SupportedTransformationType.Regex, + description: + 'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value. Regex is case insensitive.', + expressionDetails: { + show: true, + required: true, + helpText: 'Use capture groups to extract a portion of the field.', + }, + mapValueDetails: { + show: true, + required: false, + helpText: 'Defines the name of the variable if the capture group is not named.', + }, + }; + default: + return { + label: transType, + value: transType, + expressionDetails: { show: false }, + mapValueDetails: { show: false }, + }; + } +} + +export const getTransformOptions = () => { + return Object.values(SupportedTransformationType).map((transformationType) => { + const transType = getSupportedTransTypeDetails(transformationType); + return { + label: transType.label, + value: transType.value, + description: transType.description, + }; + }); +}; diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index cba91591e92..14abefccc0d 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -1,7 +1,8 @@ import { lastValueFrom } from 'rxjs'; import { DataFrame, DataLinkConfigOrigin } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { ExploreItemState } from 'app/types'; import { formatValueName } from '../explore/PrometheusListView/ItemLabels'; @@ -90,3 +91,19 @@ export const createCorrelation = async ( ): Promise => { return getBackendSrv().post(`/api/datasources/uid/${sourceUID}/correlations`, correlation); }; + +const getDSInstanceForPane = async (pane: ExploreItemState) => { + if (pane.datasourceInstance?.meta.mixed) { + return await getDataSourceSrv().get(pane.queries[0].datasource); + } else { + return pane.datasourceInstance; + } +}; + +export const generateDefaultLabel = async (sourcePane: ExploreItemState, targetPane: ExploreItemState) => { + return Promise.all([getDSInstanceForPane(sourcePane), getDSInstanceForPane(targetPane)]).then((dsInstances) => { + return dsInstances[0]?.name !== undefined && dsInstances[1]?.name !== undefined + ? `${dsInstances[0]?.name} to ${dsInstances[1]?.name}` + : ''; + }); +}; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 5c27e377406..9b04f129591 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -5,26 +5,32 @@ import { PageLayoutType } from '@grafana/data'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types'; +import { DashboardRoutes } from 'app/types'; import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; -export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {} +export interface Props extends GrafanaRouteComponentProps {} -export function DashboardScenePage({ match }: Props) { +export function DashboardScenePage({ match, route }: Props) { const stateManager = getDashboardScenePageStateManager(); const { dashboard, isLoading, loadError } = stateManager.useState(); useEffect(() => { - stateManager.loadDashboard(match.params.uid); + if (route.routeName === DashboardRoutes.Home) { + stateManager.loadDashboard(route.routeName); + } else { + stateManager.loadDashboard(match.params.uid!); + } return () => { stateManager.clearState(); }; - }, [stateManager, match.params.uid]); + }, [stateManager, match.params.uid, route.routeName]); if (!dashboard) { return ( - + {isLoading && } {loadError &&

{loadError}

}
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index f0a8eef1a0c..dce180fa0df 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -1,15 +1,17 @@ +import { advanceBy } from 'jest-date-mock'; + import { locationService } from '@grafana/runtime'; import { getUrlSyncManager } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; import { setupLoadDashboardMock } from '../utils/test-utils'; -import { DashboardScenePageStateManager } from './DashboardScenePageStateManager'; +import { DashboardScenePageStateManager, DASHBOARD_CACHE_TTL } from './DashboardScenePageStateManager'; describe('DashboardScenePageStateManager', () => { describe('when fetching/loading a dashboard', () => { it('should call loader from server if the dashboard is not cached', async () => { - const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + const loadDashboardMock = setupLoadDashboardMock({ dashboard: { uid: 'fake-dash', editable: true }, meta: {} }); const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard('fake-dash'); @@ -74,5 +76,39 @@ describe('DashboardScenePageStateManager', () => { expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m'); }); + + describe('caching', () => { + it('should cache the dashboard DTO', async () => { + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }); + + const loader = new DashboardScenePageStateManager({}); + + expect(loader.getFromCache('fake-dash')).toBeNull(); + + await loader.loadDashboard('fake-dash'); + + expect(loader.getFromCache('fake-dash')).toBeDefined(); + }); + + it('should load dashboard DTO from cache if requested again within 2s', async () => { + const loadDashSpy = jest.fn(); + setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} }, loadDashSpy); + + const loader = new DashboardScenePageStateManager({}); + + expect(loader.getFromCache('fake-dash')).toBeNull(); + + await loader.fetchDashboard('fake-dash'); + expect(loadDashSpy).toHaveBeenCalledTimes(1); + + advanceBy(DASHBOARD_CACHE_TTL / 2); + await loader.fetchDashboard('fake-dash'); + expect(loadDashSpy).toHaveBeenCalledTimes(1); + + advanceBy(DASHBOARD_CACHE_TTL / 2 + 1); + await loader.fetchDashboard('fake-dash'); + expect(loadDashSpy).toHaveBeenCalledTimes(2); + }); + }); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 78faff65731..b8ec4022740 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1,6 +1,14 @@ +import { locationUtil } from '@grafana/data'; +import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; +import { updateNavIndex } from 'app/core/actions'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { buildNavModel } from 'app/features/folders/state/navModel'; +import { store } from 'app/store/store'; +import { DashboardDTO, DashboardMeta, DashboardRoutes } from 'app/types'; import { buildPanelEditScene, PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; @@ -14,8 +22,69 @@ export interface DashboardScenePageState { loadError?: string; } +export const DASHBOARD_CACHE_TTL = 2000; + +interface DashboardCacheEntry { + dashboard: DashboardDTO; + ts: number; +} export class DashboardScenePageStateManager extends StateManagerBase { private cache: Record = {}; + // This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span. + private dashboardCache: Map = new Map(); + + // To eventualy replace the fetchDashboard function from Dashboard redux state management. + // For now it's a simplistic version to support Home and Normal dashboard routes. + public async fetchDashboard(uid: string) { + const cachedDashboard = this.getFromCache(uid); + + if (cachedDashboard) { + return cachedDashboard; + } + + let rsp: DashboardDTO | undefined; + + try { + if (uid === DashboardRoutes.Home) { + rsp = await getBackendSrv().get('/api/dashboards/home'); + + // If user specified a custom home dashboard redirect to that + if (rsp?.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); + locationService.replace(newUrl); + return null; + } + + if (rsp?.meta) { + rsp.meta.canSave = false; + rsp.meta.canShare = false; + rsp.meta.canStar = false; + } + } else { + rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); + } + + if (rsp) { + // Fill in meta fields + const dashboard = this.initDashboardMeta(rsp); + + // Populate nav model in global store according to the folder + await this.initNavModel(dashboard); + + this.dashboardCache.set(uid, { dashboard, ts: Date.now() }); + } + } catch (e) { + // Ignore cancelled errors + if (isFetchError(e) && e.cancelled) { + return null; + } + + console.error(e); + throw e; + } + + return rsp; + } public async loadDashboard(uid: string) { try { @@ -55,10 +124,11 @@ export class DashboardScenePageStateManager extends StateManagerBase DASHBOARD_CACHE_TTL; + } + + private initDashboardMeta(dashboard: DashboardDTO): DashboardDTO { + return { + ...dashboard, + meta: initDashboardMeta(dashboard.meta, Boolean(dashboard.dashboard?.editable)), + }; + } + + private async initNavModel(dashboard: DashboardDTO) { + // only the folder API has information about ancestors + // get parent folder (if it exists) and put it in the store + // this will be used to populate the full breadcrumb trail + if (newBrowseDashboardsEnabled() && dashboard.meta.folderUid) { + try { + const folder = await backendSrv.getFolderByUid(dashboard.meta.folderUid); + store.dispatch(updateNavIndex(buildNavModel(folder))); + } catch (err) { + console.warn('Error fetching parent folder', dashboard.meta.folderUid, 'for dashboard', err); + } + } + } + public clearState() { getDashboardSrv().setCurrent(undefined); this.setState({ dashboard: undefined, loadError: undefined, isLoading: false, panelEditor: undefined }); } + + public setDashboardCache(uid: string, dashboard: DashboardDTO) { + this.dashboardCache.set(uid, { dashboard, ts: Date.now() }); + } } let stateManager: DashboardScenePageStateManager | null = null; @@ -81,3 +190,25 @@ export function getDashboardScenePageStateManager(): DashboardScenePageStateMana return stateManager; } + +function initDashboardMeta(source: DashboardMeta, isEditable: boolean) { + const result = source ? { ...source } : {}; + + result.canShare = source.canShare !== false; + result.canSave = source.canSave !== false; + result.canStar = source.canStar !== false; + result.canEdit = source.canEdit !== false; + result.canDelete = source.canDelete !== false; + + result.showSettings = source.canEdit; + result.canMakeEditable = source.canSave && !isEditable; + result.hasUnsavedFolderChange = false; + + if (!isEditable) { + result.canEdit = false; + result.canDelete = false; + result.canSave = false; + } + + return result; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 7d0416a645f..b63244c728c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -1,5 +1,6 @@ import * as H from 'history'; +import { NavIndex } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { getUrlSyncManager, @@ -54,10 +55,10 @@ export class PanelEditor extends SceneObjectBase { getUrlSyncManager().initSync(this); } - public getPageNav(location: H.Location) { + public getPageNav(location: H.Location, navIndex: NavIndex) { return { text: 'Edit panel', - parentItem: this.state.dashboardRef.resolve().getPageNav(location), + parentItem: this.state.dashboardRef.resolve().getPageNav(location, navIndex), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 9af29338ca7..d44801cbaa5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -8,6 +8,7 @@ import { Button, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; import { Page } from 'app/core/components/Page/Page'; +import { useSelector } from 'app/types/store'; import { PanelEditor } from './PanelEditor'; @@ -15,7 +16,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) const { body, controls, drawer } = model.useState(); const styles = useStyles2(getStyles); const location = useLocation(); - const pageNav = model.getPageNav(location); + const navIndex = useSelector((state) => state.navIndex); + const pageNav = model.getPageNav(location, navIndex); return ( diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 4a849ee9c94..e380d2da55e 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -1,8 +1,8 @@ import * as H from 'history'; import { Unsubscribable } from 'rxjs'; -import { CoreApp, DataQueryRequest, NavModelItem, UrlQueryMap } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, UrlQueryMap } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime'; import { getUrlSyncManager, SceneFlexLayout, @@ -14,6 +14,8 @@ import { SceneObjectStateChangedEvent, sceneUtils, } from '@grafana/scenes'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardMeta } from 'app/types'; @@ -155,16 +157,45 @@ export class DashboardScene extends SceneObjectBase { this.setState({ overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef() }) }); }; - public getPageNav(location: H.Location) { + public getPageNav(location: H.Location, navIndex: NavIndex) { + const { meta } = this.state; + let pageNav: NavModelItem = { text: this.state.title, url: getDashboardUrl({ uid: this.state.uid, currentQueryParams: location.search, updateQuery: { viewPanel: null, inspect: null }, + useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && meta.canEdit), }), }; + const { folderTitle, folderUid } = meta; + + if (folderUid) { + if (newBrowseDashboardsEnabled()) { + const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main; + // If the folder hasn't loaded (maybe user doesn't have permission on it?) then + // don't show the "page not found" breadcrumb + if (folderNavModel.id !== 'not-found') { + pageNav = { + ...pageNav, + parentItem: folderNavModel, + }; + } + } else { + if (folderTitle) { + pageNav = { + ...pageNav, + parentItem: { + text: folderTitle, + url: `/dashboards/f/${meta.folderUid}`, + }, + }; + } + } + } + if (this.state.viewPanelKey) { pageNav = { text: 'View panel', @@ -216,6 +247,25 @@ export class DashboardScene extends SceneObjectBase { this.setState({ overlay: undefined }); } + public async onStarDashboard() { + const { meta, uid } = this.state; + if (!uid) { + return; + } + try { + const result = await getDashboardSrv().starDashboard(uid, Boolean(meta.isStarred)); + + this.setState({ + meta: { + ...meta, + isStarred: result, + }, + }); + } catch (err) { + console.error('Failed to star dashboard', err); + } + } + /** * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request */ @@ -230,6 +280,8 @@ export class DashboardScene extends SceneObjectBase { } canEditDashboard() { - return Boolean(this.state.meta.canEdit || this.state.meta.canMakeEditable); + const { meta } = this.state; + + return Boolean(meta.canEdit || meta.canMakeEditable); } } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 63bb7df05e6..74943eb95fb 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -1,11 +1,14 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneDebugger } from '@grafana/scenes'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { useSelector } from 'app/types'; import { DashboardScene } from './DashboardScene'; import { NavToolbarActions } from './NavToolbarActions'; @@ -14,14 +17,20 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps state.navIndex); + const pageNav = model.getPageNav(location, navIndex); const bodyToRender = model.getBodyToRender(viewPanelId); + const navProps = config.featureToggles.dashboardSceneForViewers + ? { navModel: getNavModel(navIndex, 'dashboards/browse') } + : { navId: 'scenes' }; + return ( - +
+ {controls && (
{controls.map((control) => ( @@ -30,7 +39,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps
)} -
+
@@ -57,6 +66,7 @@ function getStyles(theme: GrafanaTheme2) { gap: '8px', marginBottom: theme.spacing(2), }), + controls: css({ display: 'flex', flexWrap: 'wrap', diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 2a634c2b5c5..6f5b4285c67 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -16,10 +16,28 @@ interface Props { } export const NavToolbarActions = React.memo(({ dashboard }) => { - const { actions = [], isEditing, viewPanelKey, isDirty, uid } = dashboard.useState(); + const { actions = [], isEditing, viewPanelKey, isDirty, uid, meta } = dashboard.useState(); const toolbarActions = (actions ?? []).map((action) => ); if (uid) { + if (meta.canStar) { + let desc = meta.isStarred + ? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite') + : t('dashboard.toolbar.mark-favorite', 'Mark as favorite'); + + toolbarActions.push( + { + dashboard.onStarDashboard(); + }} + /> + ); + } toolbarActions.push( (({ dashboard }) => { } if (!isEditing) { - // TODO check permissions - toolbarActions.push( - - ); + if (dashboard.canEditDashboard()) { + toolbarActions.push( + + ); + } } else { - // TODO check permissions - toolbarActions.push( - - ); - toolbarActions.push( - - ); - toolbarActions.push( - - ); + if (dashboard.canEditDashboard()) { + toolbarActions.push( + + ); + toolbarActions.push( + + ); + toolbarActions.push( + + ); + } } return ; diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 383cccb445d..199a2e06586 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -80,6 +80,9 @@ async function buildTestScene(options: SceneOptions) { const scene = new DashboardScene({ title: 'hello', uid: 'dash-1', + meta: { + canEdit: true, + }, body: new SceneGridLayout({ children: [ new SceneGridItem({ diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 343c5030447..70699f37b4d 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -35,19 +35,22 @@ export function panelMenuBehavior(menu: VizPanelMenu) { href: getViewPanelUrl(panel), }); - // We could check isEditing here but I kind of think this should always be in the menu, - // and going into panel edit should make the dashboard go into edit mode is it's not already - items.push({ - text: t('panel.header-menu.edit', `Edit`), - iconClassName: 'eye', - shortcut: 'e', - onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }), - href: getDashboardUrl({ - uid: dashboard.state.uid, - subPath: `/panel-edit/${panelId}`, - currentQueryParams: location.search, - }), - }); + if (dashboard.canEditDashboard()) { + // We could check isEditing here but I kind of think this should always be in the menu, + // and going into panel edit should make the dashboard go into edit mode is it's not already + items.push({ + text: t('panel.header-menu.edit', `Edit`), + iconClassName: 'eye', + shortcut: 'e', + onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }), + href: getDashboardUrl({ + uid: dashboard.state.uid, + subPath: `/panel-edit/${panelId}`, + currentQueryParams: location.search, + useExperimentalURL: true, + }), + }); + } items.push({ text: t('panel.header-menu.share', `Share`), diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 7057b410a2f..68c86ce9083 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -203,18 +203,23 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) }); } - const controls: SceneObject[] = [ + let controls: SceneObject[] = [ new VariableValueSelectors({}), ...filtersSets, new SceneDataLayerControls(), new SceneControlsSpacer(), - new SceneTimePicker({}), - new SceneRefreshPicker({ - refresh: oldModel.refresh, - intervals: oldModel.timepicker.refresh_intervals, - }), ]; + if (!Boolean(oldModel.timepicker.hidden)) { + controls = controls.concat([ + new SceneTimePicker({}), + new SceneRefreshPicker({ + refresh: oldModel.refresh, + intervals: oldModel.timepicker.refresh_intervals, + }), + ]); + } + return new DashboardScene({ title: oldModel.title, uid: oldModel.uid, diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index e6a9831ca34..352f8438997 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { useAsync } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { config, getBackendSrv } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, SceneObjectRef } from '@grafana/scenes'; import { Button, ClipboardButton, CodeEditor, Field, Modal, Switch, VerticalGroup } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; @@ -18,12 +17,10 @@ import { transformSceneToSaveModel } from '../serialization/transformSceneToSave import { SceneShareTabState } from './types'; const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`); -const exportDefaultTranslation = t('share-modal.export.share-default-label', `Export with default values removed`); interface ShareExportTabState extends SceneShareTabState { dashboardRef: SceneObjectRef; isSharingExternally?: boolean; - shouldTrimDefaults?: boolean; isViewingJSON?: boolean; } @@ -35,7 +32,6 @@ export class ShareExportTab extends SceneObjectBase { constructor(state: Omit) { super({ isSharingExternally: false, - shouldTrimDefaults: false, isViewingJSON: false, ...state, }); @@ -51,12 +47,6 @@ export class ShareExportTab extends SceneObjectBase { }); }; - public onTrimDefaultsChange = () => { - this.setState({ - shouldTrimDefaults: !this.state.shouldTrimDefaults, - }); - }; - public onViewJSON = () => { this.setState({ isViewingJSON: !this.state.isViewingJSON, @@ -68,19 +58,14 @@ export class ShareExportTab extends SceneObjectBase { } public async getExportableDashboardJson() { - const { dashboardRef, isSharingExternally, shouldTrimDefaults } = this.state; + const { dashboardRef, isSharingExternally } = this.state; const saveModel = transformSceneToSaveModel(dashboardRef.resolve()); const exportable = isSharingExternally ? await this._exporter.makeExportable(new DashboardModel(saveModel)) : saveModel; - if (shouldTrimDefaults) { - const trimmed = await getBackendSrv().post('/api/dashboards/trim', { dashboard: exportable }); - return trimmed.dashboard; - } else { - return exportable; - } + return exportable; } public async onSaveAsFile() { @@ -92,13 +77,17 @@ export class ShareExportTab extends SceneObjectBase { }); const time = new Date().getTime(); - saveAs(blob, `${dashboardJson.title}-${time}.json`); + let title = 'dashboard'; + if ('title' in dashboardJson && dashboardJson.title) { + title = dashboardJson.title; + } + saveAs(blob, `${title}-${time}.json`); trackDashboardSharingActionPerType('save_export', shareDashboardType.export); } } function ShareExportTabRenderer({ model }: SceneComponentProps) { - const { isSharingExternally, shouldTrimDefaults, isViewingJSON, modalRef } = model.useState(); + const { isSharingExternally, isViewingJSON, modalRef } = model.useState(); const dashboardJson = useAsync(async () => { if (isViewingJSON) { @@ -124,12 +113,6 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) onChange={model.onShareExternallyChange} /> - - {config.featureToggles.trimDefaults && ( - - - - )} diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index 1cfe3167fc6..e9e2e35a7ab 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -29,6 +29,7 @@ describe('ShareLinkTab', () => { config.appUrl = 'http://dashboards.grafana.com/grafana/'; config.rendererAvailable = true; config.bootData.user.orgId = 1; + config.featureToggles.dashboardSceneForViewers = true; locationService.push('/scenes/dashboard/dash-1?from=now-6h&to=now'); }); @@ -98,6 +99,9 @@ function buildAndRenderScenario(options: ScenarioOptions) { const dashboard = new DashboardScene({ title: 'hello', uid: 'dash-1', + meta: { + canEdit: true, + }, $timeRange: new SceneTimeRange({}), body: new SceneGridLayout({ children: [ diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index f5c2db5387a..61e38a55d65 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -74,6 +74,7 @@ export class ShareLinkTab extends SceneObjectBase { currentQueryParams: location.search, updateQuery: urlParamsUpdate, absolute: true, + useExperimentalURL: Boolean(config.featureToggles.dashboardSceneForViewers && dashboard.state.meta.canEdit), }); if (useShortUrl) { diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts index f0fae6b310b..aa8a8afe29f 100644 --- a/public/app/features/dashboard-scene/utils/test-utils.ts +++ b/public/app/features/dashboard-scene/utils/test-utils.ts @@ -18,8 +18,8 @@ import { DashboardDTO } from 'app/types'; import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; -export function setupLoadDashboardMock(rsp: DeepPartial) { - const loadDashboardMock = jest.fn().mockResolvedValue(rsp); +export function setupLoadDashboardMock(rsp: DeepPartial, spy?: jest.Mock) { + const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp); setDashboardLoaderSrv({ loadDashboard: loadDashboardMock, // disabling type checks since this is a test util diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts index 7c7e2899f4c..192f6e8f78d 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.test.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts @@ -2,13 +2,18 @@ import { getDashboardUrl } from './urlBuilders'; describe('dashboard utils', () => { it('Can getUrl', () => { - const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A' }); + const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A', useExperimentalURL: true }); expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&filter=A'); }); it('Can getUrl with subpath', () => { - const url = getDashboardUrl({ uid: 'dash-1', subPath: '/panel-edit/2', currentQueryParams: '?orgId=1&filter=A' }); + const url = getDashboardUrl({ + uid: 'dash-1', + subPath: '/panel-edit/2', + currentQueryParams: '?orgId=1&filter=A', + useExperimentalURL: true, + }); expect(url).toBe('/scenes/dashboard/dash-1/panel-edit/2?orgId=1&filter=A'); }); @@ -18,6 +23,7 @@ describe('dashboard utils', () => { uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A', updateQuery: { filter: null, new: 'A' }, + useExperimentalURL: true, }); expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A'); diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 1539a04f348..7d3bfc5b274 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -20,10 +20,15 @@ export interface DashboardUrlOptions { absolute?: boolean; // Add tz to query params timeZone?: string; + + // Add tz to query params + useExperimentalURL?: boolean; } export function getDashboardUrl(options: DashboardUrlOptions) { - let path = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`; + let path = options.useExperimentalURL + ? `/scenes/dashboard/${options.uid}${options.subPath ?? ''}` + : `/d/${options.uid}${options.subPath ?? ''}`; if (options.soloRoute) { path = `/d-solo/${options.uid}${options.subPath ?? ''}`; diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx index a2fbf2013b4..a33f0bd39ce 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx @@ -1,11 +1,9 @@ import { saveAs } from 'file-saver'; import React, { PureComponent } from 'react'; -import { config } from '@grafana/runtime'; import { Button, Field, Modal, Switch } from '@grafana/ui'; import { appEvents } from 'app/core/core'; import { t, Trans } from 'app/core/internationalization'; -import { getBackendSrv } from 'app/core/services/backend_srv'; import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; import { ShowModalReactEvent } from 'app/types/events'; @@ -18,7 +16,6 @@ interface Props extends ShareModalTabProps {} interface State { shareExternally: boolean; - trimDefaults: boolean; } export class ShareExport extends PureComponent { @@ -28,7 +25,6 @@ export class ShareExport extends PureComponent { super(props); this.state = { shareExternally: false, - trimDefaults: false, }; this.exporter = new DashboardExporter(); @@ -40,69 +36,29 @@ export class ShareExport extends PureComponent { }); }; - onTrimDefaultsChange = () => { - this.setState({ - trimDefaults: !this.state.trimDefaults, - }); - }; - onSaveAsFile = () => { const { dashboard } = this.props; const { shareExternally } = this.state; - const { trimDefaults } = this.state; if (shareExternally) { this.exporter.makeExportable(dashboard).then((dashboardJson) => { - if (trimDefaults) { - getBackendSrv() - .post('/api/dashboards/trim', { dashboard: dashboardJson }) - .then((resp) => { - this.openSaveAsDialog(resp.dashboard); - }); - } else { - this.openSaveAsDialog(dashboardJson); - } + this.openSaveAsDialog(dashboardJson); }); } else { - if (trimDefaults) { - getBackendSrv() - .post('/api/dashboards/trim', { dashboard: dashboard.getSaveModelClone() }) - .then((resp) => { - this.openSaveAsDialog(resp.dashboard); - }); - } else { - this.openSaveAsDialog(dashboard.getSaveModelClone()); - } + this.openSaveAsDialog(dashboard.getSaveModelClone()); } }; onViewJson = () => { const { dashboard } = this.props; const { shareExternally } = this.state; - const { trimDefaults } = this.state; if (shareExternally) { this.exporter.makeExportable(dashboard).then((dashboardJson) => { - if (trimDefaults) { - getBackendSrv() - .post('/api/dashboards/trim', { dashboard: dashboardJson }) - .then((resp) => { - this.openJsonModal(resp.dashboard); - }); - } else { - this.openJsonModal(dashboardJson); - } + this.openJsonModal(dashboardJson); }); } else { - if (trimDefaults) { - getBackendSrv() - .post('/api/dashboards/trim', { dashboard: dashboard.getSaveModelClone() }) - .then((resp) => { - this.openJsonModal(resp.dashboard); - }); - } else { - this.openJsonModal(dashboard.getSaveModelClone()); - } + this.openJsonModal(dashboard.getSaveModelClone()); } }; @@ -132,12 +88,9 @@ export class ShareExport extends PureComponent { render() { const { onDismiss } = this.props; const { shareExternally } = this.state; - const { trimDefaults } = this.state; const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`); - const exportDefaultTranslation = t('share-modal.export.share-default-label', `Export with default values removed`); - return ( <>

@@ -146,11 +99,6 @@ export class ShareExport extends PureComponent { - {config.featureToggles.trimDefaults && ( - - - - )} + {transformations.map((transformation, i) => { + const { type, field, expression, mapValue } = transformation; + const detailsString = [ + (mapValue ?? '').length > 0 ? `Variable name: ${mapValue}` : undefined, + (expression ?? '').length > 0 ? ( + <> + Expression: {expression} + + ) : undefined, + ].filter((val) => val); + return ( + + + {field}: {type} + + {detailsString.length > 0 && ( + {detailsString} + )} + + { + setTransformationIdxToEdit(i); + setShowTransformationAddModal(true); + }} + /> + setTransformations(transformations.filter((_, idx) => i !== idx))} + closeOnConfirm + /> + + + ); + })} + + + ); }; + +const getStyles = (theme: GrafanaTheme2) => { + return { + labelCollapseDetails: css({ + marginLeft: theme.spacing(2), + ...theme.typography['bodySmall'], + fontStyle: 'italic', + }), + transformationAction: css({ + marginBottom: theme.spacing(2), + }), + transformationMeta: css({ + alignItems: 'baseline', + }), + }; +}; diff --git a/public/app/features/explore/CorrelationTransformationAddModal.tsx b/public/app/features/explore/CorrelationTransformationAddModal.tsx new file mode 100644 index 00000000000..63388852e3e --- /dev/null +++ b/public/app/features/explore/CorrelationTransformationAddModal.tsx @@ -0,0 +1,240 @@ +import { css } from '@emotion/css'; +import React, { useId, useState, useMemo, useEffect } from 'react'; +import Highlighter from 'react-highlight-words'; +import { useForm } from 'react-hook-form'; + +import { DataLinkTransformationConfig, ScopedVars } from '@grafana/data'; +import { Button, Field, Icon, Input, InputControl, Label, Modal, Select, Tooltip, Stack } from '@grafana/ui'; + +import { + getSupportedTransTypeDetails, + getTransformOptions, + TransformationFieldDetails, +} from '../correlations/Forms/types'; +import { getTransformationVars } from '../correlations/transformations'; + +interface CorrelationTransformationAddModalProps { + onCancel: () => void; + onSave: (transformation: DataLinkTransformationConfig) => void; + fieldList: Record; + transformationToEdit?: DataLinkTransformationConfig; +} + +interface ShowFormFields { + expressionDetails: TransformationFieldDetails; + mapValueDetails: TransformationFieldDetails; +} + +const LabelWithTooltip = ({ label, tooltipText }: { label: string; tooltipText: string }) => ( + + + + + + +); + +export const CorrelationTransformationAddModal = ({ + onSave, + onCancel, + fieldList, + transformationToEdit, +}: CorrelationTransformationAddModalProps) => { + const [exampleValue, setExampleValue] = useState(undefined); + const [transformationVars, setTransformationVars] = useState({}); + const [formFieldsVis, setFormFieldsVis] = useState({ + mapValueDetails: { show: false }, + expressionDetails: { show: false }, + }); + const [isExpValid, setIsExpValid] = useState(false); // keep the highlighter from erroring on bad expressions + const [validToSave, setValidToSave] = useState(false); + const { getValues, control, register, watch } = useForm({ + defaultValues: useMemo(() => { + if (transformationToEdit) { + const exampleVal = fieldList[transformationToEdit?.field!]; + setExampleValue(exampleVal); + if (transformationToEdit?.expression) { + setIsExpValid(true); + } + const transformationTypeDetails = getSupportedTransTypeDetails(transformationToEdit?.type!); + setFormFieldsVis({ + mapValueDetails: transformationTypeDetails.mapValueDetails, + expressionDetails: transformationTypeDetails.expressionDetails, + }); + + const transformationVars = getTransformationVars( + { + type: transformationToEdit?.type!, + expression: transformationToEdit?.expression, + mapValue: transformationToEdit?.mapValue, + }, + exampleVal || '', + transformationToEdit?.field! + ); + setTransformationVars({ ...transformationVars }); + setValidToSave(true); + return { + type: transformationToEdit?.type, + field: transformationToEdit?.field, + mapValue: transformationToEdit?.mapValue, + expression: transformationToEdit?.expression, + }; + } else { + return undefined; + } + }, [fieldList, transformationToEdit]), + }); + const id = useId(); + + useEffect(() => { + const subscription = watch((formValues) => { + const expression = formValues.expression; + let isExpressionValid = false; + if (expression !== undefined) { + isExpressionValid = true; + try { + new RegExp(expression); + } catch (e) { + isExpressionValid = false; + } + } else { + isExpressionValid = !formFieldsVis.expressionDetails.show; + } + setIsExpValid(isExpressionValid); + const transformationVars = getTransformationVars( + { + type: formValues.type, + expression: isExpressionValid ? expression : '', + mapValue: formValues.mapValue, + }, + fieldList[formValues.field!] || '', + formValues.field! + ); + + const transKeys = Object.keys(transformationVars); + setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {}); + + if (transKeys.length === 0 || !isExpressionValid) { + setValidToSave(false); + } else { + setValidToSave(true); + } + }); + return () => subscription.unsubscribe(); + }, [fieldList, formFieldsVis.expressionDetails.show, watch]); + + return ( + +

+ A transformation extracts variables out of a single field. These variables will be available along with your + field variables. +

+ + ( + { + onChange(value.value); + const transformationTypeDetails = getSupportedTransTypeDetails(value.value!); + setFormFieldsVis({ + mapValueDetails: transformationTypeDetails.mapValueDetails, + expressionDetails: transformationTypeDetails.expressionDetails, + }); + }} + options={getTransformOptions()} + aria-label="type" + /> + )} + name={`type` as const} + /> + + {formFieldsVis.expressionDetails.show && ( + + ) : ( + 'Expression' + ) + } + htmlFor={`${id}-expression`} + required={formFieldsVis.expressionDetails.required} + > + + + )} + {formFieldsVis.mapValueDetails.show && ( + + ) : ( + 'Variable Name' + ) + } + htmlFor={`${id}-mapValue`} + > + + + )} + {Object.entries(transformationVars).length > 0 && ( + <> + This transformation will add the following variables: +
+                {Object.entries(transformationVars).map((entry) => {
+                  return `\$\{${entry[0]}\} = ${entry[1]?.value}\n`;
+                })}
+              
+ + )} + + )} + + + + + + ); +}; diff --git a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx index 19100150fd9..45883d7a87b 100644 --- a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx +++ b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx @@ -4,27 +4,28 @@ import React from 'react'; import { Button, Modal } from '@grafana/ui'; interface UnsavedChangesModalProps { + message: string; onDiscard: () => void; onCancel: () => void; onSave: () => void; } -export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel }: UnsavedChangesModalProps) => { +export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel, message }: UnsavedChangesModalProps) => { return ( -
Do you want to save changes to this Correlation?
+
{message}