Merge remote-tracking branch 'origin/main' into release-0.0.0-cloud.0
This commit is contained in:
+1
-1
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+25
-21
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+3
-3
@@ -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](
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
+9
-2
@@ -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.
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||

|
||||
|
||||
- **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
@@ -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');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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  
|
||||
<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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -386,4 +386,8 @@ const (
|
||||
// FlagTransformationsRedesign
|
||||
// Enables the transformations redesign
|
||||
FlagTransformationsRedesign = "transformationsRedesign"
|
||||
|
||||
// FlagMlExpressions
|
||||
// Enable support for Machine Learning in server-side expressions
|
||||
FlagMlExpressions = "mlExpressions"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user