Merge remote-tracking branch 'origin/main' into release-0.0.0-cloud.0

This commit is contained in:
Kevin Minehart
2023-07-14 08:14:37 -05:00
178 changed files with 5920 additions and 6394 deletions
+1 -1
View File
@@ -4881,7 +4881,7 @@ exports[`better eslint`] = {
"public/app/plugins/panel/geomap/utils/selection.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/geomap/utils/tootltip.ts:5381": [
"public/app/plugins/panel/geomap/utils/tooltip.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/graph/GraphContextMenuCtrl.ts:5381": [
-10
View File
@@ -11,8 +11,6 @@ load("scripts/drone/events/pr.star", "pr_pipelines")
load("scripts/drone/events/main.star", "main_pipelines")
load(
"scripts/drone/events/release.star",
"enterprise2_pipelines",
"enterprise_pipelines",
"integration_test_pipelines",
"oss_pipelines",
"publish_artifacts_pipelines",
@@ -33,8 +31,6 @@ load(
"publish_ci_build_container_image_pipeline",
"publish_ci_windows_test_image_pipeline",
)
load("scripts/drone/pipelines/github.star", "publish_github_pipeline")
load("scripts/drone/pipelines/aws_marketplace.star", "publish_aws_marketplace_pipeline")
load(
"scripts/drone/pipelines/windows.star",
"windows_test_backend",
@@ -48,13 +44,7 @@ def main(_ctx):
pr_pipelines() +
main_pipelines() +
oss_pipelines() +
enterprise_pipelines() +
enterprise2_pipelines() +
publish_image_pipelines_public() +
publish_github_pipeline("public") +
publish_github_pipeline("security") +
publish_aws_marketplace_pipeline("public") +
publish_artifacts_pipelines("security") +
publish_artifacts_pipelines("public") +
publish_npm_pipelines() +
publish_packages_pipeline() +
+42 -2611
View File
File diff suppressed because it is too large Load Diff
+25 -21
View File
@@ -33,23 +33,25 @@
/devenv/README.md @grafana/docs-grafana
# Technical documentation
/docs/ @Eve832 @jdbaldry
/docs/sources/ @Eve832
/docs/sources/administration/ @Eve832 @GrafanaWriter
/docs/sources/alerting/ @brendamuir
/docs/sources/dashboards/ @imatwawana
/docs/sources/datasources/ @lwandz13
/docs/sources/explore/ @Eve832 @GrafanaWriter
/docs/sources/fundamentals @chri2547
/docs/sources/getting-started/ @chri2547
/docs/sources/introduction/ @chri2547
/docs/sources/old-alerting/ @brendamuir
/docs/sources/panels-visualizations/ @imatwawana
/docs/sources/release-notes/ @Eve832 @GrafanaWriter
/docs/sources/setup-grafana/ @chri2547
/docs/sources/upgrade-guide/ @chri2547 @imatwawana
/docs/sources/whatsnew/ @chri2547 @imatwawana
/docs/sources/developers/plugins/ @Eve832 @josmperez @grafana/plugins-platform-frontend @grafana/plugins-platform-backend
# `make docs` procedure and related workflows are owned @grafana/docs-tooling. Slack #docs.
# Documentation sources might have different owners.
/docs/ @grafana/docs-tooling
/docs/sources/ @Eve832
/docs/sources/administration/ @Eve832 @GrafanaWriter
/docs/sources/alerting/ @brendamuir
/docs/sources/dashboards/ @imatwawana
/docs/sources/datasources/ @lwandz13
/docs/sources/explore/ @Eve832 @GrafanaWriter
/docs/sources/fundamentals @chri2547
/docs/sources/getting-started/ @chri2547
/docs/sources/introduction/ @chri2547
/docs/sources/old-alerting/ @brendamuir
/docs/sources/panels-visualizations/ @imatwawana
/docs/sources/release-notes/ @Eve832 @GrafanaWriter
/docs/sources/setup-grafana/ @chri2547
/docs/sources/upgrade-guide/ @chri2547 @imatwawana
/docs/sources/whatsnew/ @chri2547 @imatwawana
/docs/sources/developers/plugins/ @Eve832 @josmperez @grafana/plugins-platform-frontend @grafana/plugins-platform-backend
/docs/sources/developers/plugins/introduction-to-plugin-development/backend/ @Eve832 @grafana/plugins-platform-backend
# Backend code
@@ -596,7 +598,7 @@ embed.go @grafana/grafana-as-code
/.github/workflows/codeql-analysis.yml @DanCech
/.github/workflows/commands.yml @torkelo
/.github/workflows/detect-breaking-changes-* @grafana/plugins-platform-frontend
/.github/workflows/doc-validator.yml @grafana/docs-grafana
/.github/workflows/doc-validator.yml @grafana/docs-tooling
/.github/workflows/epic-add-to-platform-ux-parent-project.yml @meanmina
/.github/workflows/github-release.yml @torkelo
/.github/workflows/issue-labeled.yml @armandgrillet
@@ -611,21 +613,23 @@ embed.go @grafana/grafana-as-code
/.github/workflows/pr-commands-closed.yml @tolzhabayev
/.github/workflows/pr-commands.yml @marefr
/.github/workflows/pr-patch-check.yml @grafana/grafana-delivery
/.github/workflows/pr-patch-mirror-and-apply.yml @grafana/grafana-delivery
/.github/workflows/publish-technical-documentation-next.yml @grafana/docs-grafana
/.github/workflows/publish-technical-documentation-release.yml @grafana/docs-grafana
/.github/workflows/sync-mirror.yml @grafana/grafana-delivery
/.github/workflows/publish-technical-documentation-next.yml @grafana/docs-tooling
/.github/workflows/publish-technical-documentation-release.yml @grafana/docs-tooling
/.github/workflows/remove-milestone.yml @grafana/grafana-delivery
/.github/workflows/sbom-report.yml @grafana/security-team
/.github/workflows/scripts/json-file-to-job-output.js @grafana/plugins-platform-frontend
/.github/workflows/scripts/pr-get-job-link.js @grafana/plugins-platform-frontend
/.github/workflows/stale.yml @grafana/grafana-delivery
/.github/workflows/update-changelog.yml @grafana/grafana-delivery
/.github/workflows/update-make-docs.yml @grafana/docs-tooling
/.github/workflows/snyk.yml @grafana/security-team
/.github/workflows/scripts/kinds/verify-kinds.go @grafana/grafana-as-code
/.github/workflows/publish-kinds-next.yml @grafana/grafana-as-code
/.github/workflows/publish-kinds-release.yml @grafana/grafana-as-code
/.github/workflows/verify-kinds.yml @grafana/grafana-as-code
/.github/workflows/dashboards-issue-add-label.yml @grafana/dashboards-squad
/.github/workflows/ephemeral-instances.yml @grafana/grafana-operator-experience-squad
# Generated files not requiring owner approval
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
doc-validator:
runs-on: "ubuntu-latest"
container:
image: "grafana/doc-validator:v3.0.0"
image: "grafana/doc-validator:v3.2.0"
steps:
- name: "Checkout code"
uses: "actions/checkout@v3"
+81
View File
@@ -0,0 +1,81 @@
name: 'Ephemeral instances action'
on:
issue_comment:
types: [created]
pull_request:
types: [opened, reopened, closed]
jobs:
handle-pull-request-event:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Get pull request repo
if: github.triggering_actor == 'PoorlyDefinedBehaviour'
uses: actions/github-script@v6
id: get-pull-request-repo
with:
result-encoding: string
script: |
if(context.payload.pull_request?.head.ref) {
return context.payload.pull_request.head.ref
}
const response = await github.rest.pulls.get({
owner: "grafana",
repo: "grafana",
pull_number: context.payload.issue.number,
});
return response.data.head.ref
- name: Checkout Grafana
if: github.triggering_actor == 'PoorlyDefinedBehaviour'
uses: actions/checkout@v3
with:
repository: grafana/grafana
ref: ${{ steps.get-pull-request-repo.outputs.result }}
path: grafana
- name: Setup Go
if: github.triggering_actor == 'PoorlyDefinedBehaviour'
uses: actions/setup-go@v4
with:
go-version: '>=1.20'
- name: Generate a GitHub app installation token
if: github.triggering_actor == 'PoorlyDefinedBehaviour'
id: generate_token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ secrets.EI_APP_ID }}
private_key: ${{ secrets.EI_APP_PRIVATE_KEY }}
- name: Checkout ephemeral instances repository
uses: actions/checkout@v3
with:
repository: grafana/ephemeral-grafana-instances-github-action
token: ${{ steps.generate_token.outputs.token }}
ref: main
path: ephemeral
- name: Run action
if: github.triggering_actor == 'PoorlyDefinedBehaviour'
env:
GITHUB_EVENT: ${{ toJson(github.event)}}
run: |
GRAFANA_VERSION=10.1.0
cd $GITHUB_WORKSPACE/ephemeral/src
go run . \
-GITHUB_TOKEN="${{ steps.generate_token.outputs.token }}" \
-GITHUB_EVENT="$GITHUB_EVENT" \
-GITHUB_TRIGGERING_ACTOR="${{ github.triggering_actor }}" \
-GCOM_HOST="${{ secrets.EI_GCOM_HOST }}" \
-GCOM_TOKEN="${{ secrets.EI_GCOM_TOKEN }}" \
-HOSTED_GRAFANA_IMAGE_TAG="$GRAFANA_VERSION-ephemeral-grafana-${{ github.triggering_actor }}-${{ github.run_number }}-${{ github.run_attempt }}" \
-REGISTRY="${{ secrets.EI_EPHEMERAL_INSTANCES_REGISTRY }}" \
-GRAFANA_DIR="$GITHUB_WORKSPACE/grafana" \
-ENTERPRISE_REF="main" \
-GRAFANA_VERSION="$GRAFANA_VERSION" \
-GCP_SERVICE_ACCOUNT_KEY_BASE64="${{ secrets.EI_GCP_SERVICE_ACCOUNT_KEY_BASE64 }}" \
-EPHEMERAL_ORG_ID="${{ secrets.EI_EPHEMERAL_ORG_ID }}" || true
@@ -3,9 +3,8 @@
name: Sync to mirror
run-name: sync-to-mirror-${{ github.base_ref }}-${{ github.head_ref }}
on:
pull_request_target:
types:
- closed
workflow_dispatch:
push:
branches:
- "main"
- "v*.*.*"
@@ -15,10 +14,10 @@ on:
jobs:
trigger_downstream_patch_mirror:
concurrency: patch-mirror-${{ github.ref }}
if: github.event.pull_request.merged == true
uses: grafana/security-patch-actions/.github/workflows/mirror-branch-and-apply-patches.yml@main
if: github.repository == 'grafana/grafana'
with:
ref: "${{ github.base_ref }}" # this is the target branch name, Ex: "main"
ref: "${{ github.ref_name }}" # this is the target branch name, Ex: "main"
src_repo: "${{ github.repository }}"
dest_repo: "${{ github.repository }}-security-mirror"
patch_repo: "${{ github.repository }}-security-patches"
+27
View File
@@ -0,0 +1,27 @@
name: Update `make docs` procedure
on:
schedule:
- cron: '0 7 * * 1-5'
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Update procedure
if: github.repository != 'grafana/writers-toolkit'
run: |
curl -s -Lo docs/docs.mk https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/docs.mk
curl -s -Lo docs/make-docs https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/make-docs
if git diff --exit-code; then exit 0; fi
BRANCH="$(date +%Y-%m-%d)/update-make-docs"
git checkout -b "${BRANCH}"
git add .
git config --local user.email "bot@grafana.com"
git config --local user.name "grafanabot"
git commit -m "Update \`make docs\` procedure"
git push -v origin "refs/heads/${BRANCH}"
gh pr create --fill
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9
View File
@@ -600,6 +600,8 @@ tls_skip_verify_insecure = false
tls_client_cert =
tls_client_key =
tls_client_ca =
# GitHub OAuth apps does not provide refresh tokens and the access tokens never expires.
use_refresh_token = false
#################################### GitLab Auth #########################
[auth.gitlab]
@@ -625,6 +627,7 @@ tls_client_cert =
tls_client_key =
tls_client_ca =
use_pkce = true
use_refresh_token = true
#################################### Google Auth #########################
[auth.google]
@@ -647,6 +650,7 @@ tls_client_cert =
tls_client_key =
tls_client_ca =
use_pkce = true
use_refresh_token = true
#################################### Grafana.com Auth ####################
# legacy key names (so they work in env variables)
@@ -657,6 +661,7 @@ client_id = some_id
client_secret =
scopes = user:email
allowed_organizations =
use_refresh_token = false
[auth.grafana_com]
name = Grafana.com
@@ -669,6 +674,7 @@ client_secret =
scopes = user:email
allowed_organizations =
skip_org_role_sync = false
use_refresh_token = false
#################################### Azure AD OAuth #######################
[auth.azuread]
@@ -694,6 +700,7 @@ tls_client_key =
tls_client_ca =
use_pkce = true
skip_org_role_sync = false
use_refresh_token = true
#################################### Okta OAuth #######################
[auth.okta]
@@ -719,6 +726,7 @@ tls_client_cert =
tls_client_key =
tls_client_ca =
use_pkce = true
use_refresh_token = false
#################################### Generic OAuth #######################
[auth.generic_oauth]
@@ -756,6 +764,7 @@ use_pkce = false
auth_style =
allow_assign_grafana_admin = false
skip_org_role_sync = false
use_refresh_token = false
#################################### Basic Auth ##########################
[auth.basic]
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -63,20 +63,23 @@ SOURCES_as_code='as-code-docs'
SOURCES_enterprise_metrics='backend-enterprise'
SOURCES_enterprise_metrics_='backend-enterprise'
SOURCES_grafana_cloud='website'
SOURCES_grafana_cloud_alerting_and_irm_machine_learning='machine-learning'
SOURCES_grafana_cloud_alerting_and_irm_slo='slo'
SOURCES_grafana_cloud_k6='k6-docs'
SOURCES_grafana_cloud_data_configuration_integrations='cloud-onboarding'
SOURCES_grafana_cloud_frontend_observability_faro_web_sdk='faro-web-sdk'
SOURCES_grafana_cloud_machine_learning='machine-learning'
SOURCES_helm_charts_mimir_distributed='mimir'
SOURCES_helm_charts_tempo_distributed='tempo'
SOURCES_opentelemetry='opentelemetry-docs'
SOURCES_plugins_grafana_splunk_datasource='splunk-datasource'
VERSIONS_as_code='UNVERSIONED'
VERSIONS_grafana_cloud='UNVERSIONED'
VERSIONS_grafana_cloud_alerting_and_irm_machine_learning='UNVERSIONED'
VERSIONS_grafana_cloud_alerting_and_irm_slo='UNVERSIONED'
VERSIONS_grafana_cloud_k6='UNVERSIONED'
VERSIONS_grafana_cloud_data_configuration_integrations='UNVERSIONED'
VERSIONS_grafana_cloud_frontend_observability_faro_web_sdk='UNVERSIONED'
VERSIONS_grafana_cloud_machine_learning='UNVERSIONED'
VERSIONS_opentelemetry='UNVERSIONED'
VERSIONS_technical_documentation='UNVERSIONED'
VERSIONS_website='UNVERSIONED'
@@ -19,7 +19,7 @@ Grafana supports three types of plugins: [panels](/grafana/plugins?type=panel),
## Panel plugins
Add new visualizations to your dashboard with panel plugins, such as the [Worldmap Panel](/grafana/plugins/grafana-worldmap-panel), [Clock](/grafana/plugins/grafana-clock-panel), and [Pie Chart](/grafana/plugins/grafana-piechart-panel).
Add new visualizations to your dashboard with panel plugins, such as the [Clock](/grafana/plugins/grafana-clock-panel), [Mosaic](/grafana/plugins/boazreicher-mosaicplot-panel) and [Variable](/grafana/plugins/volkovlabs-variable-panel) panels.
Use panel plugins when you want to:
@@ -301,11 +301,11 @@ For example, if a value is within a certain range, you can configure a range val
Range to match against and the result to apply when the value is within the range
| Property | Type | Required | Default | Description |
|----------|-------------------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `from` | number or null | **Yes** | | Min value of the range. It can be null which means -Infinity<br/>Constraint: `>=-1.797693134862315708145274237317043567981E+308 & <=1.797693134862315708145274237317043567981E+308`. |
| `result` | [ValueMappingResult](#valuemappingresult) | **Yes** | | Result used as replacement with text and color when the value matches |
| `to` | number or null | **Yes** | | Max value of the range. It can be null which means +Infinity<br/>Constraint: `>=-1.797693134862315708145274237317043567981E+308 & <=1.797693134862315708145274237317043567981E+308`. |
| Property | Type | Required | Default | Description |
|----------|-------------------------------------------|----------|---------|-----------------------------------------------------------------------|
| `from` | number or null | **Yes** | | Min value of the range. It can be null which means -Infinity |
| `result` | [ValueMappingResult](#valuemappingresult) | **Yes** | | Result used as replacement with text and color when the value matches |
| `to` | number or null | **Yes** | | Max value of the range. It can be null which means +Infinity |
### ValueMappingResult
@@ -194,9 +194,9 @@ If you want to let users know that your app requires an existing plugin, you can
"plugins": [
{
"type": "panel",
"name": "Worldmap Panel",
"id": "grafana-worldmap-panel",
"version": "^0.3.2"
"name": "Clock Panel",
"id": "grafana-clock-panel",
"version": "^2.1.3"
}
]
}
@@ -46,6 +46,10 @@ Controls line width of state regions.
Controls the opacity of state regions.
{{< docs/shared "visualizations/connect-null-values.md" >}}
{{< docs/shared "visualizations/disconnect-values.md" >}}
## Value mappings
To assign colors to boolean or string values, you can use [Value mappings]({{< relref "../../configure-value-mappings/" >}}).
@@ -131,15 +131,9 @@ Set the style of the line. To change the color, use the standard [color scheme](
![Line styles examples](/static/img/docs/time-series-panel/line-styles-examples-v9.png)
### Connect null values
{{< docs/shared "visualizations/connect-null-values.md" >}}
Choose how null values, which are gaps in the data, appear on the graph. Null values can be connected to form a continuous line or set to a threshold above which gaps in the data are no longer connected.
![Connect null values option](/static/img/docs/time-series-panel/connect-null-values-option-v9.png)
- **Never:** Time series data points with gaps in the the data are never connected.
- **Always:** Time series data points with gaps in the the data are always connected.
- **Threshold:** Specify a threshold above which gaps in the data are no longer connected. This can be useful when the connected gaps in the data are of a known size and/or within a known range, and gaps outside this range should no longer be connected.
{{< docs/shared "visualizations/disconnect-values.md" >}}
### Stack series
@@ -827,7 +827,9 @@ Sets the default UI theme: `dark`, `light`, or `system`. The default theme is `d
### default_language
This setting configures the default UI language, which must be a supported IETF language tag, such as `en-US`.
This option will set the default UI language if a supported IETF language tag like `en-US` is available.
If set to `detect`, the default UI language will be determined by browser preference.
The default is `en-US`.
### home_page
@@ -126,6 +126,7 @@ Experimental features might be changed or removed without prior notice.
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore |
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
| `transformationsRedesign` | Enables the transformations redesign |
| `mlExpressions` | Enable support for Machine Learning in server-side expressions |
## Development feature toggles
@@ -21,6 +21,7 @@ The Azure AD authentication allows you to use an Azure Active Directory tenant a
- [Assign server administrator privileges](#assign-server-administrator-privileges)
- [Enable Azure AD OAuth in Grafana](#enable-azure-ad-oauth-in-grafana)
- [Configure refresh token](#configure-refresh-token)
- [Configure allowed tenants](#configure-allowed-tenants)
- [Configure allowed groups](#configure-allowed-groups)
- [Configure allowed domains](#configure-allowed-domains)
- [PKCE](#pkce)
@@ -176,7 +177,9 @@ When a user logs in using an OAuth provider, Grafana verifies that the access to
Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired.
To enable a refresh token for AzureAD, extend the `scopes` in `[auth.azuread]` with `offline_access`.
Refresh token fetching and access token expiration check is enabled by default for the AzureAD provider since Grafana v10.1.0 if the `accessTokenExpirationCheck` feature toggle is enabled. If you would like to disable access token expiration check then set the `use_refresh_token` configuration value to `false`.
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
### Configure allowed tenants
@@ -65,7 +65,9 @@ To integrate your OAuth2 provider with Grafana using our generic OAuth2 authenti
b. Extend the `scopes` field of `[auth.generic_oauth]` section in Grafana configuration file with refresh token scope used by your OAuth2 provider.
c. Enable the refresh token on the provider if required.
c. Set `use_refresh_token` to `true` in `[auth.generic_oauth]` section in Grafana configuration file.
d. Enable the refresh token on the provider if required.
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
@@ -113,6 +115,7 @@ The following table outlines the various generic OAuth2 configuration options. Y
| `tls_client_key` | No | The path to the key. | |
| `tls_client_ca` | No | The path to the trusted certificate authority list. | |
| `use_pkce` | No | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` |
| `use_refresh_token` | No | Set to `true` to use refresh token and check access token expiration. The `accessTokenExpirationCheck` feature toggle should also be enabled to use refresh token. | `false` |
### Configure login
@@ -169,11 +172,13 @@ When a user logs in using an OAuth2 provider, Grafana verifies that the access t
Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired.
To configure generic OAuth2 to use a refresh token, perform one or both of the following steps, if required:
To configure generic OAuth2 to use a refresh token, set `use_refresh_token` configuration option to `true` and perform one or both of the following steps, if required:
1. Extend the `scopes` field of `[auth.generic_oauth]` section in Grafana configuration file with additional scopes.
1. Enable the refresh token on the provider.
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
## Configure role mapping
Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the auth provider upon user login.
@@ -336,6 +341,7 @@ To set up generic OAuth2 authentication with Auth0, follow these steps:
token_url = https://<domain>/oauth/token
api_url = https://<domain>/userinfo
use_pkce = true
use_refresh_token = true
```
### Set up OAuth2 with Bitbucket
@@ -368,6 +374,7 @@ To set up generic OAuth2 authentication with Bitbucket, follow these steps:
team_ids_attribute_path = values[*].workspace.slug
team_ids =
allowed_organizations =
use_refresh_token = true
```
By default, a refresh token is included in the response for the **Authorization Code Grant**.
@@ -104,6 +104,10 @@ Grafana uses a refresh token to obtain a new access token without requiring the
By default, GitLab provides a refresh token.
Refresh token fetching and access token expiration check is enabled by default for the GitLab provider since Grafana v10.1.0 if the `accessTokenExpirationCheck` feature toggle is enabled. If you would like to disable access token expiration check then set the `use_refresh_token` configuration value to `false`.
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
### allowed_groups
To limit access to authenticated users that are members of one or more [GitLab
@@ -82,6 +82,10 @@ Grafana uses a refresh token to obtain a new access token without requiring the
By default, Grafana includes the `access_type=offline` parameter in the authorization request to request a refresh token.
Refresh token fetching and access token expiration check is enabled by default for the Google provider since Grafana v10.1.0 if the `accessTokenExpirationCheck` feature toggle is enabled. If you would like to disable access token expiration check then set the `use_refresh_token` configuration value to `false`.
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
### Configure automatic login
Set `auto_login` option to true to attempt login automatically, skipping the login screen.
@@ -75,7 +75,10 @@ When a user logs in using an OAuth provider, Grafana verifies that the access to
Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired.
1. To enable the `Refresh Token`, grant type in the `General Settings` section.
1. Extend the `scopes` in `[auth.okta]` with `offline_access`.
1. Extend the `scopes` in `[auth.okta]` with `offline_access` for Grafana versions between v9.3 and v10.0.x.
1. Set `use_refresh_token` in `[auth.okta]` to `true` for Grafana versions v10.1.0 and later.
> **Note:** The `accessTokenExpirationCheck` feature toggle will be removed in Grafana v10.2.0 and the `use_refresh_token` configuration value will be used instead for configuring refresh token fetching and access token expiration check.
### Configure allowed groups and domains
@@ -0,0 +1,13 @@
---
title: Connect null values
---
### Connect null values
Choose how null values, which are gaps in the data, appear on the graph. Null values can be connected to form a continuous line or set to a threshold above which gaps in the data are no longer connected.
![Connect null values option](/static/img/docs/time-series-panel/connect-null-values-option-v9.png)
- **Never:** Time series data points with gaps in the the data are never connected.
- **Always:** Time series data points with gaps in the the data are always connected.
- **Threshold:** Specify a threshold above which gaps in the data are no longer connected. This can be useful when the connected gaps in the data are of a known size and/or within a known range, and gaps outside this range should no longer be connected.
@@ -0,0 +1,12 @@
---
title: Disconnect values
---
### Disconnect values
Choose whether to set a threshold above which values in the data should be disconnected.
{{< figure src="/media/docs/grafana/screenshot-grafana-10-1-disconnect-values.png" max-width="750px" >}}
- **Never:** Time series data points in the the data are never disconnected.
- **Threshold:** Specify a threshold above which values in the data are disconnected. This can be useful when desired values in the data are of a known size and/or within a known range, and values outside this range should no longer be connected.
+4 -4
View File
@@ -1,11 +1,11 @@
# List of projects to provide to the make-docs script.
PROJECTS = grafana
PROJECTS := grafana
# Use the doc-validator image defined in CI by default.
export DOC_VALIDATOR_IMAGE := $(shell sed -En 's, *image: "(grafana/doc-validator[^"]+)",\1,p' "$(shell git rev-parse --show-toplevel)/.github/workflows/doc-validator.yml")
# Skip some doc-validator checks.
export DOC_VALIDATOR_SKIP_CHECKS := ^(?:image.+|canonical-does-not-match-pretty-URL)$
export DOC_VALIDATOR_SKIP_CHECKS := $(shell sed -En "s, *'--skip-checks=(.+)',\1,p" "$(shell git rev-parse --show-toplevel)/.github/workflows/doc-validator.yml")
# Use alternative image until make-docs 3.0.0 is rolled out.
export DOCS_IMAGE := grafana/docs-base:dbd975af06
# Only run on sections that have been enabled in CI.
export DOC_VALIDATOR_INCLUDE := $(shell sed -En "s, *'--include=\\^docs/sources/(.+)',/hugo/content/docs/grafana/latest/\1,p" "$(shell git rev-parse --show-toplevel)/.github/workflows/doc-validator.yml")
@@ -10,10 +10,7 @@ e2e.scenario({
e2e.flows.openDashboard({ uid: '5SdHCadmz', queryParams: { editPanel: 3 } });
e2e.components.Tab.title('Transform').should('be.visible').click();
// Flacky tests. Error: cy.click() failed because this element is detached from the DOM.
// The element is visible and clickable manually.
// e2e.components.TransformTab.newTransform('Reduce').scrollIntoView().should('be.visible').click();
// e2e.components.Transforms.Reduce.calculationsLabel().should('be.visible');
e2e.components.TransformTab.newTransform('Reduce').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.Reduce.calculationsLabel().should('be.visible');
},
});
+158 -158
View File
@@ -13,13 +13,13 @@ replace github.com/denisenkom/go-mssqldb => github.com/grafana/go-mssqldb v0.9.2
replace github.com/docker/docker => github.com/moby/moby v23.0.4+incompatible
// contains openapi encoder fixes. remove ASAP
replace cuelang.org/go => github.com/sdboyer/cue v0.5.0-beta.2.0.20230419165817-251c3ae823d8
replace cuelang.org/go => github.com/sdboyer/cue v0.5.0-beta.2.0.20230712135403-bdc4772ae055
// For some insane reason, client-go seems to have a broken v12.0.0 tag on it that forces us to
// hoist a replace statement.
replace k8s.io/client-go => k8s.io/client-go v0.25.3
require k8s.io/apimachinery v0.26.2
require k8s.io/apimachinery v0.26.2 // @grafana/backend-platform
// Override Prometheus version because Prometheus v2.X is tagged as v0.X for Go modules purposes and Go assumes
// that v1.Y is higher than v0.X, so when we resolve dependencies if any dependency imports v1.Y we'd
@@ -27,104 +27,104 @@ require k8s.io/apimachinery v0.26.2
replace github.com/prometheus/prometheus => github.com/prometheus/prometheus v0.43.0
require (
cloud.google.com/go/storage v1.28.1
cuelang.org/go v0.6.0-0.dev
github.com/Azure/azure-sdk-for-go v65.0.0+incompatible
github.com/Azure/go-autorest/autorest v0.11.28
github.com/BurntSushi/toml v1.2.1
github.com/Masterminds/semver v1.5.0
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f
github.com/aws/aws-sdk-go v1.44.234
github.com/beevik/etree v1.2.0
github.com/benbjohnson/clock v1.3.3
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
github.com/centrifugal/centrifuge v0.29.1
github.com/crewjam/saml v0.4.13
github.com/fatih/color v1.15.0
github.com/gchaincl/sqlhooks v1.3.0
github.com/go-git/go-git/v5 v5.4.2
github.com/go-ldap/ldap/v3 v3.4.4
github.com/go-openapi/strfmt v0.21.7
github.com/go-redis/redis/v8 v8.11.5
github.com/go-sourcemap/sourcemap v2.1.3+incompatible
github.com/go-sql-driver/mysql v1.7.1
github.com/go-stack/stack v1.8.1
github.com/gobwas/glob v0.2.3
cloud.google.com/go/storage v1.28.1 // @grafana/backend-platform
cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code
github.com/Azure/azure-sdk-for-go v65.0.0+incompatible // @grafana/backend-platform
github.com/Azure/go-autorest/autorest v0.11.28 // @grafana/backend-platform
github.com/BurntSushi/toml v1.2.1 // @grafana/grafana-authnz-team
github.com/Masterminds/semver v1.5.0 // @grafana/backend-platform
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/backend-platform
github.com/aws/aws-sdk-go v1.44.234 // @grafana/aws-datasources
github.com/beevik/etree v1.2.0 // @grafana/backend-platform
github.com/benbjohnson/clock v1.3.3 // @grafana/alerting-squad-backend
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // @grafana/backend-platform
github.com/centrifugal/centrifuge v0.29.1 // @grafana/grafana-app-platform-squad
github.com/crewjam/saml v0.4.13 // @grafana/backend-platform
github.com/fatih/color v1.15.0 // @grafana/backend-platform
github.com/gchaincl/sqlhooks v1.3.0 // @grafana/backend-platform
github.com/go-git/go-git/v5 v5.4.2 // @grafana/grafana-app-platform-squad
github.com/go-ldap/ldap/v3 v3.4.4 // @grafana/grafana-authnz-team
github.com/go-openapi/strfmt v0.21.7 // @grafana/alerting-squad-backend
github.com/go-redis/redis/v8 v8.11.5 // @grafana/backend-platform
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // @grafana/backend-platform
github.com/go-sql-driver/mysql v1.7.1 // @grafana/backend-platform
github.com/go-stack/stack v1.8.1 // @grafana/backend-platform
github.com/gobwas/glob v0.2.3 // @grafana/backend-platform
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2
github.com/golang/mock v1.6.0
github.com/golang/snappy v0.0.4
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0
github.com/gorilla/websocket v1.5.0
github.com/grafana/alerting v0.0.0-20230606080147-55b8d71c7890
github.com/grafana/cuetsy v0.1.9
github.com/grafana/grafana-aws-sdk v0.15.0
github.com/grafana/grafana-azure-sdk-go v1.7.0
github.com/grafana/grafana-plugin-sdk-go v0.165.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/hashicorp/go-hclog v1.5.0
github.com/hashicorp/go-plugin v1.4.9
github.com/hashicorp/go-version v1.3.0
github.com/influxdata/influxdb-client-go/v2 v2.6.0
github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097
github.com/jmespath/go-jmespath v0.4.0
github.com/json-iterator/go v1.1.12
github.com/jung-kurt/gofpdf v1.16.2
github.com/lib/pq v1.10.6
github.com/linkedin/goavro/v2 v2.10.0
github.com/m3db/prometheus_remote_client_golang v0.4.4
github.com/magefile/mage v1.14.0
github.com/mattn/go-isatty v0.0.18
github.com/mattn/go-sqlite3 v1.14.16
github.com/matttproud/golang_protobuf_extensions v1.0.4
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f
github.com/gogo/protobuf v1.3.2 // @grafana/alerting-squad-backend
github.com/golang/mock v1.6.0 // @grafana/alerting-squad-backend
github.com/golang/snappy v0.0.4 // @grafana/alerting-squad-backend
github.com/google/go-cmp v0.5.9 // @grafana/backend-platform
github.com/google/uuid v1.3.0 // @grafana/backend-platform
github.com/google/wire v0.5.0 // @grafana/backend-platform
github.com/gorilla/websocket v1.5.0 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20230606080147-55b8d71c7890 // @grafana/alerting-squad-backend
github.com/grafana/cuetsy v0.1.10 // @grafana/grafana-as-code
github.com/grafana/grafana-aws-sdk v0.15.0 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go v1.7.0 // @grafana/backend-platform
github.com/grafana/grafana-plugin-sdk-go v0.165.0 // @grafana/alerting-squad-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
github.com/hashicorp/go-version v1.3.0 // @grafana/backend-platform
github.com/influxdata/influxdb-client-go/v2 v2.6.0 // @grafana/observability-metrics
github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 // @grafana/grafana-app-platform-squad
github.com/jmespath/go-jmespath v0.4.0 // @grafana/backend-platform
github.com/json-iterator/go v1.1.12 // @grafana/backend-platform
github.com/jung-kurt/gofpdf v1.16.2 // @grafana/backend-platform
github.com/lib/pq v1.10.6 // @grafana/backend-platform
github.com/linkedin/goavro/v2 v2.10.0 // @grafana/backend-platform
github.com/m3db/prometheus_remote_client_golang v0.4.4 // @grafana/backend-platform
github.com/magefile/mage v1.14.0 // @grafana/grafana-delivery
github.com/mattn/go-isatty v0.0.18 // @grafana/backend-platform
github.com/mattn/go-sqlite3 v1.14.16 // @grafana/backend-platform
github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-squad-backend
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // @grafana/grafana-operator-experience-squad
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/patrickmn/go-cache v2.1.0+incompatible // @grafana/alerting-squad-backend
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/alertmanager v0.25.0
github.com/prometheus/client_golang v1.15.1
github.com/prometheus/client_model v0.4.0
github.com/prometheus/common v0.43.0
github.com/prometheus/prometheus v1.8.2-0.20210621150501-ff58416a0b02
github.com/robfig/cron/v3 v3.0.1
github.com/russellhaering/goxmldsig v1.4.0
github.com/stretchr/testify v1.8.4
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f
github.com/prometheus/alertmanager v0.25.0 // @grafana/alerting-squad-backend
github.com/prometheus/client_golang v1.15.1 // @grafana/alerting-squad-backend
github.com/prometheus/client_model v0.4.0 // @grafana/backend-platform
github.com/prometheus/common v0.43.0 // @grafana/alerting-squad-backend
github.com/prometheus/prometheus v1.8.2-0.20210621150501-ff58416a0b02 // @grafana/alerting-squad-backend
github.com/robfig/cron/v3 v3.0.1 // @grafana/backend-platform
github.com/russellhaering/goxmldsig v1.4.0 // @grafana/backend-platform
github.com/stretchr/testify v1.8.4 // @grafana/backend-platform
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf // @grafana/backend-platform
github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f // @grafana/backend-platform
github.com/uber/jaeger-client-go v2.29.1+incompatible // indirect
github.com/urfave/cli/v2 v2.25.0
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae
github.com/yalue/merged_fs v1.2.2
github.com/yudai/gojsondiff v1.0.0
go.opentelemetry.io/collector v0.31.0
go.opentelemetry.io/collector/model v0.31.0
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.37.0
go.opentelemetry.io/otel v1.14.0
go.opentelemetry.io/otel/exporters/jaeger v1.0.0
go.opentelemetry.io/otel/sdk v1.14.0
go.opentelemetry.io/otel/trace v1.14.0
golang.org/x/crypto v0.11.0
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
golang.org/x/net v0.10.0
golang.org/x/oauth2 v0.7.0
golang.org/x/sync v0.3.0
golang.org/x/time v0.3.0
golang.org/x/tools v0.7.0
gonum.org/v1/gonum v0.11.0
google.golang.org/api v0.111.0
google.golang.org/grpc v1.55.0
google.golang.org/protobuf v1.30.0
github.com/urfave/cli/v2 v2.25.0 // @grafana/backend-platform
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae // @grafana/backend-platform
github.com/yalue/merged_fs v1.2.2 // @grafana/grafana-as-code
github.com/yudai/gojsondiff v1.0.0 // @grafana/backend-platform
go.opentelemetry.io/collector v0.31.0 // @grafana/backend-platform
go.opentelemetry.io/collector/model v0.31.0 // @grafana/backend-platform
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.37.0 // @grafana/grafana-operator-experience-squad
go.opentelemetry.io/otel v1.14.0 // @grafana/alerting-squad-backend
go.opentelemetry.io/otel/exporters/jaeger v1.0.0 // @grafana/backend-platform
go.opentelemetry.io/otel/sdk v1.14.0 // @grafana/backend-platform
go.opentelemetry.io/otel/trace v1.14.0 // @grafana/backend-platform
golang.org/x/crypto v0.11.0 // @grafana/backend-platform
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // @grafana/alerting-squad-backend
golang.org/x/net v0.10.0 // @grafana/grafana-bi-squad
golang.org/x/oauth2 v0.7.0 // @grafana/grafana-authnz-team
golang.org/x/sync v0.3.0 // @grafana/alerting-squad-backend
golang.org/x/time v0.3.0 // @grafana/backend-platform
golang.org/x/tools v0.7.0 // @grafana/grafana-as-code
gonum.org/v1/gonum v0.11.0 // @grafana/observability-metrics
google.golang.org/api v0.111.0 // @grafana/backend-platform
google.golang.org/grpc v1.55.0 // @grafana/plugins-platform-backend
google.golang.org/protobuf v1.30.0 // @grafana/plugins-platform-backend
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-squad-backend
gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1
xorm.io/builder v0.3.6
xorm.io/core v0.7.3
xorm.io/xorm v0.8.2
gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-squad-backend
xorm.io/builder v0.3.6 // indirect
xorm.io/core v0.7.3 // @grafana/backend-platform
xorm.io/xorm v0.8.2 // @grafana/alerting-squad-backend
)
require (
@@ -137,7 +137,7 @@ require (
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/FZambia/eagle v0.0.2 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/andybalholm/brotli v1.0.4
github.com/andybalholm/brotli v1.0.4 // @grafana/partner-datasources
github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -152,31 +152,31 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/emicklei/proto v1.10.0 // indirect
github.com/go-kit/log v0.2.1
github.com/go-kit/log v0.2.1 // @grafana/backend-platform
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-openapi/analysis v0.21.4 // indirect
github.com/go-openapi/errors v0.20.3 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/loads v0.21.2
github.com/go-openapi/loads v0.21.2 // @grafana/alerting-squad-backend
github.com/go-openapi/runtime v0.25.0 // indirect
github.com/go-openapi/spec v0.20.8 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-openapi/validate v0.22.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang-jwt/jwt/v4 v4.5.0 // @grafana/backend-platform
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3
github.com/golang/protobuf v1.5.3 // @grafana/backend-platform
github.com/google/btree v1.1.2 // indirect
github.com/google/flatbuffers v2.0.8+incompatible // indirect
github.com/googleapis/gax-go/v2 v2.7.0
github.com/googleapis/gax-go/v2 v2.7.0 // @grafana/backend-platform
github.com/gorilla/mux v1.8.0 // indirect
github.com/grafana/grafana-google-sdk-go v0.1.0
github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
github.com/hashicorp/golang-lru v0.6.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
@@ -196,7 +196,7 @@ require (
github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/olekukonko/tablewriter v0.0.5
github.com/olekukonko/tablewriter v0.0.5 // @grafana/backend-platform
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/common/sigv4 v0.1.0 // indirect
github.com/prometheus/exporter-toolkit v0.9.1 // indirect
@@ -215,63 +215,64 @@ require (
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
go.mongodb.org/mongo-driver v1.11.3 // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.10.0
go.uber.org/atomic v1.10.0 // @grafana/alerting-squad-backend
go.uber.org/goleak v1.2.1 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0
golang.org/x/text v0.11.0 // @grafana/backend-platform
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // @grafana/backend-platform
)
require (
cloud.google.com/go/kms v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.0
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0
github.com/Azure/azure-storage-blob-go v0.15.0
github.com/Azure/go-autorest/autorest/adal v0.9.22
github.com/armon/go-radix v1.0.0
github.com/blugelabs/bluge v0.1.9
github.com/blugelabs/bluge_segment_api v0.2.0
github.com/bufbuild/connect-go v1.4.1
github.com/dlmiddlecote/sqlstats v1.0.2
github.com/drone/drone-cli v1.6.1
github.com/getkin/kin-openapi v0.115.0
github.com/golang-migrate/migrate/v4 v4.7.0
github.com/google/go-github/v45 v45.2.0
github.com/grafana/codejen v0.0.3
github.com/grafana/dskit v0.0.0-20230620150242-3dc2113b720d
github.com/grafana/phlare/api v0.1.4-0.20230426005640-f90edba05413
github.com/huandu/xstrings v1.3.1
github.com/jmoiron/sqlx v1.3.5
github.com/matryer/is v1.4.0
github.com/urfave/cli v1.22.12
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.40.0
go.opentelemetry.io/contrib/propagators/jaeger v1.15.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.14.0
gocloud.dev v0.25.0
cloud.google.com/go/kms v1.4.0 // @grafana/backend-platform
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.0 // @grafana/backend-platform
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.9.0 // @grafana/backend-platform
github.com/Azure/azure-storage-blob-go v0.15.0 // @grafana/backend-platform
github.com/Azure/go-autorest/autorest/adal v0.9.22 // @grafana/backend-platform
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
github.com/blugelabs/bluge v0.1.9 // @grafana/backend-platform
github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/backend-platform
github.com/bufbuild/connect-go v1.4.1 // @grafana/observability-traces-and-profiling
github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/backend-platform
github.com/drone/drone-cli v1.6.1 // @grafana/grafana-delivery
github.com/getkin/kin-openapi v0.115.0 // @grafana/grafana-operator-experience-squad
github.com/golang-migrate/migrate/v4 v4.7.0 // @grafana/backend-platform
github.com/google/go-github/v45 v45.2.0 // @grafana/grafana-delivery
github.com/grafana/codejen v0.0.3 // @grafana/dataviz-squad
github.com/grafana/dskit v0.0.0-20230620150242-3dc2113b720d // @grafana/backend-platform
github.com/grafana/phlare/api v0.1.4-0.20230426005640-f90edba05413 // @grafana/observability-traces-and-profiling
github.com/huandu/xstrings v1.3.1 // @grafana/partner-datasources
github.com/jmoiron/sqlx v1.3.5 // @grafana/backend-platform
github.com/matryer/is v1.4.0 // @grafana/grafana-as-code
github.com/urfave/cli v1.22.12 // @grafana/backend-platform
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.40.0 // @grafana/plugins-platform-backend
go.opentelemetry.io/contrib/propagators/jaeger v1.15.0 // @grafana/backend-platform
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // @grafana/backend-platform
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.14.0 // @grafana/backend-platform
gocloud.dev v0.25.0 // @grafana/grafana-app-platform-squad
)
require (
buf.build/gen/go/parca-dev/parca/bufbuild/connect-go v1.4.1-20221222094228-8b1d3d0f62e6.1
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.28.1-20221222094228-8b1d3d0f62e6.4
github.com/Masterminds/semver/v3 v3.1.1
github.com/alicebob/miniredis/v2 v2.30.1
github.com/dave/dst v0.27.2
github.com/go-jose/go-jose/v3 v3.0.0
github.com/grafana/dataplane/examples v0.0.0-20230404174214-4d6fd58a18ad
github.com/grafana/dataplane/sdata v0.0.6
github.com/grafana/go-mssqldb v0.9.1
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482
github.com/grafana/thema v0.0.0-20230615161902-b6e21996aef8
github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f
github.com/redis/go-redis/v9 v9.0.2
github.com/weaveworks/common v0.0.0-20230511094633-334485600903
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f
go.opentelemetry.io/contrib/samplers/jaegerremote v0.9.0
gopkg.in/square/go-jose.v2 v2.5.2-0.20210529014059-a5c7eec3c614
k8s.io/utils v0.0.0-20230308161112-d77c459e9343
buf.build/gen/go/parca-dev/parca/bufbuild/connect-go v1.4.1-20221222094228-8b1d3d0f62e6.1 // @grafana/observability-traces-and-profiling
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.28.1-20221222094228-8b1d3d0f62e6.4 // @grafana/observability-traces-and-profiling
github.com/Masterminds/semver/v3 v3.1.1 // @grafana/grafana-delivery
github.com/alicebob/miniredis/v2 v2.30.1 // @grafana/alerting-squad-backend
github.com/dave/dst v0.27.2 // @grafana/grafana-as-code
github.com/go-jose/go-jose/v3 v3.0.0 // @grafana/backend-platform
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
github.com/grafana/dataplane/sdata v0.0.6 // @grafana/observability-metrics
github.com/grafana/go-mssqldb v0.9.1 // @grafana/grafana-bi-squad
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 // @grafana/grafana-as-code
github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed // @grafana/grafana-as-code
github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f // @grafana/grafana-authnz-team
github.com/redis/go-redis/v9 v9.0.2 // @grafana/alerting-squad-backend
github.com/weaveworks/common v0.0.0-20230511094633-334485600903 // @grafana/alerting-squad-backend
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code
go.opentelemetry.io/contrib/samplers/jaegerremote v0.9.0 // @grafana/backend-platform
golang.org/x/mod v0.9.0 // @grafana/backend-platform
gopkg.in/square/go-jose.v2 v2.5.2-0.20210529014059-a5c7eec3c614 // @grafana/grafana-authnz-team
k8s.io/utils v0.0.0-20230308161112-d77c459e9343 // @grafana/partner-datasources
)
require (
@@ -368,13 +369,13 @@ require (
require (
cloud.google.com/go/compute v1.18.0 // indirect
cloud.google.com/go/iam v0.8.0 // indirect
filippo.io/age v1.1.1
filippo.io/age v1.1.1 // @grafana/grafana-authnz-team
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v0.7.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.2
github.com/Masterminds/sprig/v3 v3.2.2 // @grafana/backend-platform
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756
github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 // @grafana/plugins-platform-backend
github.com/RoaringBitmap/roaring v0.9.4 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f // indirect
@@ -388,7 +389,7 @@ require (
github.com/caio/go-tdigest v3.1.0+incompatible // indirect
github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect
github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect
github.com/docker/docker v23.0.4+incompatible
github.com/docker/docker v23.0.4+incompatible // @grafana/grafana-delivery
github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect
@@ -396,9 +397,9 @@ require (
github.com/go-git/go-billy/v5 v5.3.1 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-github v17.0.0+incompatible
github.com/google/go-github v17.0.0+incompatible // @grafana/grafana-app-platform-squad
github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.2 // indirect
github.com/hmarr/codeowners v1.1.2
github.com/hmarr/codeowners v1.1.2 // @grafana/grafana-as-code
github.com/imdario/mergo v0.3.13 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
@@ -411,12 +412,11 @@ require (
github.com/mschoch/smat v0.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wk8/go-ordered-map v1.0.0
github.com/wk8/go-ordered-map v1.0.0 // @grafana/backend-platform
github.com/xanzy/ssh-agent v0.3.0 // indirect
github.com/xlab/treeprint v1.1.0
github.com/xlab/treeprint v1.1.0 // @grafana/observability-traces-and-profiling
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0 // indirect
go.opentelemetry.io/proto/otlp v0.19.0 // indirect
golang.org/x/mod v0.9.0
gopkg.in/warnings.v0 v0.1.2 // indirect
)
+8 -6
View File
@@ -1360,10 +1360,12 @@ github.com/grafana/alerting v0.0.0-20230606080147-55b8d71c7890 h1:ubNIgVGX4PQ9YI
github.com/grafana/alerting v0.0.0-20230606080147-55b8d71c7890/go.mod h1:zEflOvMVchYhRbFb5ziXVR/JG67FOLBzQTjhHh9xaI4=
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cuetsy v0.1.9 h1:EwT8BqHoC0B3B4Y6Lg/D1aeYUKnQC1+2jjOHNsOuUfU=
github.com/grafana/cuetsy v0.1.9/go.mod h1:Ix97+CPD8ws9oSSxR3/Lf4ahU1I4Np83kjJmDVnLZvc=
github.com/grafana/cuetsy v0.1.10 h1:+W9/7roI8LorL+D1RJhKGdhsTZ81adrK9dHS0r7qsXs=
github.com/grafana/cuetsy v0.1.10/go.mod h1:Ix97+CPD8ws9oSSxR3/Lf4ahU1I4Np83kjJmDVnLZvc=
github.com/grafana/dataplane/examples v0.0.0-20230404174214-4d6fd58a18ad h1:bROIOQEkRlxAt+Epn9Pos8ytkVqoIAvnaUZOAb7EgEU=
github.com/grafana/dataplane/examples v0.0.0-20230404174214-4d6fd58a18ad/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg=
github.com/grafana/dataplane/examples v0.0.1 h1:K9M5glueWyLoL4//H+EtTQq16lXuHLmOhb6DjSCahzA=
github.com/grafana/dataplane/examples v0.0.1/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg=
github.com/grafana/dataplane/sdata v0.0.6 h1:Ejlj8d1Hvy/uDLeI4sOvL34Y8WLlVDd9iN270F+8aTw=
github.com/grafana/dataplane/sdata v0.0.6/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU=
github.com/grafana/dskit v0.0.0-20230620150242-3dc2113b720d h1:1w9c1/z4JypJUatX+rPIiK3eTAV7+OF094UkDIPDTIs=
@@ -1394,8 +1396,8 @@ github.com/grafana/saml v0.4.13-0.20230331080031-67cbfa09c7b6 h1:oHn/OOUkECNX06D
github.com/grafana/saml v0.4.13-0.20230331080031-67cbfa09c7b6/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA=
github.com/grafana/sqlds/v2 v2.3.10 h1:HWKhE0vR6LoEiE+Is8CSZOgaB//D1yqb2ntkass9Fd4=
github.com/grafana/sqlds/v2 v2.3.10/go.mod h1:c6ibxnxRVGxV/0YkEgvy7QpQH/lyifFyV7K/14xvdIs=
github.com/grafana/thema v0.0.0-20230615161902-b6e21996aef8 h1:ESVwKjvGWVucGY65rJq0Hny3a6p8UYqEbClqXhP2qJk=
github.com/grafana/thema v0.0.0-20230615161902-b6e21996aef8/go.mod h1:KWAKeFXxQYiJ/kBVbijBLRVq9atxkfkeeFIvmj4clEA=
github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed h1:TMtHc+B0SSNw2in6Ro1dAiBYSPRp4NzKgndFDfupt18=
github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed/go.mod h1:3zLJnssFRPCnebCBRlq53t5LgYv9P1mbj0XMozZMTww=
github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6 h1:I9dh1MXGX0wGyxdV/Sl7+ugnki4Dfsy8lv2s5Yf887o=
github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
@@ -2247,8 +2249,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.14 h1:yFl3jyaSVLNYXlnNYM5z2pagEk1dYQhfr1p20T1NyKY=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.14/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg=
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
github.com/sdboyer/cue v0.5.0-beta.2.0.20230419165817-251c3ae823d8 h1:R7AphSCwo1rqpRCftz9wn2azIGAtdgn1AjxTiSdFJ1o=
github.com/sdboyer/cue v0.5.0-beta.2.0.20230419165817-251c3ae823d8/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws=
github.com/sdboyer/cue v0.5.0-beta.2.0.20230712135403-bdc4772ae055 h1:ilCOPMIGucstzXKgCVzAT8g6tu+Ejyk0J1HhXZN2xm0=
github.com/sdboyer/cue v0.5.0-beta.2.0.20230712135403-bdc4772ae055/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210219220335-367fa274be2c/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg=
+21 -20
View File
@@ -100,9 +100,9 @@
"@betterer/cli": "5.4.0",
"@betterer/eslint": "5.4.0",
"@betterer/regexp": "5.4.0",
"@emotion/eslint-plugin": "11.10.0",
"@emotion/eslint-plugin": "11.11.0",
"@grafana/e2e": "workspace:*",
"@grafana/eslint-config": "5.1.0",
"@grafana/eslint-config": "6.0.0",
"@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules",
"@grafana/toolkit": "workspace:*",
"@grafana/tsconfig": "^1.3.0-rc1",
@@ -146,9 +146,9 @@
"@types/papaparse": "5.3.7",
"@types/pluralize": "^0.0.29",
"@types/prismjs": "1.26.0",
"@types/react": "18.0.28",
"@types/react": "18.2.15",
"@types/react-beautiful-dnd": "13.1.3",
"@types/react-dom": "18.0.11",
"@types/react-dom": "18.2.7",
"@types/react-grid-layout": "1.3.2",
"@types/react-highlight-words": "0.16.4",
"@types/react-redux": "7.1.25",
@@ -181,7 +181,7 @@
"codeowners": "^5.1.1",
"copy-webpack-plugin": "11.0.0",
"css-loader": "6.8.1",
"css-minimizer-webpack-plugin": "4.2.2",
"css-minimizer-webpack-plugin": "5.0.1",
"cypress": "9.5.1",
"esbuild": "0.18.6",
"esbuild-loader": "3.0.1",
@@ -209,7 +209,7 @@
"jest-date-mock": "1.0.8",
"jest-environment-jsdom": "29.3.1",
"jest-fail-on-console": "3.1.1",
"jest-junit": "15.0.0",
"jest-junit": "16.0.0",
"jest-matcher-utils": "29.3.1",
"lerna": "5.5.4",
"lint-staged": "13.2.3",
@@ -229,7 +229,7 @@
"react-test-renderer": "18.2.0",
"redux-mock-store": "1.5.4",
"rimraf": "5.0.1",
"rudder-sdk-js": "2.25.0",
"rudder-sdk-js": "2.37.0",
"sass": "1.63.6",
"sass-loader": "13.3.2",
"sinon": "15.2.0",
@@ -254,8 +254,8 @@
},
"dependencies": {
"@daybrush/utils": "1.13.0",
"@emotion/css": "11.10.6",
"@emotion/react": "11.10.6",
"@emotion/css": "11.11.2",
"@emotion/react": "11.11.1",
"@glideapps/glide-data-grid": "^5.2.1",
"@grafana/aws-sdk": "0.0.44",
"@grafana/data": "workspace:*",
@@ -300,12 +300,12 @@
"@types/react-resizable": "3.0.3",
"@types/trusted-types": "2.0.3",
"@types/webpack-env": "1.18.0",
"@visx/event": "3.0.1",
"@visx/gradient": "3.0.0",
"@visx/group": "3.0.0",
"@visx/scale": "3.0.0",
"@visx/shape": "3.0.0",
"@visx/tooltip": "3.1.2",
"@visx/event": "3.3.0",
"@visx/gradient": "3.3.0",
"@visx/group": "3.3.0",
"@visx/scale": "3.3.0",
"@visx/shape": "3.3.0",
"@visx/tooltip": "3.3.0",
"@welldone-software/why-did-you-render": "7.0.1",
"angular": "1.8.3",
"angular-bindonce": "0.3.1",
@@ -316,7 +316,7 @@
"baron": "3.0.3",
"brace": "0.11.1",
"calculate-size": "1.1.1",
"centrifuge": "3.1.0",
"centrifuge": "4.0.1",
"classnames": "2.3.2",
"combokeys": "^3.0.0",
"comlink": "4.4.1",
@@ -337,7 +337,8 @@
"history": "4.10.1",
"hoist-non-react-statics": "3.3.2",
"i18next": "^22.0.0",
"immer": "9.0.19",
"i18next-browser-languagedetector": "^7.0.2",
"immer": "10.0.2",
"immutable": "4.3.0",
"jquery": "3.7.0",
"js-yaml": "^4.1.0",
@@ -347,7 +348,7 @@
"kbar": "0.1.0-beta.40",
"lodash": "4.17.21",
"logfmt": "^1.3.2",
"lru-cache": "7.17.0",
"lru-cache": "10.0.0",
"lru-memoize": "^1.1.0",
"marked": "^4.3.0",
"memoize-one": "6.0.0",
@@ -408,7 +409,7 @@
"reselect": "4.1.7",
"rxjs": "7.8.0",
"sass": "link:./public/sass",
"selecto": "1.22.0",
"selecto": "1.26.0",
"semver": "7.5.4",
"slate": "0.47.9",
"slate-plain-serializer": "0.7.13",
@@ -433,7 +434,7 @@
"@rushstack/rig-package": "0.4.0",
"@rushstack/ts-command-line": "4.15.1",
"ngtemplate-loader/loader-utils": "^2.0.0",
"trim": "0.0.3",
"trim": "1.0.1",
"slate-dev-environment@^0.2.2": "patch:slate-dev-environment@npm:0.2.5#.yarn/patches/slate-dev-environment-npm-0.2.5-9aeb7da7b5.patch",
"react-split-pane@0.1.92": "patch:react-split-pane@npm:0.1.92#.yarn/patches/react-split-pane-npm-0.1.92-93dbf51dff.patch",
"@storybook/blocks@7.0.10": "patch:@storybook/blocks@npm%3A7.0.10#./.yarn/patches/@storybook-blocks-npm-7.0.10-b101e8dcba.patch"
+4 -4
View File
@@ -62,8 +62,8 @@
},
"devDependencies": {
"@grafana/tsconfig": "^1.2.0-rc1",
"@rollup/plugin-commonjs": "23.0.2",
"@rollup/plugin-json": "5.0.1",
"@rollup/plugin-commonjs": "25.0.2",
"@rollup/plugin-json": "6.0.0",
"@rollup/plugin-node-resolve": "15.1.0",
"@testing-library/dom": "9.3.0",
"@testing-library/jest-dom": "5.16.5",
@@ -77,8 +77,8 @@
"@types/marked": "4.0.8",
"@types/node": "18.16.16",
"@types/papaparse": "5.3.7",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"@types/sinon": "10.0.13",
"@types/testing-library__jest-dom": "5.14.5",
"@types/tinycolor2": "1.4.3",
@@ -113,4 +113,5 @@ export interface FeatureToggles {
logsExploreTableVisualisation?: boolean;
awsDatasourcesTempCredentials?: boolean;
transformationsRedesign?: boolean;
mlExpressions?: boolean;
}
+1 -1
View File
@@ -39,7 +39,7 @@
"postpack": "mv package.json.bak package.json"
},
"devDependencies": {
"@rollup/plugin-commonjs": "23.0.2",
"@rollup/plugin-commonjs": "25.0.2",
"@rollup/plugin-node-resolve": "15.1.0",
"@types/node": "18.16.16",
"esbuild": "0.18.6",
+3 -3
View File
@@ -49,7 +49,7 @@
},
"devDependencies": {
"@grafana/tsconfig": "^1.2.0-rc1",
"@rollup/plugin-commonjs": "23.0.2",
"@rollup/plugin-commonjs": "25.0.2",
"@rollup/plugin-node-resolve": "15.1.0",
"@testing-library/dom": "9.3.0",
"@testing-library/react": "14.0.0",
@@ -58,8 +58,8 @@
"@types/history": "4.7.11",
"@types/jest": "29.2.3",
"@types/lodash": "4.14.191",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"@types/systemjs": "^0.20.6",
"esbuild": "0.18.6",
"lodash": "4.17.21",
+2 -2
View File
@@ -37,8 +37,8 @@
},
"devDependencies": {
"@grafana/tsconfig": "^1.2.0-rc1",
"@rollup/plugin-commonjs": "23.0.2",
"@rollup/plugin-json": "5.0.1",
"@rollup/plugin-commonjs": "25.0.2",
"@rollup/plugin-json": "6.0.0",
"@rollup/plugin-node-resolve": "15.1.0",
"esbuild": "0.18.6",
"glob": "^10.2.7",
+6 -5
View File
@@ -47,8 +47,8 @@
"not IE 11"
],
"dependencies": {
"@emotion/css": "11.10.6",
"@emotion/react": "11.10.6",
"@emotion/css": "11.11.2",
"@emotion/react": "11.11.1",
"@grafana/data": "10.1.0-pre",
"@grafana/e2e-selectors": "10.1.0-pre",
"@grafana/faro-web-sdk": "1.1.0",
@@ -71,6 +71,7 @@
"date-fns": "2.30.0",
"hoist-non-react-statics": "3.3.2",
"i18next": "^22.0.0",
"i18next-browser-languagedetector": "^7.0.2",
"immutable": "4.3.0",
"is-hotkey": "0.2.0",
"jquery": "3.7.0",
@@ -84,7 +85,7 @@
"rc-drawer": "6.3.0",
"rc-slider": "10.2.1",
"rc-time-picker": "^3.7.3",
"rc-tooltip": "5.3.1",
"rc-tooltip": "6.0.1",
"react-beautiful-dnd": "13.1.1",
"react-calendar": "4.0.0",
"react-colorful": "5.6.1",
@@ -147,11 +148,11 @@
"@types/mock-raf": "1.0.3",
"@types/node": "18.16.16",
"@types/prismjs": "1.26.0",
"@types/react": "18.0.28",
"@types/react": "18.2.15",
"@types/react-beautiful-dnd": "13.1.3",
"@types/react-calendar": "3.9.0",
"@types/react-color": "3.0.6",
"@types/react-dom": "18.0.11",
"@types/react-dom": "18.2.7",
"@types/react-highlight-words": "0.16.4",
"@types/react-router-dom": "5.3.3",
"@types/react-table": "7.7.14",
@@ -101,6 +101,7 @@ Alert.displayName = 'Alert';
export const getIconFromSeverity = (severity: AlertVariant): IconName => {
switch (severity) {
case 'error':
return 'exclamation-circle';
case 'warning':
return 'exclamation-triangle';
case 'info':
@@ -64,7 +64,7 @@ const ButtonSelectComponent = <T,>(props: Props<T>) => {
tabIndex=-1 is needed here to support highlighting text within the menu when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<Menu tabIndex={-1} onClose={state.close} {...menuProps}>
<Menu tabIndex={-1} onClose={state.close} {...menuProps} autoFocus={!!menuProps.autoFocus}>
{options.map((item) => (
<MenuItem
key={`${item.value}`}
@@ -54,7 +54,7 @@ export interface GraphNGProps extends Themeable2 {
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
propsToDiff?: Array<string | PropDiffFn>;
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame;
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null;
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
/**
@@ -1,4 +1,6 @@
import { DataFrame, FieldType } from '@grafana/data';
import { DataFrame } from '@grafana/data';
import { getRefField } from './utils';
type InsertMode = (prev: number, next: number, threshold: number) => number;
@@ -29,10 +31,7 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame {
insertMode = INSERT_MODES.threshold;
}
const refField = frame.fields.find((field) => {
// note: getFieldDisplayName() would require full DF[]
return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time;
});
const refField = getRefField(frame, refFieldName);
if (refField == null) {
return frame;
@@ -18,9 +18,17 @@ function isVisibleBarField(f: Field) {
);
}
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) {
let refField = frame.fields.find((field) => field.type === FieldType.time); // this doesnt need to be time, just any numeric/asc join field
function applySpanNullsThresholds(frame: DataFrame, refFieldName?: string | null) {
const refField = getRefField(frame, refFieldName);
let refValues = refField?.values as any[];
for (let i = 0; i < frame.fields.length; i++) {
@@ -43,12 +51,22 @@ function applySpanNullsThresholds(frame: DataFrame) {
}
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 (!frame.fields[0].state?.nullThresholdApplied) {
if (!xField?.state?.nullThresholdApplied) {
return applyNullInsertThreshold({
frame,
refFieldName: null,
refFieldName: xField.name,
refFieldPseudoMin: timeRange?.from.valueOf(),
refFieldPseudoMax: timeRange?.to.valueOf(),
});
@@ -84,7 +102,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
return;
}
const xVals = frame.fields[0].values;
const xVals = xField.values;
for (let i = 0; i < xVals.length; i++) {
if (i > 0) {
@@ -102,7 +120,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
});
if (alignedFrame) {
alignedFrame = applySpanNullsThresholds(alignedFrame);
alignedFrame = applySpanNullsThresholds(alignedFrame, xField!.name);
// append 2 null vals at minXDelta to bar series
if (minXDelta !== Infinity) {
@@ -0,0 +1,80 @@
import { Meta, StoryFn } from '@storybook/react';
import React from 'react';
import { StoryExample } from '../../utils/storybook/StoryExample';
import { VerticalGroup } from '../Layout/Layout';
import { P } from '../Text/TextElements';
import { TextLink } from './TextLink';
import mdx from './TextLink.mdx';
const meta: Meta = {
title: 'General/TextLink',
component: TextLink,
parameters: {
docs: {
page: mdx,
},
controls: { exclude: ['href', 'external'] },
},
argTypes: {
variant: { control: 'select', options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'bodySmall', undefined] },
weight: {
control: 'select',
options: ['bold', 'medium', 'light', 'regular', undefined],
},
color: {
control: 'select',
options: ['primary', 'secondary', 'disabled', 'link', 'maxContrast', undefined],
},
inline: { control: 'boolean' },
},
args: {
variant: 'body',
weight: 'light',
color: undefined,
inline: false,
href: 'https://www.google.com',
external: true,
icon: 'external-link-alt',
},
};
export const Example: StoryFn = (args) => {
return (
<VerticalGroup>
<StoryExample name="This is a 'inline + external' link with the default behaviour">
<P>
To get started with a forever free Grafana Cloud account, sign up at &#160;
<TextLink href="https://grafana.com/" external {...args}>
grafana.com
</TextLink>
.
</P>
</StoryExample>
<StoryExample name="This is a 'standalone + external' link with the default behaviour">
<TextLink href="https://grafana.com/docs/grafana/latest/" inline={false} external {...args}>
Learn how in the docs
</TextLink>
</StoryExample>
<hr />
<P>*The examples cannot contemplate an internal link due to conflicts between Storybook and React Router</P>
</VerticalGroup>
);
};
Example.parameters = {
controls: { exclude: ['href', 'external', 'variant', 'weight', 'color', 'inline', 'icon'] },
};
export const Basic: StoryFn = (args) => {
return (
<div>
<TextLink href={args.href} {...args}>
Go to Google
</TextLink>
</div>
);
};
export default meta;
@@ -0,0 +1,105 @@
import { css } from '@emotion/css';
import React, { AnchorHTMLAttributes, forwardRef } from 'react';
import { GrafanaTheme2, locationUtil, textUtil, ThemeTypographyVariantTypes } from '@grafana/data';
import { useTheme2 } from '../../themes';
import { IconName, IconSize } from '../../types';
import { Icon } from '../Icon/Icon';
import { customWeight } from '../Text/utils';
import { Link } from './Link';
interface TextLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'target' | 'rel'> {
/** url to which redirect the user, external or internal */
href: string;
/** Color to use for text */
color?: keyof GrafanaTheme2['colors']['text'];
/** Specify if the link will redirect users to a page in or out Grafana */
external?: boolean;
/** True when the link will be displayed inline with surrounding text, false if it will be displayed as a block. Depending on this prop correspondant default styles will be applied */
inline?: boolean;
/** The default variant is 'body'. To fit another styles set the correspondent variant as it is necessary also to adjust the icon size */
variant?: keyof ThemeTypographyVariantTypes;
/** Override the default weight for the used variant */
weight?: 'light' | 'regular' | 'medium' | 'bold';
/** Set the icon to be shown. An external link will show the 'external-link-alt' icon as default.*/
icon?: IconName;
children: string;
}
const svgSizes: {
[key in keyof ThemeTypographyVariantTypes]: IconSize;
} = {
h1: 'xl',
h2: 'xl',
h3: 'lg',
h4: 'lg',
h5: 'md',
h6: 'md',
body: 'md',
bodySmall: 'xs',
};
export const TextLink = forwardRef<HTMLAnchorElement, TextLinkProps>(
(
{ href, color = 'link', external = false, inline = true, variant = 'body', weight, icon, children, ...rest },
ref
) => {
const validUrl = locationUtil.stripBaseFromUrl(textUtil.sanitizeUrl(href ?? ''));
const theme = useTheme2();
const styles = getLinkStyles(theme, inline, variant, weight, color);
const externalIcon = icon || 'external-link-alt';
return external ? (
<a href={validUrl} ref={ref} target="_blank" rel="noreferrer" {...rest} className={styles}>
{children}
<Icon size={svgSizes[variant] || 'md'} name={externalIcon} />
</a>
) : (
<Link ref={ref} href={validUrl} {...rest} className={styles}>
{children}
{icon && <Icon name={icon} size={svgSizes[variant] || 'md'} />}
</Link>
);
}
);
TextLink.displayName = 'TextLink';
export const getLinkStyles = (
theme: GrafanaTheme2,
inline: boolean,
variant?: keyof ThemeTypographyVariantTypes,
weight?: TextLinkProps['weight'],
color?: TextLinkProps['color']
) => {
return css([
variant && {
...theme.typography[variant],
},
weight && {
fontWeight: customWeight(weight, theme),
},
color && {
color: theme.colors.text[color],
},
{
alignItems: 'center',
gap: '0.25em',
display: 'inline-flex',
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
color: theme.colors.text.link,
},
},
inline && {
textDecoration: 'underline',
'&:hover': {
textDecoration: 'none',
},
},
]);
};
@@ -6,10 +6,10 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
// this is now typed in rc-tooltip, but they don't export it :(
// let's mirror the interface here. if there's any discrepancy, we'll get a type error
interface RCTooltipRef {
// rc-tooltip's ref is essentially untyped, so we be cautious by saying the function is
// potentially undefined which, given rc's track record, seems likely :)
forcePopupAlign?: () => {};
forceAlign: () => {};
}
const HandleTooltip = (props: {
@@ -21,7 +21,7 @@ const HandleTooltip = (props: {
}) => {
const { value, children, visible, placement, tipFormatter, ...restProps } = props;
const tooltipRef = useRef<RCTooltipRef>();
const tooltipRef = useRef<RCTooltipRef>(null);
const rafRef = useRef<number | null>(null);
const styles = useStyles2(tooltipStyles);
@@ -33,7 +33,7 @@ const HandleTooltip = (props: {
function keepAlign() {
rafRef.current = requestAnimationFrame(() => {
tooltipRef.current?.forcePopupAlign?.();
tooltipRef.current?.forceAlign();
});
}
@@ -5,6 +5,8 @@ import { GrafanaTheme2, ThemeTypographyVariantTypes } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { customWeight, customColor } from './utils';
export interface TextProps {
/** Defines what HTML element is defined underneath */
as: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p' | 'legend';
@@ -75,32 +77,3 @@ const getTextStyles = (
},
]);
};
const customWeight = (weight: TextProps['weight'], theme: GrafanaTheme2): number => {
switch (weight) {
case 'bold':
return theme.typography.fontWeightBold;
case 'medium':
return theme.typography.fontWeightMedium;
case 'light':
return theme.typography.fontWeightLight;
case 'regular':
case undefined:
return theme.typography.fontWeightRegular;
}
};
const customColor = (color: TextProps['color'], theme: GrafanaTheme2): string | undefined => {
switch (color) {
case 'error':
return theme.colors.error.text;
case 'success':
return theme.colors.success.text;
case 'info':
return theme.colors.info.text;
case 'warning':
return theme.colors.warning.text;
default:
return color ? theme.colors.text[color] : undefined;
}
};
@@ -0,0 +1,32 @@
import { GrafanaTheme2 } from '@grafana/data';
import { TextProps } from './Text';
export const customWeight = (weight: TextProps['weight'], theme: GrafanaTheme2): number => {
switch (weight) {
case 'bold':
return theme.typography.fontWeightBold;
case 'medium':
return theme.typography.fontWeightMedium;
case 'light':
return theme.typography.fontWeightLight;
case 'regular':
case undefined:
return theme.typography.fontWeightRegular;
}
};
export const customColor = (color: TextProps['color'], theme: GrafanaTheme2): string | undefined => {
switch (color) {
case 'error':
return theme.colors.error.text;
case 'success':
return theme.colors.success.text;
case 'info':
return theme.colors.info.text;
case 'warning':
return theme.colors.warning.text;
default:
return color ? theme.colors.text[color] : undefined;
}
};
@@ -23,7 +23,7 @@ const transitionStyles: { [key: string]: object } = {
export type RenderPopperArrowFn = (props: { arrowProps: PopperArrowProps; placement: string }) => JSX.Element;
interface Props extends React.HTMLAttributes<HTMLDivElement> {
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {
show: boolean;
placement?: Placement;
content: PopoverContent;
@@ -204,6 +204,7 @@ export { ToolbarButton, ToolbarButtonRow } from './ToolbarButton';
export { ValuePicker } from './ValuePicker/ValuePicker';
export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI';
export { Link } from './Link/Link';
export { TextLink } from './Link/TextLink';
export { Label } from './Forms/Label';
export { Field, type FieldProps } from './Forms/Field';
+2 -1
View File
@@ -14,7 +14,8 @@ import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // esli
// Creates a default, english i18next instance when running outside of grafana.
// we don't support changing the locale of grafana ui when outside of Grafana
function initI18n() {
if (!i18next.options.lng) {
// resources is undefined by default and set either by grafana app.ts or here
if (typeof i18next.options.resources !== 'object') {
i18next.use(initReactI18next).init({
resources: {},
returnEmptyString: false,
+1 -1
View File
@@ -70,7 +70,7 @@ func TestCallResource(t *testing.T) {
angularInspector, err := angularinspector.NewStaticInspector()
require.NoError(t, err)
l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg),
reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(pCfg), fakes.NewFakeRoleRegistry(),
reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(pCfg.DevMode), fakes.NewFakeRoleRegistry(),
assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(statickey.New()),
angularInspector, &fakes.FakeOauthService{})
srcs := sources.ProvideService(cfg, pCfg)
+25 -12
View File
@@ -5,11 +5,11 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/fatih/color"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
@@ -73,6 +73,14 @@ func installCommand(c utils.CommandLine) error {
// installPlugin downloads the plugin code as a zip file from the Grafana.com API
// and then extracts the zip into the plugin's directory.
func installPlugin(ctx context.Context, pluginID, version string, c utils.CommandLine) error {
// If a version is specified, check if it is already installed
if version != "" {
if services.PluginVersionInstalled(pluginID, version, c.PluginDirectory()) {
services.Logger.Successf("Plugin %s v%s already installed.", pluginID, version)
return nil
}
}
repository := repo.NewManager(repo.ManagerCfg{
SkipTLSVerify: c.Bool("insecure"),
BaseURL: c.PluginRepoURL(),
@@ -95,7 +103,7 @@ func installPlugin(ctx context.Context, pluginID, version string, c utils.Comman
}
pluginFs := storage.FileSystem(services.Logger, c.PluginDirectory())
extractedArchive, err := pluginFs.Extract(ctx, pluginID, archive.File)
extractedArchive, err := pluginFs.Extract(ctx, pluginID, storage.SimpleDirNameGeneratorFunc, archive.File)
if err != nil {
return err
}
@@ -107,7 +115,7 @@ func installPlugin(ctx context.Context, pluginID, version string, c utils.Comman
return fmt.Errorf("%v: %w", fmt.Sprintf("failed to download plugin %s from repository", dep.ID), err)
}
_, err = pluginFs.Extract(ctx, dep.ID, d.File)
_, err = pluginFs.Extract(ctx, dep.ID, storage.SimpleDirNameGeneratorFunc, d.File)
if err != nil {
return err
}
@@ -117,16 +125,21 @@ func installPlugin(ctx context.Context, pluginID, version string, c utils.Comman
// uninstallPlugin removes the plugin directory
func uninstallPlugin(_ context.Context, pluginID string, c utils.CommandLine) error {
logger.Infof("Removing plugin: %v\n", pluginID)
pluginPath := filepath.Join(c.PluginDirectory(), pluginID)
fs := plugins.NewLocalFS(pluginPath)
logger.Debugf("Removing directory %v\n", pluginPath)
err := fs.Remove()
if err != nil {
return err
for _, bundle := range services.GetLocalPlugins(c.PluginDirectory()) {
if bundle.Primary.JSONData.ID == pluginID {
logger.Infof("Removing plugin: %v\n", pluginID)
if remover, ok := bundle.Primary.FS.(plugins.FSRemover); ok {
logger.Debugf("Removing directory %v\n\n", bundle.Primary.FS.Base())
if err := remover.Remove(); err != nil {
return err
}
return nil
} else {
return fmt.Errorf("plugin %v is immutable and therefore cannot be uninstalled", pluginID)
}
}
}
return nil
}
+13 -2
View File
@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
)
@@ -78,7 +77,7 @@ func GetLocalPlugin(pluginDir, pluginID string) (plugins.FoundPlugin, error) {
}
func GetLocalPlugins(pluginDir string) []*plugins.FoundBundle {
f := finder.NewLocalFinder(&config.Cfg{})
f := finder.NewLocalFinder(true)
res, err := f.Find(context.Background(), sources.NewLocalSource(plugins.ClassExternal, []string{pluginDir}))
if err != nil {
@@ -88,3 +87,15 @@ func GetLocalPlugins(pluginDir string) []*plugins.FoundBundle {
return res
}
func PluginVersionInstalled(pluginID, version, pluginDir string) bool {
for _, bundle := range GetLocalPlugins(pluginDir) {
pJSON := bundle.Primary.JSONData
if pJSON.ID == pluginID {
if pJSON.Info.Version == version {
return true
}
}
}
return false
}
+2 -2
View File
@@ -150,7 +150,7 @@ func (gen *genTSVeneerIndex) extractTSIndexVeneerElements(def kindsys.Kind, tf *
return nil, terr
}
vpath := fmt.Sprintf("v%v", thema.LatestVersion(lin)[0])
vpath := fmt.Sprintf("v%v", thema.Lineage.Latest(lin).Version())
if def.Props().Common().Maturity.Less(kindsys.MaturityStable) {
vpath = "x"
}
@@ -182,7 +182,7 @@ func (gen *genTSVeneerIndex) extractTSIndexVeneerElements(def kindsys.Kind, tf *
// and exports all the symbols in the list.
//
// TODO generate code such that tsc enforces type compatibility between raw and veneer decls`,
lin.Name(), thema.LatestVersion(lin), filepath.ToSlash(filepath.Join(gen.dir, vtfile)))
lin.Name(), thema.Lineage.Latest(lin).Version(), filepath.ToSlash(filepath.Join(gen.dir, vtfile)))
customComments := []ast.Comment{{Text: customstr}}
if len(custom) > 0 {
+16 -1
View File
@@ -11,6 +11,7 @@ import (
"gonum.org/v1/gonum/graph/topo"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
// NodeType is the type of a DPNode. Currently either a expression command or datasource query.
@@ -21,6 +22,8 @@ const (
TypeCMDNode NodeType = iota
// TypeDatasourceNode is a NodeType for datasource queries.
TypeDatasourceNode
// TypeMLNode is a NodeType for Machine Learning queries.
TypeMLNode
)
func (nt NodeType) String() string {
@@ -29,6 +32,8 @@ func (nt NodeType) String() string {
return "Expression"
case TypeDatasourceNode:
return "Datasource"
case TypeMLNode:
return "Machine Learning"
default:
return "Unknown"
}
@@ -159,6 +164,7 @@ func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) {
rn := &rawNode{
Query: rawQueryProp,
QueryRaw: query.JSON,
RefID: query.RefID,
TimeRange: query.TimeRange,
QueryType: query.QueryType,
@@ -171,7 +177,16 @@ func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) {
node, err = s.buildDSNode(dp, rn, req)
case TypeCMDNode:
node, err = buildCMDNode(dp, rn)
default:
case TypeMLNode:
if s.features.IsEnabled(featuremgmt.FlagMlExpressions) {
node, err = s.buildMLNode(dp, rn, req)
if err != nil {
err = fmt.Errorf("fail to parse expression with refID %v: %w", rn.RefID, err)
}
}
}
if node == nil && err == nil {
err = fmt.Errorf("unsupported node type '%s'", NodeTypeFromDatasourceUID(query.DataSource.UID))
}
+158
View File
@@ -0,0 +1,158 @@
package expr
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
jsoniter "github.com/json-iterator/go"
"gonum.org/v1/gonum/graph/simple"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/expr/ml"
"github.com/grafana/grafana/pkg/plugins/httpresponsesender"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
)
var (
errMLPluginDoesNotExist = fmt.Errorf("expression type Machine Learning cannot be executed. Plugin '%s' must be installed and initialized", mlPluginID)
)
const (
// mlDatasourceID is similar to a fake ID for CMDNode. There is no specific reason for the selection of this value.
mlDatasourceID = -200
// DatasourceUID is the string constant used as the datasource name in requests
// to identify it as an expression command when use in Datasource.UID.
MLDatasourceUID = "__ml__"
// mlPluginID is a known constant and used in other places of the code
mlPluginID = "grafana-ml-app"
)
// MLNode is a node of expression tree that evaluates the expression by sending the payload to Machine Learning back-end.
// See ml.UnmarshalCommand for supported commands.
type MLNode struct {
baseNode
command ml.Command
TimeRange TimeRange
request *Request
}
// NodeType returns the data pipeline node type.
func (m *MLNode) NodeType() NodeType {
return TypeMLNode
}
// Execute initializes plugin API client, executes a ml.Command and then converts the result of the execution.
// Returns non-empty mathexp.Results if evaluation was successful. Returns QueryError if command execution failed
func (m *MLNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s *Service) (r mathexp.Results, e error) {
logger := logger.FromContext(ctx).New("datasourceType", mlPluginID, "queryRefId", m.refID)
var result mathexp.Results
timeRange := m.TimeRange.AbsoluteTime(now)
// get the plugin configuration that will be used by client (auth, host, etc)
pCtx, err := s.pCtxProvider.Get(ctx, mlPluginID, m.request.User, m.request.OrgId)
if err != nil {
if errors.Is(err, plugincontext.ErrPluginNotFound) {
return result, errMLPluginDoesNotExist
}
return result, fmt.Errorf("failed to get plugin settings: %w", err)
}
// Plugin must be initialized by the admin first. That will create service account, and update plugin settings so all requests can use it.
// Fail if it is not initialized.
if pCtx.AppInstanceSettings == nil || !jsoniter.Get(pCtx.AppInstanceSettings.JSONData, "initialized").ToBool() {
return mathexp.Results{}, errMLPluginDoesNotExist
}
// responseType and respStatus will be updated below. Use defer to ensure that debug log message is always emitted
responseType := "unknown"
respStatus := "success"
defer func() {
if e != nil {
responseType = "error"
respStatus = "failure"
}
logger.Debug("Data source queried", "responseType", responseType)
useDataplane := strings.HasPrefix("dataplane-", responseType)
s.metrics.dsRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane)).Inc()
}()
// Execute the command and provide callback function for sending a request via plugin API.
// This lets us make commands abstracted from peculiarities of the transfer protocol.
data, err := m.command.Execute(timeRange.From, timeRange.To, func(method string, path string, payload []byte) (response.Response, error) {
crReq := &backend.CallResourceRequest{
PluginContext: pCtx,
Path: path,
Method: method,
URL: path,
Headers: make(map[string][]string, len(m.request.Headers)),
Body: payload,
}
// copy headers from the request to evaluate the expression pipeline. Usually this contains information from upstream, e.g. FromAlert
for key, val := range m.request.Headers {
crReq.SetHTTPHeader(key, val)
}
resp := response.CreateNormalResponse(make(http.Header), nil, 0)
httpSender := httpresponsesender.New(resp)
err = s.pluginsClient.CallResource(ctx, crReq, httpSender)
if err != nil {
return nil, err
}
return resp, nil
})
if err != nil {
return result, QueryError{
RefID: m.refID,
Err: err,
}
}
// data is not guaranteed to be specified. In this case simulate NoData scenario
if data == nil {
data = &backend.QueryDataResponse{Responses: map[string]backend.DataResponse{}}
}
dataFrames, err := getResponseFrame(data, m.refID)
if err != nil {
return mathexp.Results{}, QueryError{
RefID: m.refID,
DatasourceUID: mlPluginID,
Err: err,
}
}
// process the response the same way DSNode does. Use plugin ID as data source type. Semantically, they are the same.
responseType, result, err = convertDataFramesToResults(ctx, dataFrames, mlPluginID, s, logger)
return result, err
}
func (s *Service) buildMLNode(dp *simple.DirectedGraph, rn *rawNode, req *Request) (Node, error) {
if rn.TimeRange == nil {
return nil, errors.New("time range must be specified")
}
cmd, err := ml.UnmarshalCommand(rn.QueryRaw, s.cfg.AppURL)
if err != nil {
return nil, err
}
return &MLNode{
baseNode: baseNode{
id: dp.NewNode().ID(),
refID: rn.RefID,
},
TimeRange: rn.TimeRange,
command: cmd,
request: req,
}, nil
}
+83
View File
@@ -0,0 +1,83 @@
package ml
import (
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
jsoniter "github.com/json-iterator/go"
)
type CommandConfiguration struct {
Type string `json:"type"`
IntervalMs *uint `json:"intervalMs,omitempty"`
Config jsoniter.RawMessage `json:"config"`
}
type OutlierCommandConfiguration struct {
DatasourceType string `json:"datasource_type"`
DatasourceUID string `json:"datasource_uid,omitempty"`
// If Query is empty it should be contained in a datasource specific format
// inside of QueryParms.
Query string `json:"query,omitempty"`
QueryParams map[string]interface{} `json:"query_params,omitempty"`
Algorithm map[string]interface{} `json:"algorithm"`
ResponseType string `json:"response_type"`
}
// outlierAttributes is outlier command configuration that is sent to Machine learning API
type outlierAttributes struct {
OutlierCommandConfiguration
GrafanaURL string `json:"grafana_url"`
StartEndAttributes timeRangeAndInterval `json:"start_end_attributes"`
}
type outlierData struct {
Attributes outlierAttributes `json:"attributes"`
}
// outlierRequestBody describes a request body that is sent to Outlier API
type outlierRequestBody struct {
Data outlierData `json:"data"`
}
type timeRangeAndInterval struct {
Start mlTime `json:"start"`
End mlTime `json:"end"`
Interval int64 `json:"interval"` // Interval is expected to be in milliseconds
}
func newTimeRangeAndInterval(from, to time.Time, interval time.Duration) timeRangeAndInterval {
return timeRangeAndInterval{
Start: mlTime(from),
End: mlTime(to),
Interval: interval.Milliseconds(),
}
}
// mlTime is a time.Time that is marshalled as a string in a format is supported by Machine Learning API
type mlTime time.Time
func (t *mlTime) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
parsed, err := time.Parse(timeFormat, s)
if err != nil {
return err
}
*t = mlTime(parsed)
return nil
}
// MarshalJSON implements the Marshaler interface.
func (t mlTime) MarshalJSON() ([]byte, error) {
return []byte("\"" + time.Time(t).Format(timeFormat) + "\""), nil
}
// outlierResponse is a model that represents a response of the outlier proxy API.
type outlierResponse struct {
Status string `json:"status"`
Data *backend.QueryDataResponse `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
+63
View File
@@ -0,0 +1,63 @@
package ml
import (
"fmt"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
jsoniter "github.com/json-iterator/go"
"github.com/grafana/grafana/pkg/api/response"
)
var json = jsoniter.ConfigCompatibleWithStandardLibrary
type CommandType string
const (
Outlier CommandType = "outlier"
// format of the time used by outlier API
timeFormat = "2006-01-02T15:04:05.999999999"
defaultInterval = 1000 * time.Millisecond
)
// Command is an interface implemented by all Machine Learning commands that can be executed against ML API.
type Command interface {
// DatasourceUID returns UID of a data source that is used by machine learning as the source of data
DatasourceUID() string
// Execute creates a payload send request to the ML API by calling the function argument sendRequest, and then parses response.
// Function sendRequest is supposed to abstract the client configuration such creating http request, adding authorization parameters, host etc.
Execute(from, to time.Time, sendRequest func(method string, path string, payload []byte) (response.Response, error)) (*backend.QueryDataResponse, error)
}
// UnmarshalCommand parses a config parameters and creates a command. Requires key `type` to be specified.
// Based on the value of `type` field it parses a Command
func UnmarshalCommand(query []byte, appURL string) (Command, error) {
var expr CommandConfiguration
err := json.Unmarshal(query, &expr)
if err != nil {
return nil, fmt.Errorf("failed to unmarshall Machine learning command: %w", err)
}
if len(expr.Type) == 0 {
return nil, fmt.Errorf("required field 'type' is not specified or empty. Should be one of [%s]", Outlier)
}
if len(expr.Config) == 0 {
return nil, fmt.Errorf("required field 'config' is not specified")
}
var cmd Command
switch mlType := strings.ToLower(expr.Type); mlType {
case string(Outlier):
cmd, err = unmarshalOutlierCommand(expr, appURL)
default:
return nil, fmt.Errorf("unsupported command type. Should be one of [%s]", Outlier)
}
if err != nil {
return nil, fmt.Errorf("failed to unmarshal Machine learning %s command: %w", expr.Type, err)
}
return cmd, nil
}
+167
View File
@@ -0,0 +1,167 @@
package ml
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestUnmarshalCommand(t *testing.T) {
appURL := "https://grafana.com"
updateJson := func(cmd string, f func(m map[string]interface{})) func(t *testing.T) []byte {
return func(t *testing.T) []byte {
var d map[string]interface{}
require.NoError(t, json.UnmarshalFromString(cmd, &d))
f(d)
data, err := json.Marshal(d)
require.NoError(t, err)
return data
}
}
t.Run("should parse outlier command", func(t *testing.T) {
cmd, err := UnmarshalCommand([]byte(outlierQuery), appURL)
require.NoError(t, err)
require.IsType(t, &OutlierCommand{}, cmd)
outlier := cmd.(*OutlierCommand)
require.Equal(t, 1234*time.Millisecond, outlier.interval)
require.Equal(t, appURL, outlier.appURL)
require.Equal(t, OutlierCommandConfiguration{
DatasourceType: "prometheus",
DatasourceUID: "a4ce599c-4c93-44b9-be5b-76385b8c01be",
QueryParams: map[string]interface{}{
"expr": "go_goroutines{}",
"range": true,
"refId": "A",
},
Algorithm: map[string]interface{}{
"name": "dbscan",
"config": map[string]interface{}{
"epsilon": 7.667,
},
"sensitivity": 0.83,
},
ResponseType: "binary",
}, outlier.config)
})
t.Run("should fallback to default if 'intervalMs' is not specified", func(t *testing.T) {
data := updateJson(outlierQuery, func(m map[string]interface{}) {
delete(m, "intervalMs")
})(t)
cmd, err := UnmarshalCommand(data, appURL)
require.NoError(t, err)
outlier := cmd.(*OutlierCommand)
require.Equal(t, defaultInterval, outlier.interval)
})
t.Run("fails when", func(t *testing.T) {
testCases := []struct {
name string
config func(t *testing.T) []byte
err string
}{
{
name: "field 'type' is missing",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
delete(cmd, "type")
}),
err: "required field 'type' is not specified or empty. Should be one of [outlier]",
},
{
name: "field 'type' is not known",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cmd["type"] = uuid.NewString()
}),
err: "unsupported command type. Should be one of [outlier]",
},
{
name: "field 'type' is not string",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cmd["type"] = map[string]interface{}{
"data": 1,
}
}),
err: "failed to unmarshall Machine learning command",
},
{
name: "field 'config' is missing",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
delete(cmd, "config")
}),
err: "required field 'config' is not specified",
},
{
name: "field 'intervalMs' is not number",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cmd["intervalMs"] = "test"
}),
err: "failed to unmarshall Machine learning command",
},
{
name: "field 'config.datasource_uid' is not specified",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cfg := cmd["config"].(map[string]interface{})
delete(cfg, "datasource_uid")
}),
err: "required field `config.datasource_uid` is not specified",
},
{
name: "field 'config.algorithm' is not specified",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cfg := cmd["config"].(map[string]interface{})
delete(cfg, "algorithm")
}),
err: "required field `config.algorithm` is not specified",
},
{
name: "field 'config.response_type' is not specified",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cfg := cmd["config"].(map[string]interface{})
delete(cfg, "response_type")
}),
err: "required field `config.response_type` is not specified",
},
{
name: "fields 'config.query' and 'config.query_params' are not specified",
config: updateJson(outlierQuery, func(cmd map[string]interface{}) {
cfg := cmd["config"].(map[string]interface{})
delete(cfg, "query")
delete(cfg, "query_params")
}),
err: "neither of required fields `config.query_params` or `config.query` are specified",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
_, err := UnmarshalCommand(testCase.config(t), appURL)
require.ErrorContains(t, err, testCase.err)
})
}
})
}
const outlierQuery = `
{
"type": "outlier",
"intervalMs": 1234,
"config": {
"datasource_uid": "a4ce599c-4c93-44b9-be5b-76385b8c01be",
"datasource_type": "prometheus",
"query_params": {
"expr": "go_goroutines{}",
"range": true,
"refId": "A"
},
"response_type": "binary",
"algorithm": {
"name": "dbscan",
"config": {
"epsilon": 7.667
},
"sensitivity": 0.83
}
}
}
`
+116
View File
@@ -0,0 +1,116 @@
package ml
import (
"fmt"
"net/http"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/response"
)
// OutlierCommand implements Command that sends a request to outlier proxy API and converts response to backend.QueryDataResponse
type OutlierCommand struct {
config OutlierCommandConfiguration
appURL string
interval time.Duration
}
var _ Command = OutlierCommand{}
func (c OutlierCommand) DatasourceUID() string {
return c.config.DatasourceUID
}
// Execute copies the original configuration JSON and appends (overwrites) a field "start_end_attributes" and "grafana_url" to the root object.
// The value of "start_end_attributes" is JSON object that configures time range and interval.
// The value of "grafana_url" is app URL that should be used by ML to query the data source.
// After payload is generated it sends it to POST /proxy/api/v1/outlier endpoint and parses the response.
// The proxy API normally responds with a structured data. It recognizes status 200 and 204 as successful result.
// Other statuses are considered unsuccessful and result in error. Tries to extract error from the structured payload.
// Otherwise, mentions the full message in error
func (c OutlierCommand) Execute(from, to time.Time, sendRequest func(method string, path string, payload []byte) (response.Response, error)) (*backend.QueryDataResponse, error) {
payload := outlierRequestBody{
Data: outlierData{
Attributes: outlierAttributes{
OutlierCommandConfiguration: c.config,
GrafanaURL: c.appURL,
StartEndAttributes: newTimeRangeAndInterval(from, to, c.interval),
},
},
}
requestBody, err := json.Marshal(payload)
if err != nil {
return nil, err
}
resp, err := sendRequest(http.MethodPost, "/proxy/api/v1/outlier", requestBody)
if err != nil {
return nil, fmt.Errorf("failed to call ML API: %w", err)
}
if resp == nil {
return nil, fmt.Errorf("response is nil")
}
// Outlier proxy API usually returns all responses with this body.
var respData outlierResponse
respBody := resp.Body()
err = json.Unmarshal(respBody, &respData)
if err != nil {
return nil, fmt.Errorf("unexpected format of the response from ML API, status: %d, response: %s", resp.Status(), respBody)
}
if respData.Status == "error" {
return nil, fmt.Errorf("ML API responded with error: %s", respData.Error)
}
if resp.Status() == http.StatusNoContent {
return nil, nil
}
if resp.Status() == http.StatusOK {
return respData.Data, nil
}
return nil, fmt.Errorf("unexpected status %d returned by ML API, response: %s", resp.Status(), respBody)
}
// unmarshalOutlierCommand parses the CommandConfiguration.Config, validates data and produces OutlierCommand.
func unmarshalOutlierCommand(expr CommandConfiguration, appURL string) (*OutlierCommand, error) {
var cfg OutlierCommandConfiguration
err := json.Unmarshal(expr.Config, &cfg)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal outlier command: %w", err)
}
if len(cfg.DatasourceUID) == 0 {
return nil, fmt.Errorf("required field `config.datasource_uid` is not specified")
}
if len(cfg.Query) == 0 && len(cfg.QueryParams) == 0 {
return nil, fmt.Errorf("neither of required fields `config.query_params` or `config.query` are specified")
}
if len(cfg.ResponseType) == 0 {
return nil, fmt.Errorf("required field `config.response_type` is not specified")
}
if len(cfg.Algorithm) == 0 {
return nil, fmt.Errorf("required field `config.algorithm` is not specified")
}
interval := defaultInterval
if expr.IntervalMs != nil {
i := time.Duration(*expr.IntervalMs) * time.Millisecond
if i > 0 {
interval = i
}
}
return &OutlierCommand{
config: cfg,
interval: interval,
appURL: appURL,
}, nil
}
+194
View File
@@ -0,0 +1,194 @@
package ml
import (
"errors"
"fmt"
"net/http"
"testing"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"github.com/grafana/grafana/pkg/api/response"
)
func TestOutlierExec(t *testing.T) {
outlier := OutlierCommand{
config: OutlierCommandConfiguration{
DatasourceType: "prometheus",
DatasourceUID: "a4ce599c-4c93-44b9-be5b-76385b8c01be",
QueryParams: map[string]interface{}{
"expr": "go_goroutines{}",
"range": true,
"refId": "A",
},
Algorithm: map[string]interface{}{
"name": "dbscan",
"config": map[string]interface{}{
"epsilon": 7.667,
},
"sensitivity": 0.83,
},
ResponseType: "binary",
},
appURL: "https://grafana.com",
interval: 1000 * time.Second,
}
t.Run("should generate expected parameters for request", func(t *testing.T) {
to := time.Now().UTC()
from := to.Add(-10 * time.Hour)
called := false
_, err := outlier.Execute(from, to, func(method string, path string, payload []byte) (response.Response, error) {
require.Equal(t, "POST", method)
require.Equal(t, "/proxy/api/v1/outlier", path)
assert.JSONEq(t, fmt.Sprintf(`{
"data": {
"attributes": {
"datasource_type": "prometheus",
"datasource_uid": "a4ce599c-4c93-44b9-be5b-76385b8c01be",
"query_params": {
"expr": "go_goroutines{}",
"range": true,
"refId": "A"
},
"algorithm": {
"config": {
"epsilon": 7.667
},
"name": "dbscan",
"sensitivity": 0.83
},
"response_type": "binary",
"grafana_url": "https://grafana.com",
"start_end_attributes": {
"start": "%s",
"end": "%s",
"interval": 1000000
}
}
}
}`, from.Format(timeFormat), to.Format(timeFormat)), string(payload))
called = true
return nil, nil
})
require.Truef(t, called, "request function was not called")
require.ErrorContains(t, err, "response is nil")
})
successResponse := `{"status":"success","data":{"results":{"A":{"status":200,"frames":[{"schema":{"name":"test","fields":[{"name":"Time","type":"time","typeInfo":{"frame":"time.Time"}},{"name":"Value","type":"number","typeInfo":{"frame":"float64"},"labels":{"instance":"test"}}]},"data":{"values":[[1686945300000],[0]]}}]}}}}`
testCases := []struct {
name string
response response.Response
assert func(t *testing.T, r *backend.QueryDataResponse, err error)
}{
{
name: "should return parsed frames when 200",
response: response.CreateNormalResponse(nil, []byte(successResponse), http.StatusOK),
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.NoError(t, err)
require.NotNil(t, r)
require.Contains(t, r.Responses, "A")
require.NoError(t, r.Responses["A"].Error)
require.Equal(t, backend.StatusOK, r.Responses["A"].Status)
require.Len(t, r.Responses["A"].Frames, 1)
},
},
{
name: "should return nil if 204",
response: response.CreateNormalResponse(nil, []byte(`{"status": "success"}`), http.StatusNoContent),
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.NoError(t, err)
require.Nil(t, r)
},
},
{
name: "should return error if any status and body has status error",
response: response.CreateNormalResponse(nil, []byte(`{"status": "error"}`), 0),
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.ErrorContains(t, err, "ML API responded with error")
require.Nil(t, r)
},
},
{
name: "should return error with explanations if any status and body has status error",
response: response.CreateNormalResponse(nil, []byte(`{"status": "error", "error": "test-error"}`), 0),
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.ErrorContains(t, err, "ML API responded with error")
require.ErrorContains(t, err, "test-error")
require.Nil(t, r)
},
},
{
name: "should return error response is empty",
response: response.CreateNormalResponse(nil, []byte(``), 0),
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.ErrorContains(t, err, "unexpected format of the response from ML API")
require.Nil(t, r)
},
},
{
name: "should return error response is not a valid JSON",
response: response.CreateNormalResponse(nil, []byte(`{`), 0),
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.ErrorContains(t, err, "unexpected format of the response from ML API")
require.Nil(t, r)
},
},
{
name: "should error if response is nil and no error",
response: nil,
assert: func(t *testing.T, r *backend.QueryDataResponse, err error) {
require.ErrorContains(t, err, "response is nil")
require.Nil(t, r)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
to := time.Now()
from := to.Add(-10 * time.Hour)
resp, err := outlier.Execute(from, to, func(method string, path string, payload []byte) (response.Response, error) {
return tc.response, nil
})
tc.assert(t, resp, err)
})
}
t.Run("should return error if status is not known", func(t *testing.T) {
knownStatuses := []int{http.StatusOK, http.StatusNoContent}
for status := 100; status < 600; status++ {
if http.StatusText(status) == "" || slices.Contains(knownStatuses, status) {
continue
}
to := time.Now()
from := to.Add(-10 * time.Hour)
resp, err := outlier.Execute(from, to, func(method string, path string, payload []byte) (response.Response, error) {
return response.CreateNormalResponse(nil, []byte(successResponse), status), nil
})
require.ErrorContains(t, err, fmt.Sprintf("unexpected status %d returned by ML API", status))
require.Nil(t, resp)
}
})
t.Run("should propagate error from request function", func(t *testing.T) {
to := time.Now()
from := to.Add(-10 * time.Hour)
resp, err := outlier.Execute(from, to, func(method string, path string, payload []byte) (response.Response, error) {
return nil, errors.New("test-error")
})
require.ErrorContains(t, err, "test-error")
require.Nil(t, resp)
})
}
+44
View File
@@ -0,0 +1,44 @@
package ml
import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/response"
)
type FakeCommand struct {
Method string
Path string
Payload []byte
Response *backend.QueryDataResponse
Error error
Recordings []struct {
From time.Time
To time.Time
Response response.Response
Error error
}
}
var _ Command = &FakeCommand{}
func (f *FakeCommand) DatasourceUID() string {
return "fake-ml-datasource"
}
func (f *FakeCommand) Execute(from, to time.Time, executor func(method string, path string, payload []byte) (response.Response, error)) (*backend.QueryDataResponse, error) {
r, err := executor(f.Method, f.Path, f.Payload)
f.Recordings = append(f.Recordings, struct {
From time.Time
To time.Time
Response response.Response
Error error
}{From: from, To: to, Response: r, Error: err})
if err != nil {
return nil, err
}
return f.Response, f.Error
}
+219
View File
@@ -0,0 +1,219 @@
package expr
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/expr/ml"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/user"
)
func TestMLNodeExecute(t *testing.T) {
timeNow := time.Now()
expectedOrgID := int64(123)
timeRange := RelativeTimeRange{
From: -10 * time.Hour,
To: 0,
}
request := &Request{
Headers: map[string]string{
"test": "test",
},
Debug: false,
OrgId: expectedOrgID,
Queries: nil,
User: &user.SignedInUser{
UserID: 1,
},
}
expectedResponse := &backend.CallResourceResponse{
Status: 200,
Headers: nil,
Body: []byte("test-response"),
}
pluginsClient := &recordingCallResourceHandler{
response: expectedResponse,
}
pluginCtx := &fakePluginContextProvider{
result: map[string]*backend.AppInstanceSettings{
mlPluginID: {
JSONData: json.RawMessage(`{ "initialized": true }`),
},
},
}
s := &Service{
cfg: nil,
dataService: nil,
pCtxProvider: pluginCtx,
features: nil,
pluginsClient: pluginsClient,
tracer: nil,
metrics: newMetrics(nil),
}
cmdResponse := data.NewFrame("test",
data.NewField("Time", nil, []time.Time{time.Unix(1, 0)}),
data.NewField("Value", nil, []*float64{fp(1)}),
)
cmd := &ml.FakeCommand{
Method: http.MethodPost,
Path: "/test/ml",
Payload: []byte(`{}`),
Response: &backend.QueryDataResponse{
Responses: map[string]backend.DataResponse{
"A": {
Frames: data.Frames{
cmdResponse,
},
Status: backend.StatusOK,
},
},
},
Error: nil,
}
node := &MLNode{
baseNode: baseNode{},
command: cmd,
TimeRange: timeRange,
request: request,
}
result, err := node.Execute(context.Background(), timeNow, nil, s)
require.NoError(t, err)
require.NotNil(t, result)
require.NotEmpty(t, result.Values)
t.Run("should get plugin context", func(t *testing.T) {
require.NotEmpty(t, pluginCtx.recordings)
require.Equal(t, "Get", pluginCtx.recordings[0].method)
require.Equal(t, mlPluginID, pluginCtx.recordings[0].params[0])
require.Equal(t, request.User, pluginCtx.recordings[0].params[1])
})
t.Run("should call command execute with correct parameters", func(t *testing.T) {
require.NotEmpty(t, cmd.Recordings)
rec := cmd.Recordings[0]
require.Equal(t, timeRange.AbsoluteTime(timeNow).From, rec.From)
require.Equal(t, timeRange.AbsoluteTime(timeNow).To, rec.To)
require.NotNil(t, rec.Response)
require.Equal(t, expectedResponse.Status, rec.Response.Status())
require.Equal(t, expectedResponse.Body, rec.Response.Body())
})
t.Run("should call plugin API", func(t *testing.T) {
require.NotEmpty(t, pluginsClient.recordings)
req := pluginsClient.recordings[0]
require.Equal(t, cmd.Payload, req.Body)
require.Equal(t, cmd.Path, req.Path)
require.Equal(t, cmd.Method, req.Method)
require.NotNil(t, req.PluginContext)
require.Equal(t, mlPluginID, req.PluginContext.PluginID)
t.Run("should append request headers to API call", func(t *testing.T) {
for key, value := range request.Headers {
require.Contains(t, req.Headers, key)
require.Equal(t, value, req.Headers[key][0])
}
})
})
t.Run("should fail if plugin is not installed", func(t *testing.T) {
s := &Service{
cfg: nil,
dataService: nil,
pCtxProvider: &fakePluginContextProvider{
errorResult: plugincontext.ErrPluginNotFound,
},
features: nil,
pluginsClient: nil,
tracer: nil,
metrics: nil,
}
_, err := node.Execute(context.Background(), timeNow, nil, s)
require.ErrorIs(t, err, errMLPluginDoesNotExist)
})
t.Run("should fail if plugin settings cannot be retrieved", func(t *testing.T) {
expectedErr := errors.New("test-error")
s := &Service{
cfg: nil,
dataService: nil,
pCtxProvider: &fakePluginContextProvider{
errorResult: expectedErr,
},
features: nil,
pluginsClient: nil,
tracer: nil,
metrics: nil,
}
_, err := node.Execute(context.Background(), timeNow, nil, s)
require.ErrorIs(t, err, expectedErr)
})
t.Run("should fail if plugin is not initialized", func(t *testing.T) {
s := &Service{
cfg: nil,
dataService: nil,
pCtxProvider: &fakePluginContextProvider{
result: map[string]*backend.AppInstanceSettings{
mlPluginID: {
JSONData: json.RawMessage(`{}`),
},
},
},
features: nil,
pluginsClient: nil,
tracer: nil,
metrics: nil,
}
_, err := node.Execute(context.Background(), timeNow, nil, s)
require.ErrorIs(t, err, errMLPluginDoesNotExist)
})
t.Run("should return QueryError if command failed", func(t *testing.T) {
s := &Service{
cfg: nil,
dataService: nil,
pCtxProvider: pluginCtx,
features: nil,
pluginsClient: pluginsClient,
tracer: nil,
metrics: newMetrics(nil),
}
cmd := &ml.FakeCommand{
Error: errors.New("failed to execute command"),
}
node := &MLNode{
baseNode: baseNode{},
command: cmd,
TimeRange: timeRange,
request: request,
}
_, err := node.Execute(context.Background(), timeNow, nil, s)
require.IsType(t, err, QueryError{})
require.ErrorIs(t, err, cmd.Error)
})
}
+1
View File
@@ -47,6 +47,7 @@ type baseNode struct {
type rawNode struct {
RefID string `json:"refId"`
Query map[string]interface{}
QueryRaw []byte
QueryType string
TimeRange TimeRange
DataSource *datasources.DataSource
+19 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -47,6 +48,9 @@ func NodeTypeFromDatasourceUID(uid string) NodeType {
if IsDataSource(uid) {
return TypeCMDNode
}
if uid == MLDatasourceUID {
return TypeMLNode
}
return TypeDatasourceNode
}
@@ -54,7 +58,7 @@ func NodeTypeFromDatasourceUID(uid string) NodeType {
type Service struct {
cfg *setting.Cfg
dataService backend.QueryDataHandler
pCtxProvider *plugincontext.Provider
pCtxProvider pluginContextProvider
features featuremgmt.FeatureToggles
pluginsClient backend.CallResourceHandler
@@ -63,6 +67,11 @@ type Service struct {
metrics *metrics
}
type pluginContextProvider interface {
Get(ctx context.Context, pluginID string, user *user.SignedInUser, orgID int64) (backend.PluginContext, error)
GetWithDataSource(ctx context.Context, pluginID string, user *user.SignedInUser, ds *datasources.DataSource) (backend.PluginContext, error)
}
func ProvideService(cfg *setting.Cfg, pluginClient plugins.Client, pCtxProvider *plugincontext.Provider,
features featuremgmt.FeatureToggles, registerer prometheus.Registerer, tracer tracing.Tracer) *Service {
return &Service{
@@ -117,6 +126,15 @@ func DataSourceModelFromNodeType(kind NodeType) (*datasources.DataSource, error)
JsonData: simplejson.New(),
SecureJsonData: make(map[string][]byte),
}, nil
case TypeMLNode:
return &datasources.DataSource{
ID: mlDatasourceID,
UID: MLDatasourceUID,
Name: DatasourceUID,
Type: mlPluginID,
JsonData: simplejson.New(),
SecureJsonData: make(map[string][]byte),
}, nil
case TypeDatasourceNode:
return nil, errors.New("cannot create expression data source for data source kind")
default:
+84
View File
@@ -0,0 +1,84 @@
package expr
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/user"
)
type fakePluginContextProvider struct {
recordings []struct {
method string
params []interface{}
}
result map[string]*backend.AppInstanceSettings
errorResult error
}
var _ pluginContextProvider = &fakePluginContextProvider{}
func (f *fakePluginContextProvider) Get(_ context.Context, pluginID string, user *user.SignedInUser, orgID int64) (backend.PluginContext, error) {
f.recordings = append(f.recordings, struct {
method string
params []interface{}
}{method: "Get", params: []interface{}{pluginID, user, orgID}})
if f.errorResult != nil {
return backend.PluginContext{}, f.errorResult
}
var u *backend.User
if user != nil {
u = &backend.User{
Login: user.Login,
Name: user.Name,
Email: user.Email,
}
}
return backend.PluginContext{
OrgID: orgID,
PluginID: pluginID,
User: u,
AppInstanceSettings: f.result[pluginID],
DataSourceInstanceSettings: nil,
}, nil
}
func (f *fakePluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user *user.SignedInUser, ds *datasources.DataSource) (backend.PluginContext, error) {
f.recordings = append(f.recordings, struct {
method string
params []interface{}
}{method: "GetWithDataSource", params: []interface{}{pluginID, user, ds}})
if f.errorResult != nil {
return backend.PluginContext{}, f.errorResult
}
orgId := int64(1)
if user != nil {
orgId = user.OrgID
}
r, err := f.Get(ctx, pluginID, user, orgId)
if ds != nil {
r.DataSourceInstanceSettings = &backend.DataSourceInstanceSettings{
ID: ds.ID,
UID: ds.UID,
Type: ds.Type,
Name: ds.Name,
}
}
return r, err
}
type recordingCallResourceHandler struct {
recordings []*backend.CallResourceRequest
response *backend.CallResourceResponse
}
var _ backend.CallResourceHandler = &recordingCallResourceHandler{}
func (f *recordingCallResourceHandler) CallResource(_ context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
f.recordings = append(f.recordings, req)
return sender.Send(f.response)
}
+2 -2
View File
@@ -612,13 +612,13 @@ type RangeMap struct {
// Range to match against and the result to apply when the value is within the range
Options struct {
// Min value of the range. It can be null which means -Infinity
From *float32 `json:"from"`
From *float64 `json:"from"`
// Result used as replacement with text and color when the value matches
Result ValueMappingResult `json:"result"`
// Max value of the range. It can be null which means +Infinity
To *float32 `json:"to"`
To *float64 `json:"to"`
} `json:"options"`
Type RangeMapType `json:"type"`
}
-9
View File
@@ -11,8 +11,6 @@ import (
"strconv"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
type SocialGenericOAuth struct {
@@ -489,13 +487,6 @@ func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *htt
return logins, true
}
func (s *SocialGenericOAuth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
opts = append(opts, oauth2.AccessTypeOffline)
}
return s.SocialBase.AuthCodeURL(state, opts...)
}
func (s *SocialGenericOAuth) SupportBundleContent(bf *bytes.Buffer) error {
bf.WriteString("## GenericOAuth specific configuration\n\n")
bf.WriteString("```ini\n")
+1 -1
View File
@@ -114,7 +114,7 @@ func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client)
}
func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) && s.useRefreshToken {
opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
}
return s.SocialBase.AuthCodeURL(state, opts...)
+22
View File
@@ -17,6 +17,7 @@ import (
"strings"
"time"
"golang.org/x/exp/slices"
"golang.org/x/oauth2"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -31,6 +32,10 @@ import (
"github.com/grafana/grafana/pkg/util"
)
const (
OfflineAccessScope = "offline_access"
)
type SocialService struct {
cfg *setting.Cfg
@@ -66,6 +71,7 @@ type OAuthInfo struct {
RoleAttributeStrict bool `toml:"role_attribute_strict"`
TlsSkipVerify bool `toml:"tls_skip_verify"`
UsePKCE bool `toml:"use_pkce"`
UseRefreshToken bool `toml:"use_refresh_token"`
}
func ProvideService(cfg *setting.Cfg,
@@ -111,6 +117,7 @@ func ProvideService(cfg *setting.Cfg,
TlsClientCa: sec.Key("tls_client_ca").String(),
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
UsePKCE: sec.Key("use_pkce").MustBool(),
UseRefreshToken: sec.Key("use_refresh_token").MustBool(false),
AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false),
AutoLogin: sec.Key("auto_login").MustBool(false),
}
@@ -198,6 +205,9 @@ func ProvideService(cfg *setting.Cfg,
forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false),
skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync,
}
if info.UseRefreshToken && features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
appendUniqueScope(&config, OfflineAccessScope)
}
}
// Okta
@@ -208,6 +218,9 @@ func ProvideService(cfg *setting.Cfg,
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
skipOrgRoleSync: cfg.OktaSkipOrgRoleSync,
}
if info.UseRefreshToken && features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
appendUniqueScope(&config, OfflineAccessScope)
}
}
// Generic - Uses the same scheme as GitHub.
@@ -271,6 +284,7 @@ func (b *BasicUserInfo) String() string {
b.Id, b.Name, b.Email, b.Login, b.Role, b.Groups)
}
//go:generate mockery --name SocialConnector --structname MockSocialConnector --outpkg socialtest --filename social_connector_mock.go --output ../socialtest/
type SocialConnector interface {
UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error)
IsEmailAllowed(email string) bool
@@ -295,6 +309,7 @@ type SocialBase struct {
autoAssignOrgRole string
skipOrgRoleSync bool
features featuremgmt.FeatureManager
useRefreshToken bool
}
type Error struct {
@@ -344,6 +359,7 @@ func newSocialBase(name string,
roleAttributeStrict: info.RoleAttributeStrict,
skipOrgRoleSync: skipOrgRoleSync,
features: features,
useRefreshToken: info.UseRefreshToken,
}
}
@@ -592,3 +608,9 @@ func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) {
return rawJSON, nil
}
func appendUniqueScope(config *oauth2.Config, scope string) {
if !slices.Contains(config.Scopes, OfflineAccessScope) {
config.Scopes = append(config.Scopes, OfflineAccessScope)
}
}
@@ -0,0 +1,190 @@
// Code generated by mockery v2.27.1. DO NOT EDIT.
package socialtest
import (
bytes "bytes"
context "context"
http "net/http"
mock "github.com/stretchr/testify/mock"
oauth2 "golang.org/x/oauth2"
social "github.com/grafana/grafana/pkg/login/social"
)
// MockSocialConnector is an autogenerated mock type for the SocialConnector type
type MockSocialConnector struct {
mock.Mock
}
// AuthCodeURL provides a mock function with given fields: state, opts
func (_m *MockSocialConnector) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
_va := make([]interface{}, len(opts))
for _i := range opts {
_va[_i] = opts[_i]
}
var _ca []interface{}
_ca = append(_ca, state)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
var r0 string
if rf, ok := ret.Get(0).(func(string, ...oauth2.AuthCodeOption) string); ok {
r0 = rf(state, opts...)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Client provides a mock function with given fields: ctx, t
func (_m *MockSocialConnector) Client(ctx context.Context, t *oauth2.Token) *http.Client {
ret := _m.Called(ctx, t)
var r0 *http.Client
if rf, ok := ret.Get(0).(func(context.Context, *oauth2.Token) *http.Client); ok {
r0 = rf(ctx, t)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*http.Client)
}
}
return r0
}
// Exchange provides a mock function with given fields: ctx, code, authOptions
func (_m *MockSocialConnector) Exchange(ctx context.Context, code string, authOptions ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
_va := make([]interface{}, len(authOptions))
for _i := range authOptions {
_va[_i] = authOptions[_i]
}
var _ca []interface{}
_ca = append(_ca, ctx, code)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
var r0 *oauth2.Token
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error)); ok {
return rf(ctx, code, authOptions...)
}
if rf, ok := ret.Get(0).(func(context.Context, string, ...oauth2.AuthCodeOption) *oauth2.Token); ok {
r0 = rf(ctx, code, authOptions...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauth2.Token)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, ...oauth2.AuthCodeOption) error); ok {
r1 = rf(ctx, code, authOptions...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// IsEmailAllowed provides a mock function with given fields: email
func (_m *MockSocialConnector) IsEmailAllowed(email string) bool {
ret := _m.Called(email)
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(email)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsSignupAllowed provides a mock function with given fields:
func (_m *MockSocialConnector) IsSignupAllowed() bool {
ret := _m.Called()
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// SupportBundleContent provides a mock function with given fields: _a0
func (_m *MockSocialConnector) SupportBundleContent(_a0 *bytes.Buffer) error {
ret := _m.Called(_a0)
var r0 error
if rf, ok := ret.Get(0).(func(*bytes.Buffer) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
// TokenSource provides a mock function with given fields: ctx, t
func (_m *MockSocialConnector) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource {
ret := _m.Called(ctx, t)
var r0 oauth2.TokenSource
if rf, ok := ret.Get(0).(func(context.Context, *oauth2.Token) oauth2.TokenSource); ok {
r0 = rf(ctx, t)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(oauth2.TokenSource)
}
}
return r0
}
// UserInfo provides a mock function with given fields: ctx, client, token
func (_m *MockSocialConnector) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) {
ret := _m.Called(ctx, client, token)
var r0 *social.BasicUserInfo
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *http.Client, *oauth2.Token) (*social.BasicUserInfo, error)); ok {
return rf(ctx, client, token)
}
if rf, ok := ret.Get(0).(func(context.Context, *http.Client, *oauth2.Token) *social.BasicUserInfo); ok {
r0 = rf(ctx, client, token)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*social.BasicUserInfo)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *http.Client, *oauth2.Token) error); ok {
r1 = rf(ctx, client, token)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type mockConstructorTestingTNewMockSocialConnector interface {
mock.TestingT
Cleanup(func())
}
// NewMockSocialConnector creates a new instance of MockSocialConnector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockSocialConnector(t mockConstructorTestingTNewMockSocialConnector) *MockSocialConnector {
mock := &MockSocialConnector{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,33 @@
package socialtest
import (
"net/http"
"github.com/grafana/grafana/pkg/login/social"
)
type FakeSocialService struct {
ExpectedAuthInfoProvider *social.OAuthInfo
ExpectedConnector social.SocialConnector
ExpectedHttpClient *http.Client
}
func (fss *FakeSocialService) GetOAuthProviders() map[string]bool {
panic("not implemented")
}
func (fss *FakeSocialService) GetOAuthHttpClient(string) (*http.Client, error) {
return fss.ExpectedHttpClient, nil
}
func (fss *FakeSocialService) GetConnector(string) (social.SocialConnector, error) {
return fss.ExpectedConnector, nil
}
func (fss *FakeSocialService) GetOAuthInfoProvider(string) *social.OAuthInfo {
return fss.ExpectedAuthInfoProvider
}
func (fss *FakeSocialService) GetOAuthInfoProviders() map[string]*social.OAuthInfo {
panic("not implemented")
}
+3 -3
View File
@@ -233,16 +233,16 @@ func (r *FakePluginRepo) GetPluginArchiveInfo(ctx context.Context, pluginID, ver
}
type FakePluginStorage struct {
ExtractFunc func(_ context.Context, pluginID string, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error)
ExtractFunc func(_ context.Context, pluginID string, dirNameFunc storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error)
}
func NewFakePluginStorage() *FakePluginStorage {
return &FakePluginStorage{}
}
func (s *FakePluginStorage) Extract(ctx context.Context, pluginID string, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
func (s *FakePluginStorage) Extract(ctx context.Context, pluginID string, dirNameFunc storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
if s.ExtractFunc != nil {
return s.ExtractFunc(ctx, pluginID, z)
return s.ExtractFunc(ctx, pluginID, dirNameFunc, z)
}
return &storage.ExtractedPluginArchive{}, nil
}
+16 -14
View File
@@ -18,27 +18,29 @@ import (
var _ plugins.Installer = (*PluginInstaller)(nil)
type PluginInstaller struct {
pluginRepo repo.Service
pluginStorage storage.ZipExtractor
pluginRegistry registry.Service
pluginLoader loader.Service
log log.Logger
pluginRepo repo.Service
pluginStorage storage.ZipExtractor
pluginStorageDirFunc storage.DirNameGeneratorFunc
pluginRegistry registry.Service
pluginLoader loader.Service
log log.Logger
}
func ProvideInstaller(cfg *config.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service,
pluginRepo repo.Service) *PluginInstaller {
return New(pluginRegistry, pluginLoader, pluginRepo,
storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath))
storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath), storage.SimpleDirNameGeneratorFunc)
}
func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRepo repo.Service,
pluginStorage storage.ZipExtractor) *PluginInstaller {
pluginStorage storage.ZipExtractor, pluginStorageDirFunc storage.DirNameGeneratorFunc) *PluginInstaller {
return &PluginInstaller{
pluginLoader: pluginLoader,
pluginRegistry: pluginRegistry,
pluginRepo: pluginRepo,
pluginStorage: pluginStorage,
log: log.New("plugin.installer"),
pluginLoader: pluginLoader,
pluginRegistry: pluginRegistry,
pluginRepo: pluginRepo,
pluginStorage: pluginStorage,
pluginStorageDirFunc: pluginStorageDirFunc,
log: log.New("plugin.installer"),
}
}
@@ -102,7 +104,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
}
}
extractedArchive, err := m.pluginStorage.Extract(ctx, pluginID, pluginArchive.File)
extractedArchive, err := m.pluginStorage.Extract(ctx, pluginID, m.pluginStorageDirFunc, pluginArchive.File)
if err != nil {
return err
}
@@ -116,7 +118,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
return fmt.Errorf("%v: %w", fmt.Sprintf("failed to download plugin %s from repository", dep.ID), err)
}
depArchive, err := m.pluginStorage.Extract(ctx, dep.ID, d.File)
depArchive, err := m.pluginStorage.Extract(ctx, dep.ID, m.pluginStorageDirFunc, d.File)
if err != nil {
return err
}
+4 -4
View File
@@ -53,7 +53,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
}
fs := &fakes.FakePluginStorage{
ExtractFunc: func(_ context.Context, id string, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
ExtractFunc: func(_ context.Context, id string, _ storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
require.Equal(t, pluginID, id)
require.Equal(t, mockZipV1, z)
return &storage.ExtractedPluginArchive{
@@ -62,7 +62,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
},
}
inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs)
inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs, storage.SimpleDirNameGeneratorFunc)
err := inst.Add(context.Background(), pluginID, v1, testCompatOpts())
require.NoError(t, err)
@@ -108,7 +108,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
File: mockZipV2,
}, nil
}
fs.ExtractFunc = func(_ context.Context, pluginID string, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
fs.ExtractFunc = func(_ context.Context, pluginID string, _ storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
require.Equal(t, pluginV1.ID, pluginID)
require.Equal(t, mockZipV2, z)
return &storage.ExtractedPluginArchive{
@@ -168,7 +168,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
},
}
pm := New(reg, &fakes.FakeLoader{}, &fakes.FakePluginRepo{}, &fakes.FakePluginStorage{})
pm := New(reg, &fakes.FakeLoader{}, &fakes.FakePluginRepo{}, &fakes.FakePluginStorage{}, storage.SimpleDirNameGeneratorFunc)
err := pm.Add(context.Background(), p.ID, "3.2.0", testCompatOpts())
require.ErrorIs(t, err, plugins.ErrInstallCorePlugin)
+3 -7
View File
@@ -28,15 +28,15 @@ type Local struct {
production bool
}
func NewLocalFinder(cfg *config.Cfg) *Local {
func NewLocalFinder(devMode bool) *Local {
return &Local{
production: !cfg.DevMode,
production: !devMode,
log: log.New("local.finder"),
}
}
func ProvideLocalFinder(cfg *config.Cfg) *Local {
return NewLocalFinder(cfg)
return NewLocalFinder(cfg.DevMode)
}
func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) {
@@ -79,10 +79,6 @@ func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins.
continue
}
if _, dupe := foundPlugins[filepath.Dir(pluginJSONAbsPath)]; dupe {
l.log.Warn("Skipping plugin loading as it's a duplicate", "pluginID", plugin.ID)
continue
}
foundPlugins[filepath.Dir(pluginJSONAbsPath)] = plugin
}
@@ -255,7 +255,7 @@ func TestFinder_Find(t *testing.T) {
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
f := NewLocalFinder(pCfg)
f := NewLocalFinder(pCfg.DevMode)
pluginBundles, err := f.Find(context.Background(), &fakes.FakePluginSource{
PluginURIsFunc: func(ctx context.Context) []string {
return tc.pluginDirs
@@ -292,7 +292,7 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
walk = origWalk
})
finder := NewLocalFinder(pCfg)
finder := NewLocalFinder(pCfg.DevMode)
paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err)
require.Empty(t, paths)
@@ -307,7 +307,7 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
walk = origWalk
})
finder := NewLocalFinder(pCfg)
finder := NewLocalFinder(pCfg.DevMode)
paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err)
require.Empty(t, paths)
@@ -322,7 +322,7 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
walk = origWalk
})
finder := NewLocalFinder(pCfg)
finder := NewLocalFinder(pCfg.DevMode)
paths, err := finder.getAbsPluginJSONPaths("test")
require.Error(t, err)
require.Empty(t, paths)
+1 -1
View File
@@ -1494,7 +1494,7 @@ func newLoader(t *testing.T, cfg *config.Cfg, cbs ...func(loader *Loader)) *Load
require.NoError(t, err)
l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(),
fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakeRoleRegistry(),
assetpath.ProvideService(pluginscdn.ProvideService(cfg)), finder.NewLocalFinder(cfg),
assetpath.ProvideService(pluginscdn.ProvideService(cfg)), finder.NewLocalFinder(cfg.DevMode),
signature.ProvideService(statickey.New()), angularInspector, &fakes.FakeOauthService{})
for _, cb := range cbs {
@@ -121,7 +121,7 @@ func TestIntegrationPluginManager(t *testing.T) {
angularInspector, err := angularinspector.NewStaticInspector()
require.NoError(t, err)
l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg),
reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(pCfg), fakes.NewFakeRoleRegistry(),
reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(pCfg.DevMode), fakes.NewFakeRoleRegistry(),
assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(statickey.New()),
angularInspector, &fakes.FakeOauthService{})
srcs := sources.ProvideService(cfg, pCfg)
+13 -8
View File
@@ -32,14 +32,18 @@ func FileSystem(logger log.PrettyLogger, pluginsDir string) *FS {
}
}
func (fs *FS) Extract(ctx context.Context, pluginID string, pluginArchive *zip.ReadCloser) (
var SimpleDirNameGeneratorFunc = func(pluginID string) string {
return pluginID
}
func (fs *FS) Extract(ctx context.Context, pluginID string, dirNameFunc DirNameGeneratorFunc, pluginArchive *zip.ReadCloser) (
*ExtractedPluginArchive, error) {
pluginDir, err := fs.extractFiles(ctx, pluginArchive, pluginID)
pluginDir, err := fs.extractFiles(ctx, pluginArchive, pluginID, dirNameFunc)
if err != nil {
return nil, fmt.Errorf("%v: %w", "failed to extract plugin archive", err)
}
pluginJSON, err := readPluginJSON(pluginID, pluginDir)
pluginJSON, err := readPluginJSON(pluginDir)
if err != nil {
return nil, fmt.Errorf("%v: %w", "failed to convert to plugin DTO", err)
}
@@ -62,8 +66,9 @@ func (fs *FS) Extract(ctx context.Context, pluginID string, pluginArchive *zip.R
}, nil
}
func (fs *FS) extractFiles(_ context.Context, pluginArchive *zip.ReadCloser, pluginID string) (string, error) {
installDir := filepath.Join(fs.pluginsDir, pluginID)
func (fs *FS) extractFiles(_ context.Context, pluginArchive *zip.ReadCloser, pluginID string, dirNameFunc DirNameGeneratorFunc) (string, error) {
pluginDirName := dirNameFunc(pluginID)
installDir := filepath.Join(fs.pluginsDir, pluginDirName)
if _, err := os.Stat(installDir); !os.IsNotExist(err) {
fs.log.Debugf("Removing existing installation of plugin %s", installDir)
err = os.RemoveAll(installDir)
@@ -92,7 +97,7 @@ func (fs *FS) extractFiles(_ context.Context, pluginArchive *zip.ReadCloser, plu
zf.Name, fs.pluginsDir)
}
dstPath := filepath.Clean(filepath.Join(fs.pluginsDir, removeGitBuildFromName(zf.Name, pluginID))) // lgtm[go/zipslip]
dstPath := filepath.Clean(filepath.Join(fs.pluginsDir, removeGitBuildFromName(zf.Name, pluginDirName))) // lgtm[go/zipslip]
if zf.FileInfo().IsDir() {
// We can ignore gosec G304 here since it makes sense to give all users read access
@@ -220,7 +225,7 @@ func removeGitBuildFromName(filename, pluginID string) string {
return reGitBuild.ReplaceAllString(filename, pluginID+"/")
}
func readPluginJSON(pluginID, pluginDir string) (plugins.JSONData, error) {
func readPluginJSON(pluginDir string) (plugins.JSONData, error) {
pluginPath := filepath.Join(pluginDir, "plugin.json")
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
@@ -232,7 +237,7 @@ func readPluginJSON(pluginID, pluginDir string) (plugins.JSONData, error) {
// nolint:gosec
data, err = os.ReadFile(pluginPath)
if err != nil {
return plugins.JSONData{}, fmt.Errorf("could not find plugin.json or dist/plugin.json for %s in %s", pluginID, pluginDir)
return plugins.JSONData{}, fmt.Errorf("could not find plugin.json or dist/plugin.json for in %s", pluginDir)
}
}
+25 -26
View File
@@ -27,25 +27,24 @@ func TestAdd(t *testing.T) {
pluginID := "test-app"
fs := FileSystem(log.NewTestPrettyLogger(), testDir)
archive, err := fs.Extract(context.Background(), pluginID, zipFile(t, "./testdata/plugin-with-symlinks.zip"))
archive, err := fs.Extract(context.Background(), pluginID, SimpleDirNameGeneratorFunc, zipFile(t, "./testdata/plugin-with-symlinks.zip"))
require.NotNil(t, archive)
require.NoError(t, err)
// verify extracted contents
files, err := os.ReadDir(filepath.Join(testDir, pluginID))
files, err := os.ReadDir(archive.Path)
require.NoError(t, err)
file2, err := files[2].Info()
require.NoError(t, err)
file4, err := files[4].Info()
require.NoError(t, err)
require.Len(t, files, 6)
require.Equal(t, files[0].Name(), "MANIFEST.txt")
require.Equal(t, files[1].Name(), "dashboards")
require.Equal(t, files[2].Name(), "extra")
file2, err := files[2].Info()
require.NoError(t, err)
require.Equal(t, os.ModeSymlink, file2.Mode()&os.ModeSymlink)
require.Equal(t, files[3].Name(), "plugin.json")
require.Equal(t, files[4].Name(), "symlink_to_txt")
file4, err := files[4].Info()
require.NoError(t, err)
require.Equal(t, os.ModeSymlink, file4.Mode()&os.ModeSymlink)
require.Equal(t, files[5].Name(), "text.txt")
}
@@ -59,27 +58,27 @@ func TestExtractFiles(t *testing.T) {
skipWindows(t)
pluginID := "grafana-simple-json-datasource"
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip"), pluginID)
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip"), pluginID, SimpleDirNameGeneratorFunc)
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
require.NoError(t, err)
// File in zip has permissions 755
fileInfo, err := os.Stat(filepath.Join(pluginsDir, "grafana-simple-json-datasource", "simple-plugin_darwin_amd64"))
fileInfo, err := os.Stat(filepath.Join(path, "simple-plugin_darwin_amd64"))
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
// File in zip has permission 755
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_linux_amd64")
fileInfo, err = os.Stat(filepath.Join(path, "simple-plugin_linux_amd64"))
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
// File in zip has permission 644
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe")
fileInfo, err = os.Stat(filepath.Join(path, "simple-plugin_windows_amd64.exe"))
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
// File in zip has permission 755
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/non-plugin-binary")
fileInfo, err = os.Stat(filepath.Join(path, "non-plugin-binary"))
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
})
@@ -88,43 +87,43 @@ func TestExtractFiles(t *testing.T) {
skipWindows(t)
pluginID := "plugin-with-symlink"
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink.zip"), pluginID)
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink.zip"), pluginID, SimpleDirNameGeneratorFunc)
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
_, err = os.Stat(filepath.Join(path, "symlink_to_txt"))
require.NoError(t, err)
target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
target, err := filepath.EvalSymlinks(filepath.Join(path, "symlink_to_txt"))
require.NoError(t, err)
require.Equal(t, pluginsDir+"/plugin-with-symlink/text.txt", target)
require.Equal(t, filepath.Join(path, "text.txt"), target)
})
t.Run("Should extract directory with relative symlink", func(t *testing.T) {
skipWindows(t)
pluginID := "plugin-with-symlink-dir"
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink-dir.zip"), pluginID)
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink-dir.zip"), pluginID, SimpleDirNameGeneratorFunc)
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
_, err = os.Stat(filepath.Join(path, "symlink_to_dir"))
require.NoError(t, err)
target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
target, err := filepath.EvalSymlinks(filepath.Join(path, "symlink_to_dir"))
require.NoError(t, err)
require.Equal(t, pluginsDir+"/plugin-with-symlink-dir/dir", target)
require.Equal(t, filepath.Join(path, "dir"), target)
})
t.Run("Should not extract file with absolute symlink", func(t *testing.T) {
skipWindows(t)
pluginID := "plugin-with-absolute-symlink"
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink.zip"), pluginID)
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink.zip"), pluginID, SimpleDirNameGeneratorFunc)
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink/test.txt")
_, err = os.Stat(filepath.Join(path, "/test.txt"))
require.True(t, os.IsNotExist(err))
})
@@ -132,16 +131,16 @@ func TestExtractFiles(t *testing.T) {
skipWindows(t)
pluginID := "plugin-with-absolute-symlink-dir"
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink-dir.zip"), pluginID)
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink-dir.zip"), pluginID, SimpleDirNameGeneratorFunc)
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink-dir/target")
_, err = os.Stat(filepath.Join(path, "/plugin-with-absolute-symlink-dir/target"))
require.True(t, os.IsNotExist(err))
})
t.Run("Should detect if archive members point outside of the destination directory", func(t *testing.T) {
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-parent-member.zip"), "plugin-with-parent-member")
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-parent-member.zip"), "plugin-with-parent-member", SimpleDirNameGeneratorFunc)
require.Empty(t, path)
require.EqualError(t, err, fmt.Sprintf(
`archive member "../member.txt" tries to write outside of plugin directory: %q, this can be a security risk`,
@@ -150,7 +149,7 @@ func TestExtractFiles(t *testing.T) {
})
t.Run("Should detect if archive members are absolute", func(t *testing.T) {
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-member.zip"), "plugin-with-absolute-member")
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-member.zip"), "plugin-with-absolute-member", SimpleDirNameGeneratorFunc)
require.Empty(t, path)
require.EqualError(t, err, fmt.Sprintf(
`archive member "/member.txt" tries to write outside of plugin directory: %q, this can be a security risk`,
+3 -1
View File
@@ -6,5 +6,7 @@ import (
)
type ZipExtractor interface {
Extract(ctx context.Context, pluginID string, rc *zip.ReadCloser) (*ExtractedPluginArchive, error)
Extract(ctx context.Context, pluginID string, destDir DirNameGeneratorFunc, rc *zip.ReadCloser) (*ExtractedPluginArchive, error)
}
type DirNameGeneratorFunc = func(pluginID string) string
+1 -1
View File
@@ -158,7 +158,7 @@ func ProvideService(
s.RegisterPostAuthHook(userSyncService.SyncLastSeenHook, 40)
if features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) {
s.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService).SyncOauthTokenHook, 60)
s.RegisterPostAuthHook(sync.ProvideOAuthTokenSync(oauthTokenService, sessionService, socialService).SyncOauthTokenHook, 60)
}
s.RegisterPostAuthHook(userSyncService.FetchSyncedUserHook, 100)
@@ -3,10 +3,12 @@ package sync
import (
"context"
"errors"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/oauthtoken"
@@ -15,15 +17,19 @@ import (
)
var (
errExpiredAccessToken = errutil.NewBase(errutil.StatusUnauthorized, "oauth.expired-token")
errExpiredAccessToken = errutil.NewBase(
errutil.StatusUnauthorized,
"oauth.expired-token",
errutil.WithPublicMessage("OAuth access token expired"))
)
func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService) *OAuthTokenSync {
func ProvideOAuthTokenSync(service oauthtoken.OAuthTokenService, sessionService auth.UserTokenService, socialService social.Service) *OAuthTokenSync {
return &OAuthTokenSync{
log.New("oauth_token.sync"),
localcache.New(maxOAuthTokenCacheTTL, 15*time.Minute),
service,
sessionService,
socialService,
}
}
@@ -32,6 +38,7 @@ type OAuthTokenSync struct {
cache *localcache.CacheService
service oauthtoken.OAuthTokenService
sessionService auth.UserTokenService
socialService social.Service
}
func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
@@ -64,6 +71,19 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn
return nil
}
// get the token's auth provider (f.e. azuread)
provider := strings.TrimPrefix(token.AuthModule, "oauth_")
currentOAuthInfo := s.socialService.GetOAuthInfoProvider(provider)
if currentOAuthInfo == nil {
s.log.Warn("OAuth provider not found", "provider", provider)
return nil
}
// if refresh token handling is disabled for this provider, we can skip the hook
if !currentOAuthInfo.UseRefreshToken {
return nil
}
expires := token.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta)
// token has not expired, so we don't have to refresh it
if !expires.Before(time.Now()) {
@@ -78,14 +98,14 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, identity *authn
}
if err := s.service.InvalidateOAuthTokens(ctx, token); err != nil {
s.log.FromContext(ctx).Error("Failed invalidate OAuth tokens", "id", identity.ID, "error", err)
s.log.FromContext(ctx).Error("Failed to invalidate OAuth tokens", "id", identity.ID, "error", err)
}
if err := s.sessionService.RevokeToken(ctx, identity.SessionToken, false); err != nil {
s.log.FromContext(ctx).Error("Failed to revoke session token", "id", identity.ID, "tokenId", identity.SessionToken.Id, "error", err)
}
return errExpiredAccessToken.Errorf("oauth access token could not be refreshed: %w", auth.ErrInvalidSessionToken)
return errExpiredAccessToken.Errorf("oauth access token could not be refreshed: %w", err)
}
return nil
@@ -10,6 +10,8 @@ import (
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/socialtest"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
@@ -20,8 +22,9 @@ import (
func TestOauthTokenSync_SyncOAuthTokenHook(t *testing.T) {
type testCase struct {
desc string
identity *authn.Identity
desc string
identity *authn.Identity
oauthInfo *social.OAuthInfo
expectedHasEntryToken *login.UserAuth
expectHasEntryCalled bool
@@ -84,6 +87,13 @@ func TestOauthTokenSync_SyncOAuthTokenHook(t *testing.T) {
expectRevokeTokenCalled: true,
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)},
expectedErr: errExpiredAccessToken,
}, {
desc: "should skip sync when use_refresh_token is disabled",
identity: &authn.Identity{ID: "user:1", SessionToken: &auth.UserToken{}, AuthenticatedBy: login.GitLabAuthModule},
expectHasEntryCalled: true,
expectTryRefreshTokenCalled: false,
expectedHasEntryToken: &login.UserAuth{OAuthExpiry: time.Now().Add(-10 * time.Minute)},
oauthInfo: &social.OAuthInfo{UseRefreshToken: false},
},
}
@@ -118,11 +128,22 @@ func TestOauthTokenSync_SyncOAuthTokenHook(t *testing.T) {
},
}
if tt.oauthInfo == nil {
tt.oauthInfo = &social.OAuthInfo{
UseRefreshToken: true,
}
}
socialService := &socialtest.FakeSocialService{
ExpectedAuthInfoProvider: tt.oauthInfo,
}
sync := &OAuthTokenSync{
log: log.NewNopLogger(),
cache: localcache.New(0, 0),
service: service,
sessionService: sessionService,
socialService: socialService,
}
err := sync.SyncOauthTokenHook(context.Background(), tt.identity, nil)
+7
View File
@@ -647,5 +647,12 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "mlExpressions",
Description: "Enable support for Machine Learning in server-side expressions",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaAlertingSquad,
},
}
)
+1
View File
@@ -94,3 +94,4 @@ prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-me
logsExploreTableVisualisation,experimental,@grafana/observability-logs,false,false,false,true
awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false,false
transformationsRedesign,experimental,@grafana/observability-metrics,false,false,false,true
mlExpressions,experimental,@grafana/alerting-squad,false,false,false,false
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
94 logsExploreTableVisualisation experimental @grafana/observability-logs false false false true
95 awsDatasourcesTempCredentials experimental @grafana/aws-datasources false false false false
96 transformationsRedesign experimental @grafana/observability-metrics false false false true
97 mlExpressions experimental @grafana/alerting-squad false false false false
+4
View File
@@ -386,4 +386,8 @@ const (
// FlagTransformationsRedesign
// Enables the transformations redesign
FlagTransformationsRedesign = "transformationsRedesign"
// FlagMlExpressions
// Enable support for Machine Learning in server-side expressions
FlagMlExpressions = "mlExpressions"
)
+18 -9
View File
@@ -270,10 +270,10 @@ func getExprRequest(ctx EvaluationContext, data []models.AlertQuery, dsCacheServ
ds, ok := datasources[q.DatasourceUID]
if !ok {
switch nodeType := expr.NodeTypeFromDatasourceUID(q.DatasourceUID); nodeType {
case expr.TypeCMDNode:
ds, err = expr.DataSourceModelFromNodeType(nodeType)
case expr.TypeDatasourceNode:
ds, err = dsCacheService.GetDatasourceByUID(ctx.Ctx, q.DatasourceUID, ctx.User, false /*skipCache*/)
default:
ds, err = expr.DataSourceModelFromNodeType(nodeType)
}
if err != nil {
return nil, fmt.Errorf("failed to build query '%s': %w", q.RefID, err)
@@ -622,15 +622,24 @@ func (e *evaluatorImpl) Validate(ctx EvaluationContext, condition models.Conditi
return err
}
for _, query := range req.Queries {
if query.DataSource == nil || expr.NodeTypeFromDatasourceUID(query.DataSource.UID) != expr.TypeDatasourceNode {
if query.DataSource == nil {
continue
}
p, found := e.pluginsStore.Plugin(ctx.Ctx, query.DataSource.Type)
if !found { // technically this should fail earlier during datasource resolution phase.
return fmt.Errorf("datasource refID %s could not be found: %w", query.RefID, plugins.ErrPluginUnavailable)
}
if !p.Backend {
return fmt.Errorf("datasource refID %s is not a backend datasource", query.RefID)
switch expr.NodeTypeFromDatasourceUID(query.DataSource.UID) {
case expr.TypeDatasourceNode:
p, found := e.pluginsStore.Plugin(ctx.Ctx, query.DataSource.Type)
if !found { // technically this should fail earlier during datasource resolution phase.
return fmt.Errorf("datasource refID %s could not be found: %w", query.RefID, plugins.ErrPluginUnavailable)
}
if !p.Backend {
return fmt.Errorf("datasource refID %s is not a backend datasource", query.RefID)
}
case expr.TypeMLNode:
_, found := e.pluginsStore.Plugin(ctx.Ctx, query.DataSource.Type)
if !found {
return fmt.Errorf("datasource refID %s could not be found: %w", query.RefID, plugins.ErrPluginUnavailable)
}
case expr.TypeCMDNode:
}
}
_, err = e.create(condition, req)
+5 -75
View File
@@ -1,10 +1,8 @@
package oauthtoken
import (
"bytes"
"context"
"errors"
"net/http"
"reflect"
"testing"
"time"
@@ -15,7 +13,7 @@ import (
"golang.org/x/sync/singleflight"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/socialtest"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/login/authinfoservice"
"github.com/grafana/grafana/pkg/services/user"
@@ -221,12 +219,12 @@ func TestService_TryTokenRefresh_DifferentAuthModuleForUser(t *testing.T) {
socialConnector.AssertNotCalled(t, "TokenSource")
}
func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *MockSocialConnector) {
func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *socialtest.MockSocialConnector) {
t.Helper()
socialConnector := &MockSocialConnector{}
socialService := &FakeSocialService{
connector: socialConnector,
socialConnector := &socialtest.MockSocialConnector{}
socialService := &socialtest.FakeSocialService{
ExpectedConnector: socialConnector,
}
authInfoStore := &FakeAuthInfoStore{}
@@ -239,74 +237,6 @@ func setupOAuthTokenService(t *testing.T) (*Service, *FakeAuthInfoStore, *MockSo
}, authInfoStore, socialConnector
}
type FakeSocialService struct {
httpClient *http.Client
connector *MockSocialConnector
}
func (fss *FakeSocialService) GetOAuthProviders() map[string]bool {
panic("not implemented")
}
func (fss *FakeSocialService) GetOAuthHttpClient(string) (*http.Client, error) {
return fss.httpClient, nil
}
func (fss *FakeSocialService) GetConnector(string) (social.SocialConnector, error) {
return fss.connector, nil
}
func (fss *FakeSocialService) GetOAuthInfoProvider(string) *social.OAuthInfo {
panic("not implemented")
}
func (fss *FakeSocialService) GetOAuthInfoProviders() map[string]*social.OAuthInfo {
panic("not implemented")
}
type MockSocialConnector struct {
mock.Mock
}
func (m *MockSocialConnector) Type() int {
args := m.Called()
return args.Int(0)
}
func (m *MockSocialConnector) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) {
args := m.Called(client, token)
return args.Get(0).(*social.BasicUserInfo), args.Error(1)
}
func (m *MockSocialConnector) IsEmailAllowed(email string) bool {
panic("not implemented")
}
func (m *MockSocialConnector) IsSignupAllowed() bool {
panic("not implemented")
}
func (m *MockSocialConnector) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
panic("not implemented")
}
func (m *MockSocialConnector) Exchange(ctx context.Context, code string, authOptions ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
panic("not implemented")
}
func (m *MockSocialConnector) Client(ctx context.Context, t *oauth2.Token) *http.Client {
panic("not implemented")
}
func (m *MockSocialConnector) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource {
args := m.Called(ctx, t)
return args.Get(0).(oauth2.TokenSource)
}
func (m *MockSocialConnector) SupportBundleContent(bf *bytes.Buffer) error {
return nil
}
type FakeAuthInfoStore struct {
login.Store
ExpectedError error
+75
View File
@@ -809,3 +809,78 @@ func TestRedactedValue(t *testing.T) {
})
}
}
func TestHandleAWSSettings(t *testing.T) {
t.Run("Should set default auth providers if not defined", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("allowed_auth_providers", "")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, []string{"default", "keys", "credentials"}, cfg.AWSAllowedAuthProviders)
})
t.Run("Should pass on auth providers defined in config", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("allowed_auth_providers", "keys, credentials")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, []string{"keys", "credentials"}, cfg.AWSAllowedAuthProviders)
})
t.Run("Should set assume role to true if not defined", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("assume_role_enabled", "")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, true, cfg.AWSAssumeRoleEnabled)
})
t.Run("Should set assume role to true if defined as true in the config", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("assume_role_enabled", "true")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, true, cfg.AWSAssumeRoleEnabled)
})
t.Run("Should set assume role to false if defined as false in the config", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("assume_role_enabled", "false")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, false, cfg.AWSAssumeRoleEnabled)
})
t.Run("Should set default page limit if not defined", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("list_metrics_page_limit", "")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, 500, cfg.AWSListMetricsPageLimit)
})
t.Run("Should pass on the limit if it is defined in the config", func(t *testing.T) {
cfg := NewCfg()
awsSection, err := cfg.Raw.NewSection("aws")
require.NoError(t, err)
_, err = awsSection.NewKey("list_metrics_page_limit", "400")
require.NoError(t, err)
cfg.handleAWSConfig()
assert.Equal(t, 400, cfg.AWSListMetricsPageLimit)
})
}
+15 -27
View File
@@ -102,7 +102,7 @@ func processLogsResponse(res *es.SearchResponse, target *Query, configuredFields
for hitIdx, hit := range res.Hits.Hits {
var flattened map[string]interface{}
if hit["_source"] != nil {
flattened = flatten(hit["_source"].(map[string]interface{}))
flattened = flatten(hit["_source"].(map[string]interface{}), 10)
}
doc := map[string]interface{}{
@@ -174,7 +174,7 @@ func processRawDataResponse(res *es.SearchResponse, target *Query, configuredFie
for hitIdx, hit := range res.Hits.Hits {
var flattened map[string]interface{}
if hit["_source"] != nil {
flattened = flatten(hit["_source"].(map[string]interface{}))
flattened = flatten(hit["_source"].(map[string]interface{}), 10)
}
doc := map[string]interface{}{
@@ -1041,38 +1041,26 @@ func getErrorFromElasticResponse(response *es.SearchResponse) string {
}
// flatten flattens multi-level objects to single level objects. It uses dot notation to join keys.
func flatten(target map[string]interface{}) map[string]interface{} {
func flatten(target map[string]interface{}, maxDepth int) map[string]interface{} {
// On frontend maxDepth wasn't used but as we are processing on backend
// let's put a limit to avoid infinite loop. 10 was chosen arbitrary.
maxDepth := 10
currentDepth := 0
delimiter := ""
output := make(map[string]interface{})
step(0, maxDepth, target, "", output)
return output
}
var step func(object map[string]interface{}, prev string)
func step(currentDepth, maxDepth int, target map[string]interface{}, prev string, output map[string]interface{}) {
nextDepth := currentDepth + 1
for key, value := range target {
newKey := strings.Trim(prev+"."+key, ".")
step = func(object map[string]interface{}, prev string) {
for key, value := range object {
if prev == "" {
delimiter = ""
} else {
delimiter = "."
}
newKey := prev + delimiter + key
v, ok := value.(map[string]interface{})
shouldStepInside := ok && len(v) > 0 && currentDepth < maxDepth
if shouldStepInside {
currentDepth++
step(v, newKey)
} else {
output[newKey] = value
}
v, ok := value.(map[string]interface{})
if ok && len(v) > 0 && currentDepth < maxDepth {
step(nextDepth, maxDepth, v, newKey, output)
} else {
output[newKey] = value
}
}
step(target, "")
return output
}
// sortPropNames orders propNames so that timeField is first (if it exists), log message field is second
+103 -3
View File
@@ -2481,7 +2481,6 @@ func TestProcessBuckets(t *testing.T) {
}
]
}
`)
result, err := queryDataTest(query, response)
@@ -3305,7 +3304,7 @@ func TestFlatten(t *testing.T) {
},
}
flattened := flatten(obj)
flattened := flatten(obj, 10)
require.Len(t, flattened, 2)
require.Equal(t, "bar", flattened["foo"])
require.Equal(t, "qux", flattened["nested.bax.baz"])
@@ -3340,10 +3339,111 @@ func TestFlatten(t *testing.T) {
},
}
flattened := flatten(obj)
flattened := flatten(obj, 10)
require.Len(t, flattened, 1)
require.Equal(t, map[string]interface{}{"nested11": map[string]interface{}{"nested12": "abc"}}, flattened["nested0.nested1.nested2.nested3.nested4.nested5.nested6.nested7.nested8.nested9.nested10"])
})
t.Run("does not affect any non-nested JSON", func(t *testing.T) {
target := map[string]interface{}{
"fieldName": "",
}
assert.Equal(t, map[string]interface{}{
"fieldName": "",
}, flatten(target, 10))
})
t.Run("flattens up to maxDepth", func(t *testing.T) {
target := map[string]interface{}{
"fieldName2": map[string]interface{}{
"innerFieldName2": map[string]interface{}{
"innerFieldName3": "",
},
},
}
assert.Equal(t, map[string]interface{}{
"fieldName2.innerFieldName2": map[string]interface{}{"innerFieldName3": ""}}, flatten(target, 1))
})
t.Run("flattens up to maxDepth with multiple keys in target", func(t *testing.T) {
target := map[string]interface{}{
"fieldName": map[string]interface{}{
"innerFieldName": "",
},
"fieldName2": map[string]interface{}{
"innerFieldName2": map[string]interface{}{
"innerFieldName3": "",
},
},
}
assert.Equal(t, map[string]interface{}{"fieldName.innerFieldName": "", "fieldName2.innerFieldName2": map[string]interface{}{"innerFieldName3": ""}}, flatten(target, 1))
})
t.Run("flattens multiple objects of the same max depth", func(t *testing.T) {
target := map[string]interface{}{
"fieldName": map[string]interface{}{
"innerFieldName": "",
},
"fieldName2": map[string]interface{}{
"innerFieldName2": "",
},
}
assert.Equal(t, map[string]interface{}{
"fieldName.innerFieldName": "",
"fieldName2.innerFieldName2": ""}, flatten(target, 1))
})
t.Run("only flattens multiple entries in the same key", func(t *testing.T) {
target := map[string]interface{}{
"fieldName": map[string]interface{}{
"innerFieldName": "",
"innerFieldName1": "",
},
"fieldName2": map[string]interface{}{
"innerFieldName2": map[string]interface{}{
"innerFieldName3": "",
},
},
}
assert.Equal(t, map[string]interface{}{
"fieldName.innerFieldName": "",
"fieldName.innerFieldName1": "",
"fieldName2.innerFieldName2": map[string]interface{}{"innerFieldName3": ""}}, flatten(target, 1))
})
t.Run("combines nested field names", func(t *testing.T) {
target := map[string]interface{}{
"fieldName": map[string]interface{}{
"innerFieldName": "",
},
"fieldName2": map[string]interface{}{
"innerFieldName2": "",
},
}
assert.Equal(t, map[string]interface{}{"fieldName.innerFieldName": "", "fieldName2.innerFieldName2": ""}, flatten(target, 10))
})
t.Run("will preserve only one key with the same name", func(t *testing.T) {
// This test documents that in the unlikely case of a collision of a flattened name and an existing key, only
// one entry's value will be preserved at random
target := map[string]interface{}{
"fieldName": map[string]interface{}{
"innerFieldName": "one of these values will be lost",
},
"fieldName.innerFieldName": "this may be lost",
}
result := flatten(target, 10)
assert.Len(t, result, 1)
_, ok := result["fieldName.innerFieldName"]
assert.True(t, ok)
})
}
func TestTrimEdges(t *testing.T) {
@@ -6,6 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { Trans } from 'app/core/internationalization';
import { Indent } from 'app/features/browse-dashboards/components/Indent';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
@@ -39,15 +40,21 @@ export function NestedFolderList({
return (
<div className={styles.table}>
<List
height={ROW_HEIGHT * Math.min(6.5, items.length)}
width="100%"
itemData={virtualData}
itemSize={ROW_HEIGHT}
itemCount={items.length}
>
{Row}
</List>
{items.length > 0 ? (
<List
height={ROW_HEIGHT * Math.min(6.5, items.length)}
width="100%"
itemData={virtualData}
itemSize={ROW_HEIGHT}
itemCount={items.length}
>
{Row}
</List>
) : (
<div className={styles.emptyMessage}>
<Trans i18nKey="browse-dashboards.folder-picker.empty-message">No folders found</Trans>
</div>
)}
</div>
);
}
@@ -132,7 +139,6 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
)}
<label className={styles.label} htmlFor={id}>
{/* TODO: text is not truncated properly, it still overflows the container */}
<Text as="span" truncate>
{item.title}
</Text>
@@ -159,6 +165,12 @@ const getStyles = (theme: GrafanaTheme2) => {
background: theme.components.input.background,
}),
emptyMessage: css({
padding: theme.spacing(1),
textAlign: 'center',
width: '100%',
}),
// Should be the same size as the <IconButton /> for proper alignment
folderButtonSpacer: css({
paddingLeft: `calc(${getSvgSize(CHEVRON_SIZE)}px + ${theme.spacing(0.5)})`,
@@ -86,7 +86,7 @@ describe('NestedFolderPicker', () => {
expect(screen.queryByRole('button', { name: 'Select folder' })).not.toBeInTheDocument();
// Search input and folder tree are visible
expect(screen.getByPlaceholderText('Search folder')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search folders')).toBeInTheDocument();
expect(screen.getByLabelText('Dashboards')).toBeInTheDocument();
expect(screen.getByLabelText(folderA.item.title)).toBeInTheDocument();
expect(screen.getByLabelText(folderB.item.title)).toBeInTheDocument();
@@ -5,8 +5,9 @@ import { usePopperTooltip } from 'react-popper-tooltip';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, FilterInput, LoadingBar, useStyles2 } from '@grafana/ui';
import { Alert, Button, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { Trans, t } from 'app/core/internationalization';
import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { listFolders, PAGE_SIZE } from 'app/features/browse-dashboards/api/services';
import { createFlatTree } from 'app/features/browse-dashboards/state';
@@ -172,7 +173,7 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
<Skeleton width={100} />
) : (
<Text as="span" truncate>
{label ?? 'Select folder'}
{label ?? <Trans i18nKey="browse-dashboards.folder-picker.button-label">Select folder</Trans>}
</Text>
)}
</Button>
@@ -181,14 +182,15 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
return (
<>
<FilterInput
<Input
ref={setTriggerRef}
autoFocus
placeholder={label ?? 'Search folder'}
placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')}
value={search}
escapeRegex={false}
className={styles.search}
onChange={(val) => setSearch(val)}
onChange={(e) => setSearch(e.currentTarget.value)}
role="combobox"
suffix={<Icon name="search" />}
/>
<fieldset
ref={setTooltipRef}
@@ -200,8 +202,12 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
})}
>
{error ? (
<Alert className={styles.error} severity="warning" title="Error loading folders">
{error.message || error.toString?.() || 'Unknown error'}
<Alert
className={styles.error}
severity="warning"
title={t('browse-dashboards.folder-picker.error-title', 'Error loading folders')}
>
{error.message || error.toString?.() || t('browse-dashboards.folder-picker.unknown-error', 'Unknown error')}
</Alert>
) : (
<div>
+31 -24
View File
@@ -1,50 +1,59 @@
import i18n, { BackendModule } from 'i18next';
import i18n, { BackendModule, InitOptions } from 'i18next';
import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetector';
import React from 'react';
import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // eslint-disable-line no-restricted-imports
import { DEFAULT_LANGUAGE, LANGUAGES, VALID_LANGUAGES } from './constants';
import { LANGUAGES, VALID_LANGUAGES } from './constants';
const getLanguagePartFromCode = (code: string) => code.split('-')[0].toLowerCase();
const loadTranslations: BackendModule = {
type: 'backend',
init() {},
async read(language, namespace, callback) {
const localeDef = LANGUAGES.find((v) => v.code === language);
let localeDef = LANGUAGES.find((v) => v.code === language);
if (!localeDef) {
localeDef = LANGUAGES.find((v) => getLanguagePartFromCode(v.code) === getLanguagePartFromCode(language));
}
if (!localeDef) {
return callback(new Error('No message loader available for ' + language), null);
}
const messages = await localeDef.loader();
callback(null, messages);
},
};
export function initializeI18n(language: string) {
const validLocale = VALID_LANGUAGES.includes(language) ? language : DEFAULT_LANGUAGE;
// This is a placeholder so we can put a 'comment' in the message json files.
// Starts with an underscore so it's sorted to the top of the file. Even though it is in a comment the following line is still extracted
// t('_comment', 'This file is the source of truth for English strings. Edit this to change plurals and other phrases for the UI.');
return i18n
const options: InitOptions = {
// We don't bundle any translations, we load them async
partialBundledLanguages: true,
resources: {},
// If translations are empty strings (no translation), fall back to the default value in source code
returnEmptyString: false,
pluralSeparator: '__',
};
let init = i18n;
if (language === 'detect') {
init = init.use(LanguageDetector);
const detection: DetectorOptions = { order: ['navigator'], caches: [] };
options.detection = detection;
} else {
options.lng = VALID_LANGUAGES.includes(language) ? language : undefined;
}
return init
.use(loadTranslations)
.use(initReactI18next) // passes i18n down to react-i18next
.init({
lng: validLocale,
// We don't bundle any translations, we load them async
partialBundledLanguages: true,
resources: {},
// If translations are empty strings (no translation), fall back to the default value in source code
returnEmptyString: false,
pluralSeparator: '__',
});
.init(options);
}
export function changeLanguage(locale: string) {
const validLocale = VALID_LANGUAGES.includes(locale) ? locale : DEFAULT_LANGUAGE;
const validLocale = VALID_LANGUAGES.includes(locale) ? locale : undefined;
return i18n.changeLanguage(validLocale);
}
@@ -63,8 +72,6 @@ export const i18nDate = (value: number | Date | string, format: Intl.DateTimeFor
if (typeof value === 'string') {
return i18nDate(new Date(value), format);
}
const locale = i18n.options.lng ?? DEFAULT_LANGUAGE;
const dateFormatter = new Intl.DateTimeFormat(locale, format);
const dateFormatter = new Intl.DateTimeFormat(i18n.language, format);
return dateFormatter.format(value);
};
@@ -36,6 +36,75 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => <p>hi</p>,
}));
jest.mock('./components/rule-editor/util', () => {
const originalModule = jest.requireActual('./components/rule-editor/util');
return {
...originalModule,
getThresholdsForQueries: jest.fn(() => ({})),
};
});
const dataSources = {
// can edit rules
loki: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with ruler',
},
{ alerting: true }
),
loki_disabled: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki disabled for alerting',
jsonData: {
manageAlerts: false,
},
},
{ alerting: true }
),
// can edit rules
prom: mockDataSource(
{
type: DataSourceType.Prometheus,
name: 'cortex with ruler',
},
{ alerting: true }
),
// cannot edit rules
loki_local_rule_store: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with local rule store',
},
{ alerting: true }
),
// cannot edit rules
prom_no_ruler_api: mockDataSource(
{
type: DataSourceType.Loki,
name: 'cortex without ruler api',
},
{ alerting: true }
),
// not a supported datasource type
splunk: mockDataSource(
{
type: 'splunk',
name: 'splunk',
},
{ alerting: true }
),
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.prom,
get: () => dataSources.prom,
})),
}));
jest.spyOn(config, 'getAllDataSources');
const mocks = {
@@ -74,59 +143,6 @@ describe('RuleEditor cloud: checking editable data sources', () => {
disableRBAC();
it('for cloud alerts, should only allow to select editable rules sources', async () => {
const dataSources = {
// can edit rules
loki: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with ruler',
},
{ alerting: true }
),
loki_disabled: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki disabled for alerting',
jsonData: {
manageAlerts: false,
},
},
{ alerting: true }
),
// can edit rules
prom: mockDataSource(
{
type: DataSourceType.Prometheus,
name: 'cortex with ruler',
},
{ alerting: true }
),
// cannot edit rules
loki_local_rule_store: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with local rule store',
},
{ alerting: true }
),
// cannot edit rules
prom_no_ruler_api: mockDataSource(
{
type: DataSourceType.Loki,
name: 'cortex without ruler api',
},
{ alerting: true }
),
// not a supported datasource type
splunk: mockDataSource(
{
type: 'splunk',
name: 'splunk',
},
{ alerting: true }
),
};
mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => {
if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') {
return getDiscoverFeaturesMock(PromApplication.Cortex, { rulerApiEnabled: true });
@@ -168,11 +184,22 @@ describe('RuleEditor cloud: checking editable data sources', () => {
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner'));
await ui.inputs.name.find();
await userEvent.click(await ui.buttons.lotexAlert.get());
const removeExpressionsButtons = screen.getAllByLabelText('Remove expression');
expect(removeExpressionsButtons).toHaveLength(2);
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule');
expect(switchToCloudButton).toBeInTheDocument();
await userEvent.click(switchToCloudButton);
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText('Remove expression')).toHaveLength(0);
// check that only rules sources that have ruler available are there
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(byRole('combobox').get(dataSourceSelect));
expect(await byText('loki with ruler').query()).toBeInTheDocument();
expect(byText('cortex with ruler').query()).toBeInTheDocument();
expect(byText('loki with local rule store').query()).not.toBeInTheDocument();
@@ -1,4 +1,4 @@
import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
@@ -36,6 +36,33 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => <p>hi</p>,
}));
jest.mock('./components/rule-editor/util', () => {
const originalModule = jest.requireActual('./components/rule-editor/util');
return {
...originalModule,
getThresholdsForQueries: jest.fn(() => ({})),
};
});
const dataSources = {
default: mockDataSource(
{
type: 'prometheus',
name: 'Prom',
isDefault: true,
},
{ alerting: true }
),
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.default,
get: () => dataSources.default,
})),
}));
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
}));
@@ -73,17 +100,6 @@ describe('RuleEditor cloud', () => {
disableRBAC();
it('can create a new cloud alert', async () => {
const dataSources = {
default: mockDataSource(
{
type: 'prometheus',
name: 'Prom',
isDefault: true,
},
{ alerting: true }
),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
@@ -118,7 +134,18 @@ describe('RuleEditor cloud', () => {
renderRuleEditor();
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner'));
await userEvent.click(await ui.buttons.lotexAlert.find());
const removeExpressionsButtons = screen.getAllByLabelText('Remove expression');
expect(removeExpressionsButtons).toHaveLength(2);
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule');
expect(switchToCloudButton).toBeInTheDocument();
await userEvent.click(switchToCloudButton);
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText('Remove expression')).toHaveLength(0);
expect(screen.getByTestId('datasource-picker')).toBeInTheDocument();
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(byRole('combobox').get(dataSourceSelect));
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import produce from 'immer';
import { produce } from 'immer';
import React, { useEffect, useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useToggle } from 'react-use';

Some files were not shown because too many files have changed in this diff Show More