Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2024-12-04 16:18:55 +03:00
commit 6c83756539
121 files changed
+2511 -1329

No files matched your search

-2
View File
@@ -705,7 +705,6 @@ embed.go @grafana/grafana-as-code
/.github/pr-checks.json @tolzhabayev
/.github/pr-commands.json @tolzhabayev
/.github/renovate.json5 @grafana/frontend-ops
/.github/teams.yml @armandgrillet
/.github/workflows/alerting-swagger-gen.yml @grafana/alerting-backend
/.github/workflows/auto-milestone.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/backport.yml @grafana/grafana-developer-enablement-squad
@@ -722,7 +721,6 @@ embed.go @grafana/grafana-as-code
/.github/workflows/deploy-pr-preview.yml @grafana/docs-tooling
/.github/workflows/epic-add-to-platform-ux-parent-project.yml @meanmina
/.github/workflows/github-release.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/issue-labeled.yml @armandgrillet
/.github/workflows/issue-opened.yml @grafana/grafana-community-support
/.github/workflows/metrics-collector.yml @torkelo
/.github/workflows/milestone.yml @tolzhabayev
-104
View File
@@ -1,104 +0,0 @@
# The first keys are labels used on issues. All fields are optional.
# Example
test:
# channel-label is used to send a message to a specific channel
# when the label "test" is added to an issue.
channel-label: CXXXXXXXXXX
# Alerting team
area/alerting:
channel-label: C028MCV4R7C
exclude-github-team: alerting-squad
# DataViz squad
area/dataviz:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/legend:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/barchart:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/bargauge:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/candlestick:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/canvas:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/common:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/gauge:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/geomap:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/graph:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/heatmap:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/histogram:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/piechart:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/stat:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/state-timeline:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/status-history:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/timeseries:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/trend:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/panel/xychart:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/tooltip:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/transformations:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/value-mapping:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
area/units:
channel-label: C04J73AAQ87 # grafana-dataviz
exclude-github-team: dataviz-squad
-99
View File
@@ -1,99 +0,0 @@
name: Notify Slack channel based on new issue label
on:
issues:
types: [labeled]
jobs:
config:
runs-on: "ubuntu-latest"
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
steps:
- name: "Check for secrets"
id: check
shell: bash
run: |
if [ -n "${{ (secrets.SLACK_WEBHOOK_URL != '') || '' }}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
notify:
needs: config
if: needs.config.outputs.has-secrets
runs-on: ubuntu-latest
steps:
- name: "Download teams.yml to know which label is for which team"
run: wget https://raw.githubusercontent.com/grafana/grafana/main/.github/teams.yml
- name: "Determine which team to notify"
run: |
# Default to null values.
CHANNEL="null"
TEAM="null"
echo "${{ github.event.label.name }} label added"
export CURRENT_LABEL="${{ github.event.label.name }}" # Enable the use of the label in yq evaluations
# yq is installed by default in ubuntu-latest
if [[ $(yq e 'keys | .[] | select(. == env(CURRENT_LABEL))' teams.yml ) ]]; then
# Check if we have a channel set to notify on comments.
if [[ $(yq '.[env(CURRENT_LABEL)] | has("channel-label")' teams.yml ) == true ]]; then
CHANNEL=$(yq '.[env(CURRENT_LABEL)].channel-label' teams.yml)
echo "Ready to send issue to channel ID ${CHANNEL}"
fi
if [[ $(yq '.[env(CURRENT_LABEL)] | has("exclude-github-team")' teams.yml ) == true ]]; then
TEAM=$(yq '.[env(CURRENT_LABEL)].exclude-github-team' teams.yml)
echo "Will not send issue to channel if issue author is part of the team ${TEAM}"
fi
fi
# set environment for next steps
echo "CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "TEAM=${TEAM}" >> "$GITHUB_ENV"
- name: "Prepare payload"
uses: frabert/replace-string-action@v2.5
id: preparePayload
with:
# replace double quotes with single quotes to avoid breaking the JSON payload sent to Slack
string: ${{ github.event.issue.title }}
pattern: '"'
replace-with: "'"
flags: 'g'
- name: Get Token
id: get_workflow_token
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app_id: ${{ secrets.APP_GRAFANA_TEAM_CHECKER_ID }}
private_key: ${{ secrets.APP_GRAFANA_TEAM_CHECKER_KEY }}
- name: "Check that issue author is not part of the team"
if: ${{ env.TEAM != 'null' }}
run: |
response=$(gh api /orgs/grafana/teams/${{ env.TEAM }}/memberships/${{ github.event.issue.user.login }} -i -H "Accept: application/vnd.github.v3+json")
STATUS_CODE=$(echo "$response" | head -n 1 | cut -d' ' -f2)
if [ "$STATUS_CODE" -eq "404" ]; then
echo "The user was not found in the team."
echo "USER_FOUND=false" >> "$GITHUB_ENV"
else
echo "The user was potentially found in the team"
echo "USER_FOUND=maybe" >> "$GITHUB_ENV"
fi
env:
GITHUB_TOKEN: ${{ steps.get_workflow_token.outputs.token }}
- name: "Send Slack notification"
if: ${{ (env.CHANNEL != 'null') && ((env.USER_FOUND == 'false') || (env.TEAM != 'null')) }}
uses: slackapi/slack-github-action@v1.26.0
with:
payload: >
{
"icon_emoji": ":grafana:",
"username": "Grafana issue labeled",
"text": "Issue \"${{ steps.preparePayload.outputs.replaced }}\" labeled \"${{ github.event.label.name }}\": ${{ github.event.issue.html_url }}, please triage.",
"channel": "${{ env.CHANNEL }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+2 -1
View File
@@ -3,4 +3,5 @@
List of previous team members that have had a big impact on the company or the product and contributed during a long period of time.
- Hugo Häggmark ([Björn Lundén](https://www.bjornlunden.se/))
- Marcus Efraimsson
- [Marcus Efraimsson](https://github.com/marefr)
- [Giordano Ricci](https://github.com/elfo404)
+2 -1
View File
@@ -50,6 +50,7 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
@@ -60,7 +61,7 @@ require (
go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.26.0 // indirect
golang.org/x/text v0.20.0 // indirect
+4 -4
View File
@@ -106,8 +106,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -143,8 +143,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -56,8 +56,6 @@ This section describes how to:
In both cases, the assignment applies only to the user, team or service account within the affected organization, and no other organizations. For example, if you grant the user the **Data source editor** role in the **Main** organization, then the user can edit data sources in the **Main** organization, but not in other organizations.
<br/>
**Before you begin:**
- [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy).
@@ -71,8 +69,6 @@ In both cases, the assignment applies only to the user, team or service account
- Your Grafana user can also assign fixed role if it has either the `fixed:roles:writer` fixed role assigned to the same organization to which you are assigning RBAC to a user, or a custom role with `users.roles:add` and `users.roles:remove` permissions.
- Your own user account must have the roles you are granting. For example, if you would like to grant the `fixed:users:writer` role to a team, you must have that role yourself.
<br/>
**To assign a fixed role to a user, team or service account:**
1. Sign in to Grafana.
@@ -93,6 +89,35 @@ In both cases, the assignment applies only to the user, team or service account
1. Select a role within an organization that you want to assign to the user.
1. Click **Save**.
## Assign fixed or custom roles using the HTTP API
This section describes how to:
- Assign a fixed role to a user, team or service account as an organization administrator.
**Before you begin:**
- [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy).
- Identify the fixed roles that you want to assign to the user, team or service account.
For more information about available fixed roles, refer to [RBAC role definitions](ref:rbac-role-definitions).
- Ensure that your own user account has the correct permissions:
- If you are assigning permissions to a user, team or service account within an organization, you must have organization administrator or server administrator permissions.
- If you are assigning permissions to a user who belongs to multiple organizations, you must have server administrator permissions.
- Your Grafana user can also assign fixed role if it has either the `fixed:roles:writer` fixed role assigned to the same organization to which you are assigning RBAC to a user, or a custom role with `users.roles:add` and `users.roles:remove` permissions.
- Your own user account must have the roles you are granting. For example, if you would like to grant the `fixed:users:writer` role to a team, you must have that role yourself.
**To assign a fixed role to a user or service account:**
Refer to the [RBAC HTTP API for user role assignment](/docs/grafana/<GRAFANA_VERSION>/developers/http_api/access_control/#add-a-user-role-assignment).
The service account ID can be used in place of the user ID.
**To assign a fixed role to a team:**
Refer to the [RBAC HTTP API for team role assignment](/docs/grafana/<GRAFANA_VERSION>/developers/http_api/access_control/#add-a-team-role-assignment).
## Assign fixed or custom roles to a team using provisioning
Instead of using the Grafana role picker, you can use file-based provisioning to assign fixed roles to teams. If you have a large number of teams, provisioning can provide an easier approach to assigning and managing role assignments.
@@ -161,8 +161,8 @@ By default, service account tokens don't have an expiration date, meaning they w
## Assign roles to a service account in Grafana
You can assign roles to a Grafana service account to control access for the associated service account tokens.
You can assign roles to a service account using the Grafana UI or via the API. For more information about assigning a role to a service account via the API, refer to [Update service account using the HTTP API](ref:api-update-service-account).
You can assign organization roles (`Viewer`, `Editor`, `Admin`) to a Grafana service account to control access for the associated service account tokens.
You can assign organization roles to a service account using the Grafana UI or via the API. For more information about assigning a role to a service account via the API, refer to [Update service account using the HTTP API](ref:api-update-service-account).
In [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/), you can also [assign RBAC roles](ref:rbac-assign-rbac-roles) to grant very specific permissions to applications that interact with Grafana.
@@ -18,16 +18,16 @@ labels:
title: Template annotations and labels
weight: 500
refs:
labels:
reference-labels:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/#labels
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/reference/#labels
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#labels
values:
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/#labels
reference-values:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/#values
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/reference/#values
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#values
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/#values
annotations:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/#annotations
@@ -92,7 +92,7 @@ Refer to [Templates Introduction](ref:intro-to-templates) for a more detailed ex
Both types of templates are written in the Go templating system. However, it's important to understand that variables and functions used in notification templates are different from those used in annotation and label templates.
1. **Template annotations and labels**: These templates add extra information to individual alert instances. Template variables like [`$labels`](ref:labels) and [`$values`](ref:values) represent alert query data of the individual alert instance.
1. **Template annotations and labels**: These templates add extra information to individual alert instances. Template variables like [`$labels`](ref:reference-labels) and [`$values`](ref:reference-values) represent alert query data of the individual alert instance.
1. **Template notifications**: Notification templates format the notification content for a group of alerts. Variables like [`.Alerts`](ref:notification-data-reference) include all firing and resolved alerts in the notification.
## Template annotations
@@ -111,11 +111,11 @@ CPU usage has exceeded 80% for the last 5 minutes.
However, if you want to display dynamic query values in annotations, you need to use template code. Common use cases include:
- Displaying the query value or threshold that triggered the alert.
- Highlighting label information that identifies the alert, such as environment, region, or priority.
- Displaying the query value that triggered the alert.
- Highlighting label information that identifies the alert, such as the environment, instance, or region.
- Providing specific instructions based on query values.
- Customizing runbook links depending on query or label values.
- Including contact information based on alert labels.
- Customizing runbook links depending on query labels.
- Including contact information based on query labels.
For instance, you can template the previous example to display the specific instance and CPU value that triggered the alert.
@@ -115,7 +115,9 @@ CPU usage has exceeded 80% (81.2345) for the last 5 minutes.
### Include labels for extra details
To provide additional context, you can include labels from the query. For instance, access the [`$labels`](ref:reference-labels) variable to display a label that informs about the affected instance:
To provide additional context, you can include labels from the query using the [`$labels`](ref:reference-labels) variable.
For instance, the previous case could also include the affected `instance`.
```
CPU usage for {{ $labels.instance }} has exceeded 80% ({{ $values.A.Value }}) for the last 5 minutes.
@@ -125,14 +127,14 @@ CPU usage for {{ $labels.instance }} has exceeded 80% ({{ $values.A.Value }}) fo
CPU usage for Instance 1 has exceeded 80% (81.2345) for the last 5 minutes.
```
Annotations can also be used to provide a summary of key alert labels, such as the environment and alert severity. For instance, you can display a summary of the alert with important labels like:
You can incorporate any labels returned by the query into the template. For instance, the following template includes information about the environment and region where the alert occurred.
```
Alert triggered in {{ $labels.environment }} with severity {{ $labels.severity }}
Alert triggered in {{ $labels.environment }} on {{ $labels.region }} region.
```
```template_output
Alert triggered in production with severity critical.
Alert triggered in production on AMER region.
```
### Print a range query
@@ -16,6 +16,11 @@ title: Annotation and label template reference
menuTitle: Template reference
weight: 101
refs:
label-types:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/#label-types
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#label-types
notification-template-reference:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/reference/
@@ -72,15 +77,15 @@ The following variables are available when templating annotations and labels:
| Variables | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| [$labels](#labels) | Contains all labels from the query. |
| [$labels](#labels) | Contains all labels from the query, only query labels. |
| [$values](#values) | Contains the labels and floating point values of all instant queries and expressions, indexed by their Ref IDs. |
| [$value](#value) | A string containing the labels and values of all instant queries; threshold, reduce and math expressions, and classic conditions in the alert rule. |
### $labels
The `$labels` variable contains all labels from the query.
The `$labels` variable contains all labels from the query. It excludes [user-configured and reserved labels](ref:label-types), containing only query labels.
{{< figure src="/media/docs/alerting/query-labels-and_value.png" max-width="1200px" caption="An alert rule displaying labels and value from a query" >}}
{{< figure src="/media/docs/alerting/query-labels-and-values.png" max-width="1200px" caption="An alert rule displaying labels and value from a query." >}}
For example, suppose you have a query that returns CPU usage for all of your servers, and you have an alert rule that fires when any of your servers have exceeded 80% CPU usage for the last 5 minutes. You want to add a summary annotation to the alert that tells you which server is experiencing high CPU usage. With the `$labels` variable you can write a template that prints a human-readable sentence such as:
@@ -17,6 +17,11 @@ title: Notification template reference
menuTitle: Template reference
weight: 102
refs:
label-types:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/#label-types
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#label-types
alert-rule-template-reference:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/reference/
@@ -88,7 +93,7 @@ Here's an example that prints all available notification data from dot (`.`):
| Name | Type | Description |
| -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `Status` | string | Firing or resolved. |
| `Labels` | [KV](#kv) | The labels for this alert. |
| `Labels` | [KV](#kv) | The labels for this alert. It includes all [types of labels](ref:label-types). |
| `Annotations` | [KV](#kv) | The annotations for this alert. |
| `StartsAt` | [Time](#time) | The time the alert fired |
| `EndsAt` | [Time](#time) | Only set if the end time of an alert is known. Otherwise set to a configurable timeout period from the time since the last alert was received. |
@@ -87,11 +87,13 @@ Labels that you manually configure in the alert rule to identify the generated a
Additionally, you can use a [template](ref:templates) to customize the label value and generate dynamic values from query data.
**Data source query labels**
**Query labels**
For example, if you are monitoring temperature readings and each time series for these readings has a `sensor_id`, and a `location` label, an alert instance might have the labels `{sensor_id="1",location="NY"}`, while another alert instance might have `{sensor_id="2",location="WA"}`.
Query labels are labels returned by the data source query.
Data source query labels labels are also used to generate multiple alert instances from the same alert rule, helping to distinguish alerts from different data.
{{< figure src="/media/docs/alerting/query-labels-and-values.png" max-width="1200px" caption="An alert rule query returning labels from the query." >}}
Query labels can generate multiple alert instances from the same alert rule, helping to distinguish alerts from different data. In this example, the `instance` label generates an alert instance for each server.
**Reserved labels**
@@ -75,7 +75,7 @@ You can use the **Search** query builder's user interface to select options to s
These selections generate a TraceQL query.
Any query generated using **Search** query builder can be transferred to the **TraceQL** query editor, where you can edit the query directly.
To learn more about how to query by TraceQL, refer to the [TraceQL documentation](/docs/tempo/latest/traceql).
To learn more about how to query by TraceQL, refer to the [TraceQL documentation](https://grafana.com/docs/tempo/<TEMPO_VERSION>/traceql).
## Choose a query editing mode
@@ -123,15 +123,15 @@ Using the service graph view, you can:
- Determine how long the slowest queries in your service take to complete.
- Examine all traces that contain spans of particular interest based on rate, error, and duration values (RED signals).
For more information about the service graph, refer to [Service graph](../service-graph/).
For more information about the service graph, refer to [Service graph](../service-graph).
![Screenshot of the Service Graph view](/media/docs/grafana/data-sources/tempo/query-editor/tempo-ds-query-service-graph.png)
## Use TraceQL panels in dashboards
To add TraceQL panels to your dashboard, refer to the [Traces panel documentation](/docs/grafana/latest/panels-visualizations/visualizations/traces/).
To add TraceQL panels to your dashboard, refer to the [Traces panel documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/traces/).
To learn more about Grafana dashboards, refer to the [Use dashboards documentation](/docs/grafana/latest/dashboards/use-dashboards/).
To learn more about Grafana dashboards, refer to the [Use dashboards documentation](/docs/grafana/<GRAFANA_VERSION>/dashboards/use-dashboards/).
## Set options for query builder and editor
+5 -5
View File
@@ -74,8 +74,8 @@ require (
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a // @grafana/identity-access-team
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d // @grafana/identity-access-team
github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c // @grafana/identity-access-team
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad
github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
@@ -89,7 +89,7 @@ require (
github.com/grafana/grafana-cloud-migration-snapshot v1.3.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group
github.com/grafana/grafana-plugin-sdk-go v0.259.4 // @grafana/plugins-platform-backend
github.com/grafana/grafana-plugin-sdk-go v0.260.1 // @grafana/plugins-platform-backend
github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240821155123-6891eb1d35da // @grafana/grafana-app-platform-squad
github.com/grafana/grafana/pkg/apiserver v0.0.0-20240821155123-6891eb1d35da // @grafana/grafana-app-platform-squad
@@ -148,7 +148,7 @@ require (
github.com/spf13/cobra v1.8.1 // @grafana/grafana-app-platform-squad
github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad
github.com/spyzhov/ajson v0.9.0 // @grafana/grafana-app-platform-squad
github.com/stretchr/testify v1.9.0 // @grafana/grafana-backend-group
github.com/stretchr/testify v1.10.0 // @grafana/grafana-backend-group
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf // @grafana/grafana-backend-group
github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f // @grafana/grafana-backend-group
github.com/urfave/cli v1.22.16 // indirect; @grafana/grafana-backend-group
@@ -175,7 +175,7 @@ require (
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // @grafana/alerting-backend
golang.org/x/mod v0.22.0 // indirect; @grafana/grafana-backend-group
golang.org/x/net v0.31.0 // @grafana/oss-big-tent @grafana/partner-datasources
golang.org/x/oauth2 v0.23.0 // @grafana/identity-access-team
golang.org/x/oauth2 v0.24.0 // @grafana/identity-access-team
golang.org/x/sync v0.9.0 // @grafana/alerting-backend
golang.org/x/text v0.20.0 // @grafana/grafana-backend-group
golang.org/x/time v0.6.0 // @grafana/grafana-backend-group
+10 -9
View File
@@ -2292,10 +2292,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7 h1:lsM/QscEX+ZDIJm48ynQscH+msETyGYV6ug8L4f2DtM=
github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6SEIQp7S6l1oxt964ywSmcknszHwn0=
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a/go.mod h1:XFhcSCEDeOgV43x41we7mRBcizDpsTScb7XOuYipQZg=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c h1:10aFnI5iFk0XSw19FD26oS4nIwCunfbkYT800hpHBzo=
github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ=
@@ -2331,8 +2331,8 @@ github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkr
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.259.4 h1:Qt/Ic+7K7n7z4ncZs29q2OhOOPKWjE5+ms2ckeYlTo4=
github.com/grafana/grafana-plugin-sdk-go v0.259.4/go.mod h1:BS2B+lEHC4KtOViiS4C5x9SsR1ihPHAzesGOpaPYeCs=
github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE=
github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw=
github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f h1:zZN/Jy7PjoqtrMiBRV5O3x4xAArcSbUznuyAPACrKXI=
github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f/go.mod h1:e97Zxn1WX4Wn9TXEvwTjMNwU6yrjX/K7uVNSCZyEwxY=
github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2 h1:2H9x4q53pkfUGtSNYD1qSBpNnxrFgylof/TYADb5xMI=
@@ -3230,8 +3230,9 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -3746,8 +3747,8 @@ golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+7
View File
@@ -987,6 +987,7 @@ github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18 h1:3cQ+d+fkNL2Eqp
github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 h1:oDbLKM34O+JUF9EQFS+9aYhdYoeNfUpXqNjFCLIxwF4=
github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
github.com/grafana/cog v0.0.4/go.mod h1:lzetOuhGUl/JaSACiJoHvBokf9/fS6PEFaWZvnQu2vs=
@@ -1001,6 +1002,8 @@ github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2 h1:8xGrYqQ1GM4aaMk
github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU=
github.com/grafana/grafana-app-sdk v0.19.0/go.mod h1:y0BgzYxc+a7CwOqkwUhN9zXd5cgZJjd2zAbgHEd/xzo=
github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE=
github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw=
github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE=
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
@@ -1444,6 +1447,8 @@ github.com/stoewer/parquet-cli v0.0.7 h1:rhdZODIbyMS3twr4OM3am8BPPT5pbfMcHLH93wh
github.com/stoewer/parquet-cli v0.0.7/go.mod h1:bskxHdj8q3H1EmfuCqjViFoeO3NEvs5lzZAQvI8Nfjk=
github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo=
github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/tdewolff/minify/v2 v2.12.9 h1:dvn5MtmuQ/DFMwqf5j8QhEVpPX6fi3WGImhv8RUB4zA=
@@ -1792,6 +1797,8 @@ golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+1
View File
@@ -32,6 +32,7 @@ require (
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yalue/merged_fs v1.3.0 // indirect
golang.org/x/mod v0.22.0 // indirect
+2 -2
View File
@@ -67,8 +67,8 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
+3 -3
View File
@@ -53,9 +53,9 @@
"watch": "yarn start -d watch,start core:start --watchTheme",
"ci:test-frontend": "yarn run test:ci",
"i18n:stats": "node ./scripts/cli/reportI18nStats.mjs",
"betterer": "betterer",
"betterer": "betterer --tsconfig ./scripts/cli/tsconfig.json",
"betterer:json": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/bettererResultsToJson.ts",
"betterer:merge": "betterer merge",
"betterer:merge": "betterer merge --tsconfig ./scripts/cli/tsconfig.json",
"betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts",
"betterer:issues": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateBettererIssues.ts",
"generate-icons-bundle-cache-file": "node ./scripts/generate-icon-bundle.js",
@@ -87,7 +87,7 @@
"@playwright/test": "1.48.2",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
"@react-types/button": "3.10.1",
"@react-types/menu": "3.9.12",
"@react-types/menu": "3.9.13",
"@react-types/overlays": "3.8.10",
"@react-types/shared": "3.25.0",
"@rtk-query/codegen-openapi": "^1.2.0",
@@ -620,7 +620,7 @@ export class PrometheusDatasource
// it is used in metric_find_query.ts
// and in Tempo here grafana/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx
async getTagKeys(options: DataSourceGetTagKeysOptions<PromQuery>): Promise<MetricFindValue[]> {
if (config.featureToggles.promQLScope && !!options) {
if (config.featureToggles.promQLScope && (options?.scopes?.length ?? 0) > 0) {
const suggestions = await this.languageProvider.fetchSuggestions(
options.timeRange,
options.queries,
+1 -1
View File
@@ -89,7 +89,7 @@
"rc-slider": "11.1.7",
"rc-time-picker": "^3.7.3",
"rc-tooltip": "6.2.1",
"react-calendar": "5.1.0",
"react-calendar": "^5.1.0",
"react-colorful": "5.6.1",
"react-custom-scrollbars-2": "4.5.0",
"react-dropzone": "14.3.5",
+3 -3
View File
@@ -4,11 +4,11 @@ go 1.23.1
require (
github.com/emicklei/go-restful/v3 v3.11.0
github.com/grafana/grafana-plugin-sdk-go v0.259.4
github.com/grafana/grafana-plugin-sdk-go v0.260.1
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435
github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/otel v1.32.0
k8s.io/api v0.31.3
k8s.io/apimachinery v0.31.3
@@ -138,7 +138,7 @@ require (
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.26.0 // indirect
+6 -5
View File
@@ -130,8 +130,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/grafana-plugin-sdk-go v0.259.4 h1:Qt/Ic+7K7n7z4ncZs29q2OhOOPKWjE5+ms2ckeYlTo4=
github.com/grafana/grafana-plugin-sdk-go v0.259.4/go.mod h1:BS2B+lEHC4KtOViiS4C5x9SsR1ihPHAzesGOpaPYeCs=
github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE=
github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435 h1:lmw60EW7JWlAEvgggktOyVkH4hF1m/+LSF/Ap0NCyi8=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435/go.mod h1:ORVFiW/KNRY52lNjkGwnFWCxNVfE97bJG2jr2fetq0I=
github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435 h1:SNEeqY22DrGr5E9kGF1mKSqlOom14W9+b1u4XEGJowA=
@@ -298,8 +298,9 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -406,8 +407,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+3 -9
View File
@@ -49,7 +49,6 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
@@ -64,6 +63,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
folderRoute.Post("/", handler.createFolder)
folderRoute.Get("/", handler.getFolders)
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Put("/", handler.updateFolder)
folderUidRoute.Delete("/", handler.deleteFolder)
folderUidRoute.Get("/", handler.getFolder)
})
@@ -71,17 +71,11 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
})
}
// Only adding support for some routes with the k8s handler for now. Include the rest here.
if false {
handler := newFolderK8sHandler(hs)
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Put("/:uid", handler.updateFolder)
})
}
})
}
@@ -969,7 +963,7 @@ func (fk8s *folderK8sHandler) toDTO(c *contextmodel.ReqContext, fold *folder.Fol
UpdatedBy: updater,
Updated: fold.Updated,
// #TODO version doesn't seem to be used--confirm or set it properly
Version: 1,
Version: fold.Version,
AccessControl: acMetadata,
ParentUID: fold.ParentUID,
}, nil
+237
View File
@@ -5,17 +5,24 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
clientrest "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/api/dtos"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
conversions "github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -24,6 +31,7 @@ import (
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
)
@@ -510,3 +518,232 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
})
}
}
type mockClientConfigProvider struct {
host string
}
func (m mockClientConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config {
return &clientrest.Config{
Host: m.host,
}
}
func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {}
func TestUpdateFolderLegacyAndUnifiedStorage(t *testing.T) {
testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"}
legacyFolder := folder.Folder{
UID: "ady4yobv315a8e",
Title: "Example folder 226",
URL: "/dashboards/f/ady4yobv315a8e/example-folder-226",
CreatedBy: 99,
CreatedByUID: "fdxsqt7t5ryf4a",
Created: time.Date(2024, time.November, 29, 0, 42, 34, 0, time.UTC),
UpdatedBy: 99,
UpdatedByUID: "fdxsqt7t5ryf4a",
Updated: time.Date(2024, time.November, 29, 0, 42, 34, 0, time.UTC),
Version: 3,
}
namespacer := func(_ int64) string { return "1" }
unifiedStorageFolder, err := conversions.LegacyFolderToUnstructured(&legacyFolder, namespacer)
require.NoError(t, err)
expectedFolder := dtos.Folder{
UID: legacyFolder.UID,
OrgID: 0,
Title: legacyFolder.Title,
URL: legacyFolder.URL,
HasACL: false,
CanSave: false,
CanEdit: true,
CanAdmin: false,
CanDelete: false,
CreatedBy: "testuser",
Created: legacyFolder.Created,
UpdatedBy: "testuser",
Updated: legacyFolder.Updated,
Version: legacyFolder.Version,
}
mux := http.NewServeMux()
mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) {
err := json.NewEncoder(w).Encode(unifiedStorageFolder)
require.NoError(t, err)
})
folderApiServerMock := httptest.NewServer(mux)
defer folderApiServerMock.Close()
t.Run("happy path", func(t *testing.T) {
type testCase struct {
description string
folderUID string
legacyFolder folder.Folder
expectedFolder dtos.Folder
expectedFolderServiceError error
unifiedStorageEnabled bool
unifiedStorageMode grafanarest.DualWriterMode
expectedCode int
}
tcs := []testCase{
{
description: "Happy Path - Legacy",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: false,
},
{
description: "Happy Path - Unified storage, mode 1",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode1,
},
{
description: "Happy Path - Unified storage, mode 2",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode2,
},
{
description: "Happy Path - Unified storage, mode 3",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode3,
},
{
description: "Happy Path - Unified storage, mode 4",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode4,
},
{
description: "Folder Not Found - Legacy",
expectedCode: http.StatusNotFound,
legacyFolder: legacyFolder,
folderUID: "notfound",
expectedFolder: expectedFolder,
unifiedStorageEnabled: false,
expectedFolderServiceError: dashboards.ErrFolderNotFound,
},
{
description: "Folder Not Found - Unified storage, mode 1",
expectedCode: http.StatusNotFound,
legacyFolder: legacyFolder,
folderUID: "notfound",
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode1,
},
{
description: "Folder Not Found - Unified storage, mode 2",
expectedCode: http.StatusNotFound,
legacyFolder: legacyFolder,
folderUID: "notfound",
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode2,
},
{
description: "Folder Not Found - Unified storage, mode 3",
expectedCode: http.StatusNotFound,
legacyFolder: legacyFolder,
folderUID: "notfound",
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode3,
},
{
description: "Folder Not Found - Unified storage, mode 4",
expectedCode: http.StatusNotFound,
legacyFolder: legacyFolder,
folderUID: "notfound",
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode4,
},
}
for _, tc := range tcs {
t.Run(tc.description, func(t *testing.T) {
setUpRBACGuardian(t)
cfg := setting.NewCfg()
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
folderv0alpha1.RESOURCEGROUP: {
DualWriterMode: tc.unifiedStorageMode,
},
}
featuresArr := []any{featuremgmt.FlagNestedFolders}
if tc.unifiedStorageEnabled {
featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFolders)
}
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.folderService = &foldertest.FakeService{
ExpectedFolder: &tc.legacyFolder,
ExpectedError: tc.expectedFolderServiceError,
}
hs.QuotaService = quotatest.New(false, nil)
hs.SearchService = &mockSearchService{
ExpectedResult: model.HitList{},
}
hs.userService = &usertest.FakeUserService{
ExpectedUser: testuser,
}
hs.Features = featuremgmt.WithFeatures(
featuresArr...,
)
hs.clientConfigProvider = mockClientConfigProvider{
host: folderApiServerMock.URL,
}
})
req := server.NewRequest(http.MethodPut, fmt.Sprintf("/api/folders/%s", tc.folderUID), strings.NewReader(`{"title":"new title"}`))
req.Header.Set("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll},
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")},
}),
}})
res, err := server.Send(req)
require.NoError(t, err)
require.Equal(t, tc.expectedCode, res.StatusCode)
defer func() { require.NoError(t, res.Body.Close()) }()
if tc.expectedCode == http.StatusOK {
body := dtos.Folder{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
//nolint:staticcheck
body.ID = 0
body.Version = 0
tc.expectedFolder.Version = 0
require.Equal(t, tc.expectedFolder, body)
}
})
}
})
}
+6 -2
View File
@@ -46,12 +46,16 @@ func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) {
shortURL, err := hs.ShortURLService.GetShortURLByUID(c.Req.Context(), c.SignedInUser, shortURLUID)
if err != nil {
// If we didn't get the URL for whatever reason, we redirect to the
// main page, otherwise we get into an endless loops of redirects, as
// we would try to redirect again.
if shorturls.ErrShortURLNotFound.Is(err) {
hs.log.Debug("Not redirecting short URL since not found")
hs.log.Debug("Not redirecting short URL since not found", "uid", shortURLUID)
c.Redirect(hs.Cfg.AppURL, 308)
return
}
hs.log.Error("Short URL redirection error", "err", err)
c.Redirect(hs.Cfg.AppURL, 307)
return
}
+1 -1
View File
@@ -281,7 +281,7 @@ func (hs *HTTPServer) StartEmailVerificaton(c *contextmodel.ReqContext) response
return response.Error(http.StatusBadRequest, "Only users can verify their email", nil)
}
if c.SignedInUser.IsEmailVerified() {
if c.SignedInUser.GetEmailVerified() {
// email is already verified so we don't need to trigger the flow.
return response.Respond(http.StatusNotModified, nil)
}
+3 -3
View File
@@ -3,9 +3,9 @@ module github.com/grafana/grafana/pkg/apimachinery
go 1.23.1
require (
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a // @grafana/identity-access-team
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d // @grafana/identity-access-team
github.com/stretchr/testify v1.9.0
github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c // @grafana/identity-access-team
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team
github.com/stretchr/testify v1.10.0
k8s.io/apimachinery v0.31.3
k8s.io/apiserver v0.31.3
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
+6 -6
View File
@@ -28,10 +28,10 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6SEIQp7S6l1oxt964ywSmcknszHwn0=
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a/go.mod h1:XFhcSCEDeOgV43x41we7mRBcizDpsTScb7XOuYipQZg=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c h1:10aFnI5iFk0XSw19FD26oS4nIwCunfbkYT800hpHBzo=
github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -61,8 +61,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+20 -9
View File
@@ -14,48 +14,59 @@ type Requester interface {
// GetIdentityType returns the type for the requester
GetIdentityType() claims.IdentityType
// IsIdentityType returns true if identity type for requester matches any expected identity type
IsIdentityType(expected ...claims.IdentityType) bool
// FIXME: remove and use GetIdentifier instead.
// GetRawIdentifier returns only the identifier part of the UID, excluding the type
GetRawIdentifier() string
// GetInternalID returns only the identifier part of the ID, excluding the type
GetInternalID() (int64, error)
// GetID returns namespaced internalID for the entity
// Deprecated: use GetUID instead
GetID() string
// GetEmail returns the email of the active entity.
// Can be empty.
GetEmail() string
// IsEmailVerified returns if email is verified for entity.
IsEmailVerified() bool
// GetInternalID returns only the identifier part of the ID, excluding the type
GetInternalID() (int64, error)
// GetIsGrafanaAdmin returns true if the user is a server admin
GetIsGrafanaAdmin() bool
// FIXME: remove and use GetUsername instead
// GetLogin returns the login of the active entity
// Can be empty.
GetLogin() string
// GetOrgID returns the ID of the active organization
GetOrgID() int64
// GetOrgRole returns the role of the active entity in the active organization.
GetOrgRole() RoleType
// GetPermissions returns the permissions of the active entity.
GetPermissions() map[string][]string
// GetGlobalPermissions returns the permissions of the active entity that are available across all organizations.
GetGlobalPermissions() map[string][]string
// DEPRECATED: GetTeams returns the teams the entity is a member of.
// Retrieve the teams from the team service instead of using this method.
GetTeams() []int64
// DEPRECATED: GetOrgName returns the name of the active organization.
// Retrieve the organization name from the organization service instead of using this method.
GetOrgName() string
// GetAuthID returns external id for entity.
GetAuthID() string
// GetNamespace returns either "*" or the single namespace this requester has access to
// An empty value means the implementation has not specified a kubernetes namespace.
GetNamespace() string
// GetAuthenticatedBy returns the authentication method used to authenticate the entity.
GetAuthenticatedBy() string
// IsAuthenticatedBy returns true if entity was authenticated by any of supplied providers.
IsAuthenticatedBy(providers ...string) bool
// IsNil returns true if the identity is nil
// FIXME: remove this method once all services are using an interface
IsNil() bool
+43 -37
View File
@@ -2,12 +2,13 @@ package identity
import (
"fmt"
"strconv"
authnlib "github.com/grafana/authlib/authn"
"github.com/grafana/authlib/claims"
)
var _ Requester = &StaticRequester{}
var _ Requester = (*StaticRequester)(nil)
// StaticRequester allows creating requester values explicitly.
// It is helpful in tests!
@@ -35,45 +36,63 @@ type StaticRequester struct {
CacheKey string
}
// Access implements Requester.
func (u *StaticRequester) GetAccess() claims.AccessClaims {
return &IDClaimsWrapper{Source: u}
// GetID returns typed id for the entity
func (u *StaticRequester) GetID() string {
return claims.NewTypeID(u.Type, strconv.FormatInt(u.UserID, 10))
}
// Identity implements Requester.
func (u *StaticRequester) GetIdentity() claims.IdentityClaims {
if u.IDTokenClaims != nil {
return authnlib.NewIdentityClaims(*u.IDTokenClaims)
}
return &IDClaimsWrapper{Source: u}
}
// GetRawIdentifier implements Requester.
func (u *StaticRequester) GetUID() string {
return fmt.Sprintf("%s:%s", u.Type, u.UserUID)
return claims.NewTypeID(u.Type, u.UserUID)
}
// GetRawIdentifier implements Requester.
func (u *StaticRequester) GetRawIdentifier() string {
func (u *StaticRequester) GetIdentifier() string {
return u.UserUID
}
// GetInternalID implements Requester.
func (u *StaticRequester) GetInternalID() (int64, error) {
return u.UserID, nil
}
// GetIdentityType implements Requester.
func (u *StaticRequester) GetIdentityType() claims.IdentityType {
return u.Type
}
// IsIdentityType implements Requester.
func (u *StaticRequester) GetSubject() string {
return claims.NewTypeID(u.Type, strconv.FormatInt(u.UserID, 10))
}
func (u *StaticRequester) GetAudience() []string {
return []string{fmt.Sprintf("org:%d", u.OrgID)}
}
func (u *StaticRequester) GetTokenPermissions() []string {
return []string{}
}
func (u *StaticRequester) GetTokenDelegatedPermissions() []string {
return []string{}
}
func (u *StaticRequester) GetEmail() string {
return u.Email
}
func (u *StaticRequester) GetEmailVerified() bool {
return u.EmailVerified
}
func (u *StaticRequester) GetUsername() string {
return u.Login
}
func (u *StaticRequester) GetRawIdentifier() string {
return u.UserUID
}
func (u *StaticRequester) GetInternalID() (int64, error) {
return u.UserID, nil
}
func (u *StaticRequester) IsIdentityType(expected ...claims.IdentityType) bool {
return claims.IsIdentityType(u.GetIdentityType(), expected...)
}
// GetExtra implements Requester.
func (u *StaticRequester) GetExtra() map[string][]string {
if u.IDToken != "" {
return map[string][]string{"id-token": {u.IDToken}}
@@ -81,12 +100,10 @@ func (u *StaticRequester) GetExtra() map[string][]string {
return map[string][]string{}
}
// GetGroups implements Requester.
func (u *StaticRequester) GetGroups() []string {
return []string{}
}
// GetName implements Requester.
func (u *StaticRequester) GetName() string {
if u.Name != "" {
return u.Name
@@ -171,11 +188,6 @@ func (u *StaticRequester) HasUniqueId() bool {
return u.UserID > 0
}
// GetID returns typed id for the entity
func (u *StaticRequester) GetID() string {
return claims.NewTypeID(u.Type, fmt.Sprintf("%d", u.UserID))
}
func (u *StaticRequester) GetAuthID() string {
return u.AuthID
}
@@ -202,12 +214,6 @@ func (u *StaticRequester) IsNil() bool {
return u == nil
}
// GetEmail returns the email of the active entity
// Can be empty.
func (u *StaticRequester) GetEmail() string {
return u.Email
}
func (u *StaticRequester) IsEmailVerified() bool {
return u.EmailVerified
}
-109
View File
@@ -1,109 +0,0 @@
package identity
import (
"fmt"
"time"
"github.com/grafana/authlib/claims"
)
var _ claims.IdentityClaims = &IDClaimsWrapper{}
var _ claims.AccessClaims = &IDClaimsWrapper{}
type IDClaimsWrapper struct {
Source Requester
}
func (i *IDClaimsWrapper) IsNil() bool {
return i.Source.IsNil()
}
// GetAuthenticatedBy implements claims.IdentityClaims.
func (i *IDClaimsWrapper) AuthenticatedBy() string {
return i.Source.GetAuthenticatedBy()
}
// GetDisplayName implements claims.IdentityClaims.
func (i *IDClaimsWrapper) DisplayName() string {
return i.Source.GetName()
}
// GetEmail implements claims.IdentityClaims.
func (i *IDClaimsWrapper) Email() string {
return i.Source.GetEmail()
}
// GetEmailVerified implements claims.IdentityClaims.
func (i *IDClaimsWrapper) EmailVerified() bool {
return i.Source.IsEmailVerified()
}
// GetIdentityType implements claims.IdentityClaims.
func (i *IDClaimsWrapper) IdentityType() claims.IdentityType {
return i.Source.GetIdentityType()
}
// GetRawUID implements claims.IdentityClaims.
func (i *IDClaimsWrapper) Identifier() string {
return i.Source.GetRawIdentifier()
}
// GetUsername implements claims.IdentityClaims.
func (i *IDClaimsWrapper) Username() string {
return i.Source.GetLogin()
}
// GetAudience implements claims.AccessClaims.
func (i *IDClaimsWrapper) Audience() []string {
return []string{fmt.Sprintf("org:%d", i.Source.GetOrgID())}
}
// GetDelegatedPermissions implements claims.AccessClaims.
func (i *IDClaimsWrapper) DelegatedPermissions() []string {
return []string{}
}
// GetExpiry implements claims.AccessClaims.
func (i *IDClaimsWrapper) Expiry() *time.Time {
return nil
}
// GetIssuedAt implements claims.AccessClaims.
func (i *IDClaimsWrapper) IssuedAt() *time.Time {
return nil
}
// GetIssuer implements claims.AccessClaims.
func (i *IDClaimsWrapper) Issuer() string {
return ""
}
// GetJTI implements claims.AccessClaims.
func (i *IDClaimsWrapper) JTI() string {
return ""
}
// GetNamespace implements claims.AccessClaims.
func (i *IDClaimsWrapper) Namespace() string {
return i.Source.GetNamespace()
}
// GetNotBefore implements claims.AccessClaims.
func (i *IDClaimsWrapper) NotBefore() *time.Time {
return nil
}
// GetPermissions implements claims.AccessClaims.
func (i *IDClaimsWrapper) Permissions() []string {
return []string{}
}
// GetScopes implements claims.AccessClaims.
func (i *IDClaimsWrapper) Scopes() []string {
return []string{}
}
// GetSubject implements claims.AccessClaims.
func (i *IDClaimsWrapper) Subject() string {
return i.Source.GetID()
}
+3 -3
View File
@@ -4,10 +4,10 @@ go 1.23.1
require (
github.com/google/go-cmp v0.6.0
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240701135906-559738ce6ae1
github.com/prometheus/client_golang v1.20.5
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/propagators/jaeger v1.32.0
go.opentelemetry.io/otel v1.32.0
go.opentelemetry.io/otel/trace v1.32.0
@@ -79,7 +79,7 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.26.0 // indirect
golang.org/x/text v0.20.0 // indirect
+6 -6
View File
@@ -78,8 +78,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240701135906-559738ce6ae1 h1:ItDcDxUjVLPKja+hogpqgW/kj8LxUL2qscelXIsN1Bs=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240701135906-559738ce6ae1/go.mod h1:DkxMin+qOh1Fgkxfbt+CUfBqqsCQJMG9op8Os/irBPA=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
@@ -162,8 +162,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -244,8 +244,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+2 -2
View File
@@ -28,7 +28,7 @@ require (
github.com/google/uuid v1.6.0 // indirect; @grafana/grafana-backend-group
github.com/googleapis/gax-go/v2 v2.13.0 // indirect; @grafana/grafana-backend-group
github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group
github.com/stretchr/testify v1.9.0 // @grafana/grafana-backend-group
github.com/stretchr/testify v1.10.0 // @grafana/grafana-backend-group
github.com/urfave/cli v1.22.16 // @grafana/grafana-backend-group
github.com/urfave/cli/v2 v2.27.1 // @grafana/grafana-backend-group
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 // indirect; @grafana/plugins-platform-backend
@@ -38,7 +38,7 @@ require (
golang.org/x/crypto v0.29.0 // indirect; @grafana/grafana-backend-group
golang.org/x/mod v0.22.0 // @grafana/grafana-backend-group
golang.org/x/net v0.31.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources
golang.org/x/oauth2 v0.23.0 // @grafana/identity-access-team
golang.org/x/oauth2 v0.24.0 // @grafana/identity-access-team
golang.org/x/sync v0.9.0 // indirect; @grafana/alerting-backend
golang.org/x/text v0.20.0 // indirect; @grafana/grafana-backend-group
golang.org/x/time v0.6.0 // indirect; @grafana/grafana-backend-group
+4 -3
View File
@@ -212,8 +212,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
@@ -286,8 +287,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+2 -2
View File
@@ -4,13 +4,13 @@ go 1.23.1
require (
github.com/grafana/dskit v0.0.0-20240905221822-931a021fb06b
github.com/grafana/grafana-plugin-sdk-go v0.259.4
github.com/grafana/grafana-plugin-sdk-go v0.260.1
github.com/json-iterator/go v1.1.12
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/common v0.60.1
github.com/prometheus/prometheus v1.8.2-0.20221021121301-51a44e6657c3
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/otel v1.32.0
go.opentelemetry.io/otel/trace v1.32.0
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e
+6 -5
View File
@@ -97,8 +97,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grafana/dskit v0.0.0-20240905221822-931a021fb06b h1:x2HCzk29I0o5pRPfqWP/qwhXaPGlcz8pohq5kO1NZoE=
github.com/grafana/dskit v0.0.0-20240905221822-931a021fb06b/go.mod h1:SPLNCARd4xdjCkue0O6hvuoveuS1dGJjDnfxYe405YQ=
github.com/grafana/grafana-plugin-sdk-go v0.259.4 h1:Qt/Ic+7K7n7z4ncZs29q2OhOOPKWjE5+ms2ckeYlTo4=
github.com/grafana/grafana-plugin-sdk-go v0.259.4/go.mod h1:BS2B+lEHC4KtOViiS4C5x9SsR1ihPHAzesGOpaPYeCs=
github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE=
github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw=
github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
@@ -234,8 +234,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI=
@@ -306,8 +307,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -132,7 +132,7 @@ type rowsWrapper struct {
err error
}
func (a *dashboardSqlAccess) Namespaces(ctx context.Context) ([]string, error) {
func (a *dashboardSqlAccess) GetResourceStats(ctx context.Context, minCount int) ([]resource.ResourceStats, error) {
return nil, fmt.Errorf("not implemented")
}
@@ -382,10 +382,9 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
}
var userID int64
idClaims := user.GetIdentity()
if claims.IsIdentityType(idClaims.IdentityType(), claims.TypeUser) {
if claims.IsIdentityType(user.GetIdentityType(), claims.TypeUser) {
var err error
userID, err = identity.UserIdentifier(idClaims.Subject())
userID, err = identity.UserIdentifier(user.GetSubject())
if err != nil {
return nil, false, err
}
-5
View File
@@ -12,7 +12,6 @@ import (
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
@@ -53,10 +52,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return nil // no authorizer
}
func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode {
return grafanarest.Mode0
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
if err := dashboardinternal.AddToScheme(scheme); err != nil {
return err
@@ -16,7 +16,6 @@ import (
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -98,11 +97,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return dashboard.GetAuthorizer(b.dashboardService, b.log)
}
func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode {
// Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go
return grafanarest.Mode0
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return dashboardv0alpha1.AddToScheme(scheme)
}
@@ -13,7 +13,6 @@ import (
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -93,11 +92,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return dashboard.GetAuthorizer(b.dashboardService, b.log)
}
func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode {
// Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go
return grafanarest.Mode0
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return dashboardv1alpha1.AddToScheme(scheme)
}
@@ -13,7 +13,6 @@ import (
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -93,11 +92,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return dashboard.GetAuthorizer(b.dashboardService, b.log)
}
func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode {
// Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go
return grafanarest.Mode0
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return dashboardv2alpha1.AddToScheme(scheme)
}
+7 -6
View File
@@ -89,7 +89,7 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f
if created != nil {
// #TODO Fix this time format. The legacy time format seems to be along the lines of time.Now()
// which includes a part that represents a fraction of a second. Format should be "2024-09-12T15:37:41.09466+02:00"
createdTime = created.Local()
createdTime = (*created).UTC()
}
f := &folder.Folder{
@@ -122,6 +122,10 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f
// #TODO figure out about adding version, parents, orgID fields
}
func LegacyFolderToUnstructured(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) {
return convertToK8sResource(v, namespacer)
}
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) {
f := &v0alpha1.Folder{
TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(),
@@ -208,11 +212,8 @@ func getURL(meta utils.GrafanaMetaAccessor, title string) string {
}
func getCreated(meta utils.GrafanaMetaAccessor) (*time.Time, error) {
created, err := meta.GetRepositoryTimestamp()
if err != nil {
return nil, err
}
return created, nil
created := meta.GetCreationTimestamp().Time
return &created, nil
}
func GetParentTitles(fullPath string) ([]string, error) {
+10
View File
@@ -3,6 +3,7 @@ package folders
import (
"context"
"errors"
"fmt"
"slices"
"github.com/prometheus/client_golang/prometheus"
@@ -246,6 +247,15 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
obj := a.GetObject()
f, ok := obj.(*v0alpha1.Folder)
if !ok {
return fmt.Errorf("obj is not v0alpha1.Folder")
}
if f.Spec.Title == "" {
return dashboards.ErrFolderTitleEmpty
}
for i := 1; i <= folderValidationRules.maxDepth; i++ {
parent := getParent(obj)
if parent == "" {
+79 -19
View File
@@ -17,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
@@ -149,6 +148,42 @@ func TestFolderAPIBuilder_getAuthorizerFunc(t *testing.T) {
allow: false,
},
},
{
name: "user with write permissions should be able to update a folder",
input: input{
user: &user.SignedInUser{
UserID: 1,
OrgID: orgID,
Name: "123",
Permissions: map[int64]map[string][]string{
orgID: {dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}},
},
},
verb: string(utils.VerbUpdate),
},
expect: expect{
eval: "folders:write",
allow: true,
},
},
{
name: "user without write permissions should NOT be able to update a folder",
input: input{
user: &user.SignedInUser{
UserID: 1,
OrgID: orgID,
Name: "123",
Permissions: map[int64]map[string][]string{
orgID: {},
},
},
verb: string(utils.VerbUpdate),
},
expect: expect{
eval: "folders:write",
allow: false,
},
},
}
b := &FolderAPIBuilder{
@@ -177,9 +212,19 @@ func TestFolderAPIBuilder_getAuthorizerFunc(t *testing.T) {
func TestFolderAPIBuilder_Validate(t *testing.T) {
type input struct {
obj *unstructured.Unstructured
name string
obj *v0alpha1.Folder
annotations map[string]string
name string
}
circularObj := &v0alpha1.Folder{
Spec: v0alpha1.Spec{
Title: "foo",
},
}
circularObj.Name = "valid-name"
circularObj.Annotations = map[string]string{"grafana.app/folder": "valid-name"}
tests := []struct {
name string
input input
@@ -189,9 +234,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
{
name: "should return error when name is invalid",
input: input{
obj: &unstructured.Unstructured{
Object: map[string]interface{}{
"meta": map[string]interface{}{"name": folderValidationRules.invalidNames[0]},
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
Title: "foo",
},
},
name: folderValidationRules.invalidNames[0],
@@ -201,9 +246,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
{
name: "should return no error if every validation passes",
input: input{
obj: &unstructured.Unstructured{
Object: map[string]interface{}{
"meta": map[string]interface{}{"name": "valid-name"},
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
Title: "foo",
},
},
name: "valid-name",
@@ -212,23 +257,33 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
{
name: "should return error when creating a nested folder higher than max depth",
input: input{
obj: &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{"name": "valid-name", "annotations": map[string]any{"grafana.app/folder": "valid-name"}},
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
Title: "foo",
},
},
name: "valid-name",
annotations: map[string]string{"grafana.app/folder": "valid-name"},
name: "valid-name",
},
setupFn: func(m *mock.Mock) {
m.On("Get", mock.Anything, "valid-name", mock.Anything).Return(
&unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{"name": "valid-name", "annotations": map[string]any{"grafana.app/folder": "valid-name"}},
},
}, nil)
circularObj,
nil)
},
err: folder.ErrMaximumDepthReached,
},
{
name: "should return error when title is empty",
input: input{
obj: &v0alpha1.Folder{
Spec: v0alpha1.Spec{
Title: "",
},
},
name: "foo",
},
err: dashboards.ErrFolderTitleEmpty,
},
}
s := (grafanarest.Storage)(nil)
@@ -246,6 +301,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.input.obj.Name = tt.input.name
tt.input.obj.Annotations = tt.input.annotations
if tt.setupFn != nil {
tt.setupFn(m)
}
@@ -264,7 +322,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
&user.SignedInUser{},
), nil)
if tt.err != nil {
if tt.err == nil {
require.NoError(t, err)
} else {
require.ErrorIs(t, err, tt.err)
return
}
+1 -1
View File
@@ -35,7 +35,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui
return nil // skip registration unless explicitly added (or all experimental are added)
}
builder := NewPeakQAPIBuilder()
apiregistration.RegisterAPI(NewPeakQAPIBuilder())
apiregistration.RegisterAPI(builder)
return builder
}
+3 -2
View File
@@ -98,8 +98,9 @@ func RegisterAPIService(features featuremgmt.FeatureToggles,
tracer tracing.Tracer,
legacy service.LegacyDataSourceLookup,
) (*QueryAPIBuilder, error) {
if !(features.IsEnabledGlobally(featuremgmt.FlagQueryService) ||
features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs)) {
if !featuremgmt.AnyEnabled(features,
featuremgmt.FlagQueryService,
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil, nil // skip registration unless explicitly added (or all experimental are added)
}
+1 -1
View File
@@ -31,7 +31,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui
}
builder := NewServiceAPIBuilder()
apiregistration.RegisterAPI(NewServiceAPIBuilder())
apiregistration.RegisterAPI(builder)
return builder
}
+1
View File
@@ -7,4 +7,5 @@ require go.opentelemetry.io/otel v1.32.0
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
)
+2 -2
View File
@@ -4,8 +4,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -1,41 +0,0 @@
package options
import (
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/spf13/pflag"
genericapiserver "k8s.io/apiserver/pkg/server"
)
type LoggingOptions struct {
logger log.Logger
Level string
}
func NewLoggingOptions(logger log.Logger) *LoggingOptions {
return &LoggingOptions{
logger: logger,
}
}
func (o *LoggingOptions) AddFlags(fs *pflag.FlagSet) {
fs.StringVar(&o.Level, "grafana.log.level", "debug", "Log level, debug, info, warn, error.")
}
func (o *LoggingOptions) Validate() []error {
return nil
}
func (o *LoggingOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error {
err := log.SetupConsoleLogger(o.Level)
if err != nil {
return err
}
o.logger.Info("Starting grafana-apiserver", "version", setting.BuildVersion, "commit", setting.BuildCommit, "branch", setting.BuildBranch, "compiled", time.Unix(setting.BuildStamp, 0))
o.logger.Debug("Console logging initialized", "logLevel", o.Level)
return nil
}
@@ -1,42 +0,0 @@
package options
import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/pflag"
genericapiserver "k8s.io/apiserver/pkg/server"
)
type MetricsOptions struct {
logger log.Logger
Enabled bool
MetricsRegisterer prometheus.Registerer
}
func NewMetricsOptions(logger log.Logger) *MetricsOptions {
return &MetricsOptions{
logger: logger,
}
}
func (o *MetricsOptions) AddFlags(fs *pflag.FlagSet) {
fs.BoolVar(&o.Enabled, "grafana.metrics.enable", false, "Enable metrics and Prometheus /metrics endpoint.")
}
func (o *MetricsOptions) Validate() []error {
return nil
}
func (o *MetricsOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error {
c.EnableMetrics = o.Enabled
o.MetricsRegisterer = metrics.ProvideRegisterer()
metrics.SetBuildInformation(o.MetricsRegisterer, setting.BuildVersion, setting.BuildCommit, setting.BuildBranch, setting.BuildStamp)
if o.Enabled {
o.logger.Debug("Metrics enabled")
}
return nil
}
@@ -1,216 +0,0 @@
package options
import (
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/spf13/pflag"
"k8s.io/apimachinery/pkg/runtime"
genericapiserver "k8s.io/apiserver/pkg/server"
genericoptions "k8s.io/apiserver/pkg/server/options"
)
type Options struct {
LoggingOptions *LoggingOptions
ExtraOptions *options.ExtraOptions
RecommendedOptions *genericoptions.RecommendedOptions
TracingOptions *TracingOptions
MetricsOptions *MetricsOptions
ProfilingOptions *ProfilingOptions
ServerRunOptions *genericoptions.ServerRunOptions
StorageOptions *options.StorageOptions
}
func New(logger log.Logger, codec runtime.Codec) *Options {
return &Options{
LoggingOptions: NewLoggingOptions(logger),
ExtraOptions: options.NewExtraOptions(),
RecommendedOptions: options.NewRecommendedOptions(codec),
TracingOptions: NewTracingOptions(logger),
MetricsOptions: NewMetricsOptions(logger),
ProfilingOptions: NewProfilingOptions(logger),
ServerRunOptions: genericoptions.NewServerRunOptions(),
StorageOptions: options.NewStorageOptions(),
}
}
func (o *Options) AddFlags(fs *pflag.FlagSet) {
o.LoggingOptions.AddFlags(fs)
o.ExtraOptions.AddFlags(fs)
o.RecommendedOptions.AddFlags(fs)
o.TracingOptions.AddFlags(fs)
o.MetricsOptions.AddFlags(fs)
o.ProfilingOptions.AddFlags(fs)
o.ServerRunOptions.AddUniversalFlags(fs)
o.internalFlags(fs)
}
func (o *Options) Validate() []error {
if errs := o.LoggingOptions.Validate(); len(errs) != 0 {
return errs
}
if errs := o.ExtraOptions.Validate(); len(errs) != 0 {
return errs
}
if errs := o.TracingOptions.Validate(); len(errs) != 0 {
return errs
}
if errs := o.MetricsOptions.Validate(); len(errs) != 0 {
return errs
}
if errs := o.ProfilingOptions.Validate(); len(errs) != 0 {
return errs
}
if errs := o.ServerRunOptions.Validate(); len(errs) != 0 {
return errs
}
if errs := o.internalFlagsValidate(); len(errs) != 0 {
return errs
}
// NOTE: we don't call validate on the top level recommended options as it doesn't like skipping etcd-servers
// the function is left here for troubleshooting any other config issues
// errors = append(errors, o.RecommendedOptions.Validate()...)
if errs := o.RecommendedOptions.SecureServing.Validate(); len(errs) != 0 {
return errs
}
if o.ExtraOptions.DevMode {
// NOTE: Only consider authn for dev mode - resolves the failure due to missing extension apiserver auth-config
// in parent k8s
if errs := o.RecommendedOptions.Authentication.Validate(); len(errs) != 0 {
return errs
}
}
return nil
}
// A copy of ApplyTo in recommended.go, but for >= 0.28, server pkg in apiserver does a bit extra causing
// a panic when CoreAPI is set to nil
func (o *Options) ModifiedApplyTo(config *genericapiserver.RecommendedConfig) error {
if err := o.RecommendedOptions.Etcd.ApplyTo(&config.Config); err != nil {
return err
}
if err := o.RecommendedOptions.EgressSelector.ApplyTo(&config.Config); err != nil {
return err
}
if err := o.RecommendedOptions.Traces.ApplyTo(config.Config.EgressSelector, &config.Config); err != nil {
return err
}
if err := o.RecommendedOptions.SecureServing.ApplyTo(&config.Config.SecureServing, &config.Config.LoopbackClientConfig); err != nil {
return err
}
if err := o.RecommendedOptions.Authentication.ApplyTo(&config.Config.Authentication, config.SecureServing, config.OpenAPIConfig); err != nil {
return err
}
if err := o.RecommendedOptions.Authorization.ApplyTo(&config.Config.Authorization); err != nil {
return err
}
if err := o.RecommendedOptions.Audit.ApplyTo(&config.Config); err != nil {
return err
}
// TODO: determine whether we need flow control (API priority and fairness)
// We can't assume that a shared informers config was provided in standalone mode and will need a guard
// when enabling below
/* kubeClient, err := kubernetes.NewForConfig(config.ClientConfig)
if err != nil {
return err
}
if err := o.RecommendedOptions.Features.ApplyTo(&config.Config, kubeClient, config.SharedInformerFactory); err != nil {
return err
} */
if err := o.RecommendedOptions.CoreAPI.ApplyTo(config); err != nil {
return err
}
_, err := o.RecommendedOptions.ExtraAdmissionInitializers(config)
if err != nil {
return err
}
if err := o.ServerRunOptions.ApplyTo(&config.Config); err != nil {
return err
}
return nil
}
func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) error {
if o.LoggingOptions != nil {
if err := o.LoggingOptions.ApplyTo(serverConfig); err != nil {
return err
}
}
if o.ExtraOptions != nil {
if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil {
return err
}
}
if o.RecommendedOptions.CoreAPI == nil {
if err := o.ModifiedApplyTo(serverConfig); err != nil {
return err
}
} else {
if err := o.RecommendedOptions.ApplyTo(serverConfig); err != nil {
return err
}
}
if o.TracingOptions != nil {
if err := o.TracingOptions.ApplyTo(serverConfig); err != nil {
return err
}
}
if o.MetricsOptions != nil {
if err := o.MetricsOptions.ApplyTo(serverConfig); err != nil {
return err
}
}
if o.ProfilingOptions != nil {
if err := o.ProfilingOptions.ApplyTo(serverConfig); err != nil {
return err
}
}
if o.ServerRunOptions != nil {
if err := o.ServerRunOptions.ApplyTo(&serverConfig.Config); err != nil {
return err
}
}
return nil
}
func (o *Options) internalFlags(fs *pflag.FlagSet) {
// We also want to be able to set the MaxRequestSize by using flags. This is
// usually not exposed by k8s. The value is already set to the upstream default
// at this stage, so we can use it as the default.
// Reference: https://github.com/kubernetes/kubernetes/blob/v1.31.1/staging/src/k8s.io/apiserver/pkg/server/config.go#L453
fs.Int64Var(&o.ServerRunOptions.MaxRequestBodyBytes, "max-request-body-bytes",
o.ServerRunOptions.MaxRequestBodyBytes, ""+
"Specifies the maximum allowable size for a request payload sent to the API server in bytes."+
"The default is 3MB.")
}
func (o *Options) internalFlagsValidate() []error {
if o.ServerRunOptions.MaxRequestBodyBytes < 0 {
return []error{fmt.Errorf("--max-request-body-bytes can not be a negative value")}
}
return nil
}
@@ -1,46 +0,0 @@
package options
import (
"runtime"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/spf13/pflag"
genericapiserver "k8s.io/apiserver/pkg/server"
)
type ProfilingOptions struct {
logger log.Logger
blockProfilingRate int
mutexProfilingRate int
}
func NewProfilingOptions(logger log.Logger) *ProfilingOptions {
return &ProfilingOptions{
logger: logger,
}
}
func (o *ProfilingOptions) AddFlags(fs *pflag.FlagSet) {
fs.IntVar(&o.blockProfilingRate, "grafana.profiling.block-rate", 0, "Controls the fraction of goroutine blocking events that are reported in the blocking profile. The profiler aims to sample an average of one blocking event per rate nanoseconds spent blocked. To turn off profiling entirely, use 0.")
fs.IntVar(&o.mutexProfilingRate, "grafana.profiling.mutex-rate", 0, "Controls the fraction of mutex contention events that are reported in the mutex profile. On average 1/rate events are reported. To turn off mutex profiling entirely, use 0.")
}
func (o *ProfilingOptions) Validate() []error {
return nil
}
func (o *ProfilingOptions) ApplyTo(config *genericapiserver.RecommendedConfig) error {
if !config.EnableProfiling {
return nil
}
// We bring our own block/mutex profiling configuration
config.EnableContentionProfiling = false
runtime.SetBlockProfileRate(o.blockProfilingRate)
runtime.SetMutexProfileFraction(o.mutexProfilingRate)
o.logger.Info("Profiling enabled", "blockProfileRate", o.blockProfilingRate, "mutexProfileRate", o.mutexProfilingRate)
return nil
}
@@ -1,137 +0,0 @@
package options
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/setting"
"github.com/spf13/pflag"
"go.opentelemetry.io/otel/attribute"
genericfeatures "k8s.io/apiserver/pkg/features"
genericapiserver "k8s.io/apiserver/pkg/server"
utilfeature "k8s.io/apiserver/pkg/util/feature"
k8stracing "k8s.io/component-base/tracing"
)
type TracingOptions struct {
logger log.Logger
JaegerAddress string
JaegerPropagation string
OTLPAddress string
OTLPPropagation string
ServiceName string
Tags map[string]string
SamplerType string
SamplerParam float64
SamplingServiceURL string
TracingService *tracing.TracingService
}
func NewTracingOptions(logger log.Logger) *TracingOptions {
return &TracingOptions{
logger: logger,
Tags: map[string]string{},
}
}
func (o *TracingOptions) AddFlags(fs *pflag.FlagSet) {
fs.StringVar(&o.JaegerAddress, "grafana.tracing.jaeger.address", "", "Tracing Jaeger exporter destination, e.g. http://localhost:14268/api/traces. This enabled the Jaeger export and takes presedence over grafana.tracing.otlp.")
fs.StringVar(&o.JaegerPropagation, "grafana.tracing.jaeger.propagation", "jaeger", "Tracing Jaeger propagation specifies the text map propagation format, w3c or jaeger.")
fs.StringVar(&o.OTLPAddress, "grafana.tracing.otlp.address", "", "Tracing OTLP exporter destination, e.g. localhost:4317.")
fs.StringVar(&o.OTLPPropagation, "grafana.tracing.otlp.propagation", "w3c", "Tracing OTLP propagation specifies the text map propagation format, w3c or jaeger.")
fs.StringVar(&o.ServiceName, "grafana.tracing.service-name", "grafana-apiserver", "Override the default service name, grafana-apiserver.")
fs.StringToStringVar(&o.Tags, "grafana.tracing.tag", map[string]string{}, "Tracing server tag in 'key=value' format. Specify multiple times to add many.")
fs.StringVar(&o.SamplerType, "grafana.tracing.sampler-type", "const", "Tracing sampler type specifies the type of the sampler: const, probabilistic, rateLimiting, or remote.")
fs.Float64Var(&o.SamplerParam, "grafana.tracing.sampler-param", 0, "Tracing sampler configuration parameter. For 'const' sampler, 0 or 1 for always false/true respectively. For 'rateLimiting' sampler, the number of spans per second. For 'remote' sampler, param is the same as for 'probabilistic' and indicates the initial sampling rate before the actual one is received from the sampling service.")
fs.StringVar(&o.SamplingServiceURL, "grafana.tracing.sampling-service", "", "Tracing server sampling service URL (used for both Jaeger and OTLP) if grafana.tracing.sampler-type=remote.")
}
func (o *TracingOptions) Validate() []error {
errs := []error{}
if o.JaegerAddress != "" {
if _, err := url.Parse(o.JaegerAddress); err != nil {
errs = append(errs, fmt.Errorf("failed to parse tracing.jaeger.address: %w", err))
}
}
if o.SamplingServiceURL != "" {
if _, err := url.Parse(o.SamplingServiceURL); err != nil {
errs = append(errs, fmt.Errorf("failed to parse tracing.sampling-service: %w", err))
}
}
if o.ServiceName == "" {
errs = append(errs, errors.New("grafana.tracing.service-name cannot be empty"))
}
return errs
}
func (o *TracingOptions) ApplyTo(config *genericapiserver.RecommendedConfig) error {
if err := utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{
string(genericfeatures.APIServerTracing): false,
}); err != nil {
return err
}
tracingCfg := tracing.NewEmptyTracingConfig()
var err error
if o.OTLPAddress != "" {
tracingCfg, err = tracing.NewOTLPTracingConfig(o.OTLPAddress, o.OTLPPropagation)
}
if o.JaegerAddress != "" {
tracingCfg, err = tracing.NewJaegerTracingConfig(o.JaegerAddress, o.JaegerPropagation)
}
if err != nil {
return err
}
tracingCfg.ServiceName = o.ServiceName
tracingCfg.ServiceVersion = setting.BuildVersion
for k, v := range o.Tags {
tracingCfg.CustomAttribs = append(tracingCfg.CustomAttribs, attribute.String(k, v))
}
tracingCfg.Sampler = o.SamplerType
tracingCfg.SamplerParam = o.SamplerParam
tracingCfg.SamplerRemoteURL = o.SamplingServiceURL
tracingCfg.ProfilingIntegration = true
ts, err := tracing.ProvideService(tracingCfg)
if err != nil {
return err
}
o.TracingService = ts
config.TracerProvider = ts.GetTracerProvider()
if config.LoopbackClientConfig != nil {
config.LoopbackClientConfig.Wrap(k8stracing.WrapperFor(config.TracerProvider))
}
config.AddPostStartHookOrDie("grafana-tracing-service", func(hookCtx genericapiserver.PostStartHookContext) error {
go func() {
if err := ts.Run(hookCtx.Context); err != nil && !errors.Is(err, context.Canceled) {
o.logger.Error("failed to shutdown tracing service", "error", err)
}
}()
return nil
})
return nil
}
+2 -2
View File
@@ -86,7 +86,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
now := time.Now()
idClaims := &auth.IDClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: s.cfg.AppURL,
Audience: getAudience(id.GetOrgID()),
Subject: id.GetID(),
@@ -102,7 +102,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
if id.IsIdentityType(claims.TypeUser) {
idClaims.Rest.Email = id.GetEmail()
idClaims.Rest.EmailVerified = id.IsEmailVerified()
idClaims.Rest.EmailVerified = id.GetEmailVerified()
idClaims.Rest.AuthenticatedBy = id.GetAuthenticatedBy()
idClaims.Rest.Username = id.GetLogin()
idClaims.Rest.DisplayName = id.GetName()
@@ -1,54 +0,0 @@
package idtest
import (
"time"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
authnlib "github.com/grafana/authlib/authn"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/services/auth"
)
func CreateInternalToken(authInfo claims.AuthInfo, secret []byte) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) {
signerOpts := jose.SignerOptions{}
signerOpts.WithType("jwt") // Should be uppercase, but this is what authlib expects
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: secret}, &signerOpts)
if err != nil {
return "", nil, err
}
identity := authInfo.GetIdentity()
now := time.Now()
tokenTTL := 10 * time.Minute
idClaims := &auth.IDClaims{
Claims: &jwt.Claims{
Audience: identity.Audience(),
Subject: identity.Subject(),
Expiry: jwt.NewNumericDate(now.Add(tokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
},
Rest: authnlib.IDTokenClaims{
Namespace: identity.Namespace(),
Identifier: identity.Identifier(),
Type: identity.IdentityType(),
},
}
if claims.IsIdentityType(identity.IdentityType(), claims.TypeUser) {
idClaims.Rest.Email = identity.Email()
idClaims.Rest.EmailVerified = identity.EmailVerified()
idClaims.Rest.AuthenticatedBy = identity.AuthenticatedBy()
idClaims.Rest.Username = identity.Username()
idClaims.Rest.DisplayName = identity.DisplayName()
}
builder := jwt.Signed(signer).Claims(&idClaims.Rest).Claims(idClaims.Claims)
token, err := builder.CompactSerialize()
if err != nil {
return "", nil, err
}
return token, idClaims, nil
}
+3 -3
View File
@@ -100,12 +100,12 @@ func (s *ExtendedJWT) authenticateAsUser(
accessTokenClaims authlib.Claims[authlib.AccessTokenClaims],
) (*authn.Identity, error) {
// Only allow id tokens signed for namespace configured for this instance.
if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(authlib.NewIdentityClaims(idTokenClaims), allowedNamespace) {
if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(idTokenClaims.Rest.Namespace, allowedNamespace) {
return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected id token namespace: %s", idTokenClaims.Rest.Namespace)
}
// Allow access tokens with either the same namespace as the validated id token namespace or wildcard (`*`).
if !claims.NamespaceMatches(authlib.NewAccessClaims(accessTokenClaims), idTokenClaims.Rest.Namespace) {
if !claims.NamespaceMatches(accessTokenClaims.Rest.Namespace, idTokenClaims.Rest.Namespace) {
return nil, errExtJWTMisMatchedNamespaceClaims.Errorf("unexpected access token namespace: %s", accessTokenClaims.Rest.Namespace)
}
@@ -155,7 +155,7 @@ func (s *ExtendedJWT) authenticateAsUser(
func (s *ExtendedJWT) authenticateAsService(accessTokenClaims authlib.Claims[authlib.AccessTokenClaims]) (*authn.Identity, error) {
// Allow access tokens with that has a wildcard namespace or a namespace matching this instance.
if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(authlib.NewAccessClaims(accessTokenClaims), allowedNamespace) {
if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(accessTokenClaims.Rest.Namespace, allowedNamespace) {
return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected access token namespace: %s", accessTokenClaims.Rest.Namespace)
}
+19 -19
View File
@@ -27,7 +27,7 @@ type (
var (
validAccessTokenClaims = accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "access-policy:this-uid",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -40,7 +40,7 @@ var (
},
}
validIDTokenClaims = idTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "user:2",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -51,7 +51,7 @@ var (
},
}
validIDTokenClaimsWithStackSet = idTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "user:2",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -62,7 +62,7 @@ var (
},
}
validIDTokenClaimsWithDeprecatedStackClaimSet = idTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "user:2",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -73,7 +73,7 @@ var (
},
}
validAccessTokenClaimsWildcard = accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "access-policy:this-uid",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -83,7 +83,7 @@ var (
},
}
validAccessTokenClaimsWithStackSet = accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "access-policy:this-uid",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -93,7 +93,7 @@ var (
},
}
validAccessTokenClaimsWithDeprecatedStackClaimSet = accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "access-policy:this-uid",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -103,7 +103,7 @@ var (
},
}
invalidNamespaceIDTokenClaims = idTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "user:2",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -114,7 +114,7 @@ var (
},
}
invalidSubjectIDTokenClaims = idTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "service-account:2",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
@@ -459,7 +459,7 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
{
name: "should return error when the subject is not an access-policy",
accessToken: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:2",
Audience: jwt.Audience{"http://localhost:3000"},
@@ -523,7 +523,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "missing iss",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
@@ -538,7 +538,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "missing expiry",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"},
@@ -553,7 +553,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "expired token",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"},
@@ -569,7 +569,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "missing aud",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
ID: "1234567890",
@@ -584,7 +584,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "wrong aud",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://some-other-host:3000"},
@@ -605,7 +605,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "missing sub",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
@@ -620,7 +620,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "missing iat",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"},
@@ -635,7 +635,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "iat later than current time",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"},
@@ -651,7 +651,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
{
name: "unsupported alg",
payload: &accessTokenClaims{
Claims: &jwt.Claims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "access-policy:this-uid",
Audience: jwt.Audience{"http://localhost:3000"},
+73 -51
View File
@@ -74,32 +74,30 @@ type Identity struct {
// Permissions is the list of permissions the entity has.
Permissions map[int64]map[string][]string
// IDToken is a signed token representing the identity that can be forwarded to plugins and external services.
IDToken string
IDTokenClaims *authn.Claims[authn.IDTokenClaims]
IDToken string
IDTokenClaims *authn.Claims[authn.IDTokenClaims]
AccessTokenClaims *authn.Claims[authn.AccessTokenClaims]
}
func (i *Identity) GetAccess() claims.AccessClaims {
if i.AccessTokenClaims != nil {
return authn.NewAccessClaims(*i.AccessTokenClaims)
}
return &identity.IDClaimsWrapper{Source: i}
func (i *Identity) GetID() string {
return i.GetSubject()
}
func (i *Identity) GetIdentity() claims.IdentityClaims {
if i.IDTokenClaims != nil {
return authn.NewIdentityClaims(*i.IDTokenClaims)
}
return &identity.IDClaimsWrapper{Source: i}
func (i *Identity) GetInternalID() (int64, error) {
return identity.IntIdentifier(i.GetID())
}
func (i *Identity) GetUID() string {
return claims.NewTypeID(i.Type, i.UID)
}
func (i *Identity) GetRawIdentifier() string {
return i.UID
}
func (i *Identity) GetInternalID() (int64, error) {
return identity.IntIdentifier(i.GetID())
func (i *Identity) GetIdentifier() string {
return i.UID
}
func (i *Identity) GetIdentityType() claims.IdentityType {
@@ -110,6 +108,43 @@ func (i *Identity) IsIdentityType(expected ...claims.IdentityType) bool {
return claims.IsIdentityType(i.GetIdentityType(), expected...)
}
func (i *Identity) GetNamespace() string {
return i.Namespace
}
func (i *Identity) GetSubject() string {
return claims.NewTypeID(i.Type, i.ID)
}
func (i *Identity) GetAudience() []string {
if i.AccessTokenClaims != nil {
return i.AccessTokenClaims.Audience
}
return []string{}
}
func (i *Identity) GetEmailVerified() bool {
return i.EmailVerified
}
func (i *Identity) GetTokenPermissions() []string {
if i.AccessTokenClaims != nil {
return i.AccessTokenClaims.Rest.Permissions
}
return []string{}
}
func (i *Identity) GetTokenDelegatedPermissions() []string {
if i.AccessTokenClaims != nil {
return i.AccessTokenClaims.Rest.DelegatedPermissions
}
return []string{}
}
func (i *Identity) GetGroups() []string {
return []string{}
}
func (i *Identity) GetExtra() map[string][]string {
extra := map[string][]string{}
if i.IDToken != "" {
@@ -121,10 +156,6 @@ func (i *Identity) GetExtra() map[string][]string {
return extra
}
func (i *Identity) GetGroups() []string {
return []string{} // teams?
}
func (i *Identity) GetName() string {
if i.Name != "" {
return i.Name
@@ -135,14 +166,6 @@ func (i *Identity) GetName() string {
return i.Email
}
func (i *Identity) GetID() string {
return claims.NewTypeID(i.Type, i.ID)
}
func (i *Identity) GetUID() string {
return claims.NewTypeID(i.Type, i.UID)
}
func (i *Identity) GetAuthID() string {
return i.AuthID
}
@@ -166,10 +189,6 @@ func (i *Identity) GetEmail() string {
return i.Email
}
func (i *Identity) IsEmailVerified() bool {
return i.EmailVerified
}
func (i *Identity) GetIDToken() string {
return i.IDToken
}
@@ -178,12 +197,12 @@ func (i *Identity) GetIsGrafanaAdmin() bool {
return i.IsGrafanaAdmin != nil && *i.IsGrafanaAdmin
}
func (i *Identity) GetLogin() string {
func (i *Identity) GetUsername() string {
return i.Login
}
func (i *Identity) GetNamespace() string {
return i.Namespace
func (i *Identity) GetLogin() string {
return i.Login
}
func (i *Identity) GetOrgID() int64 {
@@ -263,24 +282,27 @@ func (i *Identity) IsNil() bool {
// SignedInUser returns a SignedInUser from the identity.
func (i *Identity) SignedInUser() *user.SignedInUser {
u := &user.SignedInUser{
OrgID: i.OrgID,
OrgName: i.OrgName,
OrgRole: i.GetOrgRole(),
Login: i.Login,
Name: i.Name,
Email: i.Email,
AuthID: i.AuthID,
AuthenticatedBy: i.AuthenticatedBy,
IsGrafanaAdmin: i.GetIsGrafanaAdmin(),
IsAnonymous: i.IsIdentityType(claims.TypeAnonymous),
IsDisabled: i.IsDisabled,
HelpFlags1: i.HelpFlags1,
LastSeenAt: i.LastSeenAt,
Teams: i.Teams,
Permissions: i.Permissions,
IDToken: i.IDToken,
FallbackType: i.Type,
Namespace: i.Namespace,
OrgID: i.OrgID,
OrgName: i.OrgName,
OrgRole: i.GetOrgRole(),
Login: i.Login,
Name: i.Name,
Email: i.Email,
EmailVerified: i.EmailVerified,
AuthID: i.AuthID,
AuthenticatedBy: i.AuthenticatedBy,
Namespace: i.Namespace,
IsGrafanaAdmin: i.GetIsGrafanaAdmin(),
IsAnonymous: i.IsIdentityType(claims.TypeAnonymous),
IsDisabled: i.IsDisabled,
HelpFlags1: i.HelpFlags1,
LastSeenAt: i.LastSeenAt,
Teams: i.Teams,
Permissions: i.Permissions,
IDToken: i.IDToken,
IDTokenClaims: i.IDTokenClaims,
AccessTokenClaims: i.AccessTokenClaims,
FallbackType: i.Type,
}
if i.IsIdentityType(claims.TypeAPIKey) {
+58 -46
View File
@@ -15,7 +15,7 @@ const (
GlobalOrgID = int64(0)
)
var _ identity.Requester = &SignedInUser{}
var _ identity.Requester = (*SignedInUser)(nil)
type SignedInUser struct {
UserID int64 `xorm:"user_id"`
@@ -45,28 +45,28 @@ type SignedInUser struct {
Permissions map[int64]map[string][]string `json:"-"`
// IDToken is a signed token representing the identity that can be forwarded to plugins and external services.
IDToken string `json:"-" xorm:"-"`
IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] `json:"-" xorm:"-"`
IDToken string `json:"-" xorm:"-"`
IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] `json:"-" xorm:"-"`
AccessTokenClaims *authnlib.Claims[authnlib.AccessTokenClaims] `json:"-" xorm:"-"`
// When other settings are not deterministic, this value is used
FallbackType claims.IdentityType
}
// Access implements claims.AuthInfo.
func (u *SignedInUser) GetAccess() claims.AccessClaims {
return &identity.IDClaimsWrapper{Source: u}
func (u *SignedInUser) GetID() string {
ns, id := u.getTypeAndID()
return claims.NewTypeID(ns, id)
}
// Identity implements claims.AuthInfo.
func (u *SignedInUser) GetIdentity() claims.IdentityClaims {
if u.IDTokenClaims != nil {
return authnlib.NewIdentityClaims(*u.IDTokenClaims)
}
return &identity.IDClaimsWrapper{Source: u}
func (u *SignedInUser) GetInternalID() (int64, error) {
return identity.IntIdentifier(u.GetID())
}
// GetRawIdentifier implements Requester.
func (u *SignedInUser) GetRawIdentifier() string {
func (u *SignedInUser) GetUID() string {
return claims.NewTypeID(u.GetIdentityType(), u.GetIdentifier())
}
func (u *SignedInUser) GetIdentifier() string {
if u.UserUID == "" {
// nolint:staticcheck
id, _ := u.GetInternalID()
@@ -75,12 +75,10 @@ func (u *SignedInUser) GetRawIdentifier() string {
return u.UserUID
}
// GetInternalID implements Requester.
func (u *SignedInUser) GetInternalID() (int64, error) {
return identity.IntIdentifier(u.GetID())
func (u *SignedInUser) GetRawIdentifier() string {
return u.GetIdentifier()
}
// GetIdentityType implements Requester.
func (u *SignedInUser) GetIdentityType() claims.IdentityType {
switch {
case u.ApiKeyID != 0:
@@ -97,7 +95,6 @@ func (u *SignedInUser) GetIdentityType() claims.IdentityType {
return u.FallbackType
}
// IsIdentityType implements Requester.
func (u *SignedInUser) IsIdentityType(expected ...claims.IdentityType) bool {
return claims.IsIdentityType(u.GetIdentityType(), expected...)
}
@@ -115,7 +112,21 @@ func (u *SignedInUser) GetName() string {
return u.Email
}
// GetExtra implements Requester.
func (u *SignedInUser) GetNamespace() string {
return u.Namespace
}
func (u *SignedInUser) GetSubject() string {
return u.GetID()
}
func (u *SignedInUser) GetAudience() []string {
if u.AccessTokenClaims != nil {
return u.AccessTokenClaims.Audience
}
return []string{}
}
func (u *SignedInUser) GetExtra() map[string][]string {
extra := map[string][]string{}
if u.IDToken != "" {
@@ -127,7 +138,6 @@ func (u *SignedInUser) GetExtra() map[string][]string {
return extra
}
// GetGroups implements Requester.
func (u *SignedInUser) GetGroups() []string {
groups := []string{}
for _, t := range u.Teams {
@@ -136,6 +146,28 @@ func (u *SignedInUser) GetGroups() []string {
return groups
}
func (u *SignedInUser) GetTokenPermissions() []string {
if u.AccessTokenClaims != nil {
return u.AccessTokenClaims.Rest.Permissions
}
return []string{}
}
func (u *SignedInUser) GetTokenDelegatedPermissions() []string {
if u.AccessTokenClaims != nil {
return u.AccessTokenClaims.Rest.DelegatedPermissions
}
return []string{}
}
func (u *SignedInUser) GetEmail() string {
return u.Email
}
func (u *SignedInUser) GetEmailVerified() bool {
return u.EmailVerified
}
func (u *SignedInUser) ShouldUpdateLastSeenAt() bool {
return u.UserID > 0 && time.Since(u.LastSeenAt) > time.Minute*5
}
@@ -171,10 +203,6 @@ func (u *SignedInUser) HasUniqueId() bool {
return u.IsRealUser() || u.IsApiKeyUser() || u.IsServiceAccountUser()
}
func (u *SignedInUser) GetNamespace() string {
return u.Namespace
}
// GetCacheKey returns a unique key for the entity.
// Add an extra prefix to avoid collisions with other caches
func (u *SignedInUser) GetCacheKey() string {
@@ -193,7 +221,6 @@ func (u *SignedInUser) GetCacheKey() string {
return fmt.Sprintf("%d-%s-%s", u.GetOrgID(), typ, id)
}
// GetIsGrafanaAdmin returns true if the user is a server admin
func (u *SignedInUser) GetIsGrafanaAdmin() bool {
return u.IsGrafanaAdmin
}
@@ -204,6 +231,11 @@ func (u *SignedInUser) GetLogin() string {
return u.Login
}
// GetUsername implements identity.Requester.
func (u *SignedInUser) GetUsername() string {
return u.Login
}
// GetOrgID returns the ID of the active organization
func (u *SignedInUser) GetOrgID() int64 {
return u.OrgID
@@ -252,12 +284,6 @@ func (u *SignedInUser) GetOrgRole() identity.RoleType {
return u.OrgRole
}
// GetID returns namespaced id for the entity
func (u *SignedInUser) GetID() string {
ns, id := u.getTypeAndID()
return claims.NewTypeID(ns, id)
}
func (u *SignedInUser) getTypeAndID() (claims.IdentityType, string) {
switch {
case u.ApiKeyID != 0:
@@ -275,10 +301,6 @@ func (u *SignedInUser) getTypeAndID() (claims.IdentityType, string) {
return u.FallbackType, strconv.FormatInt(u.UserID, 10)
}
func (u *SignedInUser) GetUID() string {
return fmt.Sprintf("%s:%s", u.GetIdentityType(), u.GetRawIdentifier())
}
func (u *SignedInUser) GetAuthID() string {
return u.AuthID
}
@@ -301,16 +323,6 @@ func (u *SignedInUser) IsNil() bool {
return u == nil
}
// GetEmail returns the email of the active entity
// Can be empty.
func (u *SignedInUser) GetEmail() string {
return u.Email
}
func (u *SignedInUser) IsEmailVerified() bool {
return u.EmailVerified
}
func (u *SignedInUser) GetIDToken() string {
return u.IDToken
}
+2 -1
View File
@@ -108,7 +108,8 @@ func (s *cdkBackend) getPath(key *ResourceKey, rv int64) string {
return buffer.String()
}
func (s *cdkBackend) Namespaces(ctx context.Context) ([]string, error) {
// GetResourceStats implements Backend.
func (s *cdkBackend) GetResourceStats(ctx context.Context, minCount int) ([]ResourceStats, error) {
return nil, fmt.Errorf("not implemented")
}
+13 -15
View File
@@ -142,13 +142,12 @@ func idTokenExtractor(ctx context.Context) (string, error) {
// If no token is found, create an internal token.
// This is a workaround for StaticRequester not having a signed ID token.
if staticRequester, ok := authInfo.(*identity.StaticRequester); ok {
token, idClaims, err := createInternalToken(staticRequester)
token, _, err := createInternalToken(staticRequester)
if err != nil {
return "", fmt.Errorf("failed to create internal token: %w", err)
}
staticRequester.IDToken = token
staticRequester.IDTokenClaims = idClaims
return token, nil
}
@@ -170,29 +169,28 @@ func createInternalToken(authInfo claims.AuthInfo) (string, *authnlib.Claims[aut
return "", nil, err
}
identity := authInfo.GetIdentity()
now := time.Now()
tokenTTL := 10 * time.Minute
idClaims := &auth.IDClaims{
Claims: &jwt.Claims{
Audience: identity.Audience(),
Subject: identity.Subject(),
Claims: jwt.Claims{
Audience: authInfo.GetAudience(),
Subject: authInfo.GetSubject(),
Expiry: jwt.NewNumericDate(now.Add(tokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
},
Rest: authnlib.IDTokenClaims{
Namespace: identity.Namespace(),
Identifier: identity.Identifier(),
Type: identity.IdentityType(),
Namespace: authInfo.GetNamespace(),
Identifier: authInfo.GetIdentifier(),
Type: authInfo.GetIdentityType(),
},
}
if claims.IsIdentityType(identity.IdentityType(), claims.TypeUser) {
idClaims.Rest.Email = identity.Email()
idClaims.Rest.EmailVerified = identity.EmailVerified()
idClaims.Rest.AuthenticatedBy = identity.AuthenticatedBy()
idClaims.Rest.Username = identity.Username()
idClaims.Rest.DisplayName = identity.DisplayName()
if claims.IsIdentityType(authInfo.GetIdentityType(), claims.TypeUser) {
idClaims.Rest.Email = authInfo.GetEmail()
idClaims.Rest.EmailVerified = authInfo.GetEmailVerified()
idClaims.Rest.AuthenticatedBy = authInfo.GetAuthenticatedBy()
idClaims.Rest.Username = authInfo.GetUsername()
idClaims.Rest.DisplayName = authInfo.GetName()
}
builder := jwt.Signed(signer).Claims(&idClaims.Rest).Claims(idClaims.Claims)
+14 -30
View File
@@ -81,6 +81,7 @@ type searchSupport struct {
access authz.AccessClient
builders *builderCache
initWorkers int
initMinSize int
}
var (
@@ -104,6 +105,7 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access authz.A
search: opts.Backend,
log: slog.Default().With("logger", "resource-search"),
initWorkers: opts.WorkerThreads,
initMinSize: opts.InitMinCount,
}
info, err := opts.Resources.GetDocumentBuilders()
@@ -164,40 +166,22 @@ func (s *searchSupport) init(ctx context.Context) error {
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init")
defer span.End()
// TODO, replace namespaces with a query that gets top values
namespaces, err := s.storage.Namespaces(ctx)
if err != nil {
return err
}
// Hardcoded for now... should come from the query
kinds := []schema.GroupResource{
{Group: "dashboard.grafana.app", Resource: "dashboards"},
{Group: "playlist.grafana.app", Resource: "playlists"},
}
totalBatchesIndexed := 0
group := errgroup.Group{}
group.SetLimit(s.initWorkers)
// Prepare all the (large) indexes
// TODO, threading and query real information:
// SELECT namespace,"group",resource,COUNT(*),resource_version FROM resource
// GROUP BY "group", "resource", "namespace"
// ORDER BY resource_version desc;
for _, ns := range namespaces {
for _, gr := range kinds {
group.Go(func() error {
s.log.Debug("initializing search index", "namespace", ns, "gr", gr)
totalBatchesIndexed++
_, _, err = s.build(ctx, NamespacedResource{
Group: gr.Group,
Resource: gr.Resource,
Namespace: ns,
}, 10, 0) // TODO, approximate size
return err
})
}
stats, err := s.storage.GetResourceStats(ctx, s.initMinSize)
if err != nil {
return err
}
for _, info := range stats {
group.Go(func() error {
s.log.Debug("initializing search index", "namespace", info.Namespace, "group", info.Group, "resource", info.Resource)
totalBatchesIndexed++
_, _, err = s.build(ctx, info.NamespacedResource, info.Count, info.ResourceVersion)
return err
})
}
err = group.Wait()
+11 -1
View File
@@ -95,7 +95,14 @@ type StorageBackend interface {
// For HA setups, this will be more events than the local WriteEvent above!
WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error)
Namespaces(ctx context.Context) ([]string, error)
GetResourceStats(ctx context.Context, minCount int) ([]ResourceStats, error)
}
type ResourceStats struct {
NamespacedResource
Count int64
ResourceVersion int64
}
// This interface is not exposed to end users directly
@@ -133,6 +140,9 @@ type SearchOptions struct {
// How many threads should build indexes
WorkerThreads int
// Skip building index on startup for small indexes
InitMinCount int
}
type ResourceServerOptions struct {
+32 -29
View File
@@ -121,6 +121,38 @@ func (b *backend) Stop(_ context.Context) error {
return nil
}
// GetResourceStats implements Backend.
func (b *backend) GetResourceStats(ctx context.Context, minCount int) ([]resource.ResourceStats, error) {
_, span := b.tracer.Start(ctx, tracePrefix+".GetResourceStats")
defer span.End()
req := &sqlStatsRequest{
SQLTemplate: sqltemplate.New(b.dialect),
MinCount: minCount, // not used in query... yet?
}
res := make([]resource.ResourceStats, 0, 100)
err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error {
rows, err := dbutil.QueryRows(ctx, tx, sqlResourceStats, req)
if err != nil {
return err
}
for rows.Next() {
row := resource.ResourceStats{}
err = rows.Scan(&row.Namespace, &row.Group, &row.Resource, &row.Count, &row.ResourceVersion)
if err != nil {
return err
}
if row.Count > int64(minCount) {
res = append(res, row)
}
}
return err
})
return res, err
}
func (b *backend) WriteEvent(ctx context.Context, event resource.WriteEvent) (int64, error) {
_, span := b.tracer.Start(ctx, tracePrefix+"WriteEvent")
defer span.End()
@@ -137,35 +169,6 @@ func (b *backend) WriteEvent(ctx context.Context, event resource.WriteEvent) (in
}
}
// Namespaces returns the list of unique namespaces in storage.
func (b *backend) Namespaces(ctx context.Context) ([]string, error) {
var namespaces []string
err := b.db.WithTx(ctx, RepeatableRead, func(ctx context.Context, tx db.Tx) error {
rows, err := tx.QueryContext(ctx, "SELECT DISTINCT(namespace) FROM resource ORDER BY namespace;")
if err != nil {
return err
}
defer func() {
_ = rows.Close()
}()
for rows.Next() {
var ns string
err = rows.Scan(&ns)
if err != nil {
return err
}
namespaces = append(namespaces, ns)
}
return nil
})
return namespaces, err
}
func (b *backend) create(ctx context.Context, event resource.WriteEvent) (int64, error) {
ctx, span := b.tracer.Start(ctx, tracePrefix+"Create")
defer span.End()
@@ -0,0 +1,12 @@
SELECT
{{ .Ident "namespace" }},
{{ .Ident "group" }},
{{ .Ident "resource" }},
COUNT(*),
MAX({{ .Ident "resource_version" }})
FROM {{ .Ident "resource" }}
GROUP BY
{{ .Ident "namespace" }},
{{ .Ident "group" }},
{{ .Ident "resource" }}
;
@@ -25,6 +25,10 @@ func getEngineMySQL(getter confGetter) (*xorm.Engine, error) {
// See: https://dev.mysql.com/doc/refman/en/sql-mode.html
"@@SESSION.sql_mode": "ANSI",
}
sslMode := getter.String("ssl_mode")
if sslMode == "true" || sslMode == "skip-verify" {
config.Params["tls"] = "preferred"
}
tls := getter.String("tls")
if tls != "" {
config.Params["tls"] = tls
+10
View File
@@ -31,6 +31,7 @@ var (
sqlResourceInsert = mustTemplate("resource_insert.sql")
sqlResourceUpdate = mustTemplate("resource_update.sql")
sqlResourceRead = mustTemplate("resource_read.sql")
sqlResourceStats = mustTemplate("resource_stats.sql")
sqlResourceList = mustTemplate("resource_list.sql")
sqlResourceHistoryList = mustTemplate("resource_history_list.sql")
sqlResourceUpdateRV = mustTemplate("resource_update_rv.sql")
@@ -71,6 +72,15 @@ func (r sqlResourceRequest) Validate() error {
return nil // TODO
}
type sqlStatsRequest struct {
sqltemplate.SQLTemplate
MinCount int
}
func (r sqlStatsRequest) Validate() error {
return nil // TODO
}
type historyPollResponse struct {
Key resource.ResourceKey
ResourceVersion int64
+10
View File
@@ -219,5 +219,15 @@ func TestUnifiedStorageQueries(t *testing.T) {
},
},
},
sqlResourceStats: {
{
Name: "query",
Data: &sqlStatsRequest{
SQLTemplate: mocks.NewTestingSQLTemplate(),
MinCount: 10, // Not yet used in query (only response filter)
},
},
},
}})
}
+1
View File
@@ -65,6 +65,7 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg,
}, tracer, reg),
Resources: docs,
WorkerThreads: 5, // from cfg?
InitMinCount: 1,
}
}
@@ -13,7 +13,6 @@ import (
"github.com/grafana/authlib/claims"
"github.com/grafana/dskit/services"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
infraDB "github.com/grafana/grafana/pkg/infra/db"
@@ -98,6 +97,12 @@ func TestIntegrationBackendHappyPath(t *testing.T) {
rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
require.NoError(t, err)
require.Greater(t, rv3, rv2)
stats, err := backend.GetResourceStats(ctx, 0)
require.NoError(t, err)
require.Len(t, stats, 1)
require.Equal(t, int64(3), stats[0].Count)
require.Equal(t, rv3, stats[0].ResourceVersion)
})
t.Run("Update item2", func(t *testing.T) {
+12
View File
@@ -0,0 +1,12 @@
SELECT
`namespace`,
`group`,
`resource`,
COUNT(*),
MAX(`resource_version`)
FROM `resource`
GROUP BY
`namespace`,
`group`,
`resource`
;
+12
View File
@@ -0,0 +1,12 @@
SELECT
"namespace",
"group",
"resource",
COUNT(*),
MAX("resource_version")
FROM "resource"
GROUP BY
"namespace",
"group",
"resource"
;
+12
View File
@@ -0,0 +1,12 @@
SELECT
"namespace",
"group",
"resource",
COUNT(*),
MAX("resource_version")
FROM "resource"
GROUP BY
"namespace",
"group",
"resource"
;
+130
View File
@@ -0,0 +1,130 @@
package shorturl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/userimpl"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestShortURL(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
username, password := "viewer", "viewer"
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleEditor),
Password: user.Password(password),
Login: username,
})
c := client(grafanaListedAddr, username, password)
// Create a valid short-urls
res, err := c.post("/api/short-urls", bytes.NewReader([]byte(`{"path":"explore"}`)))
require.NoError(t, err)
defer func() {
_ = res.Body.Close()
}()
require.Equal(t, http.StatusOK, res.StatusCode)
bodyRaw, err := io.ReadAll(res.Body)
require.NoError(t, err)
resParsed := struct {
UID string `json:"uid"`
}{}
err = json.Unmarshal(bodyRaw, &resParsed)
require.NoError(t, err)
// If the go-to exists, it should be set in the location and the status should match 302.
res, err = c.get(fmt.Sprintf("/goto/%s", resParsed.UID))
require.NoError(t, err)
defer func() {
_ = res.Body.Close()
}()
require.Equal(t, "http://localhost:3000/explore", res.Header.Get("Location"))
require.Equal(t, http.StatusFound, res.StatusCode)
// If the go-to does not exist, it should redirect to the home page and return 308.
res, err = c.get("/goto/DoesNotExist")
require.NoError(t, err)
defer func() {
_ = res.Body.Close()
}()
require.Equal(t, "http://localhost:3000/", res.Header.Get("Location"))
require.Equal(t, http.StatusPermanentRedirect, res.StatusCode)
}
func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 {
t.Helper()
cfg.AutoAssignOrg = true
cfg.AutoAssignOrgId = 1
quotaService := quotaimpl.ProvideService(db, cfg)
orgService, err := orgimpl.ProvideService(db, cfg, quotaService)
require.NoError(t, err)
usrSvc, err := userimpl.ProvideService(
db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(),
quotaService, supportbundlestest.NewFakeBundleService(),
)
require.NoError(t, err)
u, err := usrSvc.Create(context.Background(), &cmd)
require.NoError(t, err)
return u.ID
}
type apiClient struct {
url string
}
func client(host, user, pass string) apiClient {
if len(user) == 0 && len(pass) == 0 {
return apiClient{url: fmt.Sprintf("http://%s", host)}
}
return apiClient{url: fmt.Sprintf("http://%s:%s@%s", user, pass, host)}
}
func (a apiClient) get(path string) (*http.Response, error) {
u := fmt.Sprintf("%s%s", a.url, path)
// Setup a client that doesn't follow redirects.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// nolint:gosec
return client.Get(u)
}
func (a apiClient) post(path string, body io.Reader) (*http.Response, error) {
u := fmt.Sprintf("%s%s", a.url, path)
// nolint:gosec
return http.Post(u, "application/json", body)
}
+2 -1
View File
@@ -59,7 +59,8 @@ func (e *cloudWatchExecutor) executeAnnotationQuery(ctx context.Context, pluginC
cli, err := e.getCWClient(ctx, pluginCtx, region)
if err != nil {
return nil, err
result = errorsource.AddDownstreamErrorToResponse(query.RefID, result, fmt.Errorf("%v: %w", "failed to get client", err))
return result, nil
}
var alarmNames []*string
+2 -1
View File
@@ -24,7 +24,8 @@ var executeSyncLogQuery = func(ctx context.Context, e *cloudWatchExecutor, req *
instance, err := e.getInstance(ctx, req.PluginContext)
if err != nil {
return nil, err
errorsource.AddErrorToResponse(req.Queries[0].RefID, resp, err)
return resp, nil
}
for _, q := range req.Queries {
+6 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana-aws-sdk/pkg/awsds"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource"
)
type Duration struct {
@@ -61,13 +62,16 @@ func (duration *Duration) UnmarshalJSON(b []byte) error {
case float64:
*duration = Duration{time.Duration(value)}
case string:
if value == "" {
return nil
}
dur, err := time.ParseDuration(value)
if err != nil {
return err
return errorsource.DownstreamError(err, false)
}
*duration = Duration{dur}
default:
return fmt.Errorf("invalid duration: %#v", unmarshalledJson)
return errorsource.DownstreamError(fmt.Errorf("invalid duration: %#v", unmarshalledJson), false)
}
return nil
@@ -2,12 +2,14 @@ package models
import (
"context"
"errors"
"testing"
"time"
"github.com/grafana/grafana-aws-sdk/pkg/awsds"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/proxy"
"github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -114,6 +116,24 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, time.Minute*30, s.LogsTimeout.Duration)
})
t.Run("Should set logsTimeout to default duration if it is empty string", func(t *testing.T) {
settings := backend.DataSourceInstanceSettings{
ID: 33,
JSONData: []byte(`{
"authType": "arn",
"assumeRoleArn": "arn:aws:iam::123456789012:role/grafana",
"logsTimeout": ""
}`),
DecryptedSecureJSONData: map[string]string{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}
s, err := LoadCloudWatchSettings(settingCtx, settings)
require.NoError(t, err)
assert.Equal(t, time.Minute*30, s.LogsTimeout.Duration)
})
t.Run("Should correctly parse logsTimeout duration string", func(t *testing.T) {
settings := backend.DataSourceInstanceSettings{
ID: 33,
@@ -184,6 +204,10 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) {
_, err := LoadCloudWatchSettings(context.Background(), settings)
require.Error(t, err)
var sourceErr errorsource.Error
ok := errors.As(err, &sourceErr)
require.True(t, ok)
require.Equal(t, sourceErr.ErrorSource().String(), "downstream")
})
t.Run("Should throw error if logsTimeout is an invalid type", func(t *testing.T) {
settings := backend.DataSourceInstanceSettings{
+2 -1
View File
@@ -35,7 +35,8 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *ba
instance, err := e.getInstance(ctx, req.PluginContext)
if err != nil {
return nil, err
errorsource.AddErrorToResponse(req.Queries[0].RefID, resp, err)
return resp, nil
}
requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, instance.Settings.Region, e.logger.FromContext(ctx),
+13
View File
@@ -3,6 +3,7 @@ package elasticsearch
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource"
)
func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
@@ -51,6 +53,9 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
if err != nil {
logger.Error("Failed to do healthcheck request", "error", err, "url", esUrl.String())
if backend.IsDownstreamHTTPError(err) {
err = errorsource.DownstreamError(err, false)
}
return &backend.CheckHealthResult{
Status: backend.HealthStatusUnknown,
Message: "Failed to do healthcheck request",
@@ -64,6 +69,14 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}, nil
}
if response.StatusCode >= 400 {
errWithSource := errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(response.StatusCode), fmt.Errorf("unexpected status code: %d", response.StatusCode), false)
return &backend.CheckHealthResult{
Status: backend.HealthStatusError,
Message: fmt.Sprintf("Elasticsearch data source is not healthy. Status: %s", response.Status),
}, errWithSource
}
logger.Info("Response received from Elasticsearch", "statusCode", response.StatusCode, "status", "ok", "duration", time.Since(start))
defer func() {
+22 -10
View File
@@ -16,7 +16,7 @@ import (
)
func Test_Healthcheck_OK(t *testing.T) {
service := GetMockService(true)
service := GetMockService(http.StatusOK, "200 OK")
res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{
PluginContext: backend.PluginContext{},
Headers: nil,
@@ -26,7 +26,7 @@ func Test_Healthcheck_OK(t *testing.T) {
}
func Test_Healthcheck_Timeout(t *testing.T) {
service := GetMockService(false)
service := GetMockService(http.StatusRequestTimeout, "408 Request Timeout")
res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{
PluginContext: backend.PluginContext{},
Headers: nil,
@@ -35,13 +35,24 @@ func Test_Healthcheck_Timeout(t *testing.T) {
assert.Equal(t, "Elasticsearch data source is not healthy", res.Message)
}
func Test_Healthcheck_Error(t *testing.T) {
service := GetMockService(http.StatusBadGateway, "502 Bad Gateway")
res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{
PluginContext: backend.PluginContext{},
Headers: nil,
})
assert.Equal(t, backend.HealthStatusError, res.Status)
assert.Equal(t, "Elasticsearch data source is not healthy. Status: 502 Bad Gateway", res.Message)
}
type FakeRoundTripper struct {
isDsHealthy bool
statusCode int
status string
}
func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
var res *http.Response
if fakeRoundTripper.isDsHealthy {
if fakeRoundTripper.statusCode == http.StatusOK {
res = &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
@@ -49,8 +60,8 @@ func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Re
}
} else {
res = &http.Response{
StatusCode: http.StatusRequestTimeout,
Status: "408 Request Timeout",
StatusCode: fakeRoundTripper.statusCode,
Status: fakeRoundTripper.status,
Body: io.NopCloser(bytes.NewBufferString("{\"status\":\"red\"}")),
}
}
@@ -58,12 +69,13 @@ func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Re
}
type FakeInstanceManager struct {
isDsHealthy bool
statusCode int
status string
}
func (fakeInstanceManager *FakeInstanceManager) Get(tx context.Context, pluginContext backend.PluginContext) (instancemgmt.Instance, error) {
httpClient, _ := httpclient.New(httpclient.Options{})
httpClient.Transport = &FakeRoundTripper{isDsHealthy: fakeInstanceManager.isDsHealthy}
httpClient.Transport = &FakeRoundTripper{statusCode: fakeInstanceManager.statusCode, status: fakeInstanceManager.status}
return es.DatasourceInfo{
HTTPClient: httpClient,
@@ -74,9 +86,9 @@ func (*FakeInstanceManager) Do(_ context.Context, _ backend.PluginContext, _ ins
return nil
}
func GetMockService(isDsHealthy bool) *Service {
func GetMockService(statusCode int, status string) *Service {
return &Service{
im: &FakeInstanceManager{isDsHealthy: isDsHealthy},
im: &FakeInstanceManager{statusCode: statusCode, status: status},
logger: log.New(),
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ toolchain go1.23.1
require (
github.com/mattn/go-sqlite3 v1.14.22
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
xorm.io/builder v0.3.6
xorm.io/core v0.7.3
)
+2 -2
View File
@@ -30,8 +30,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+130
View File
@@ -4501,6 +4501,21 @@
}
}
},
"EmailSchema": {
"description": "Implementation of SCIM specification email schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.2",
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"type": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"EnumFieldConfig": {
"description": "Enum field config\nVector values are used as lookup keys into the enum fields",
"type": "object",
@@ -5016,6 +5031,35 @@
}
}
},
"GetUsersBody": {
"type": "object",
"properties": {
"Resources": {
"type": "array",
"items": {
"$ref": "#/definitions/UserSchema"
}
},
"itemsPerPage": {
"type": "integer",
"format": "int64"
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"startIndex": {
"type": "integer",
"format": "int64"
},
"totalResults": {
"type": "integer",
"format": "int64"
}
}
},
"Group": {
"type": "object",
"properties": {
@@ -5553,6 +5597,29 @@
}
}
},
"MetaSchema": {
"description": "Implementation of SCIM specification meta schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-3.1",
"type": "object",
"properties": {
"created": {
"type": "string",
"format": "date-time"
},
"lastModified": {
"type": "string",
"format": "date-time"
},
"location": {
"type": "string"
},
"resourceType": {
"type": "string"
},
"version": {
"type": "string"
}
}
},
"Metadata": {
"description": "Metadata contains user accesses for a given resource\nEx: map[string]bool{\"create\":true, \"delete\": true}",
"type": "object",
@@ -5737,6 +5804,24 @@
}
}
},
"NameSchema": {
"description": "Implementation of SCIM specification name schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1",
"type": "object",
"properties": {
"familyName": {
"type": "string"
},
"formatted": {
"type": "string"
},
"givenName": {
"type": "string"
},
"middleName": {
"type": "string"
}
}
},
"NavbarPreference": {
"type": "object",
"title": "NavbarPreference defines model for NavbarPreference.",
@@ -8566,6 +8651,45 @@
}
}
},
"UserSchema": {
"description": "Implementation of SCIM specification user schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1",
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"displayName": {
"type": "string"
},
"emails": {
"type": "array",
"items": {
"$ref": "#/definitions/EmailSchema"
}
},
"externalId": {
"type": "string"
},
"id": {
"type": "string"
},
"meta": {
"$ref": "#/definitions/MetaSchema"
},
"name": {
"$ref": "#/definitions/NameSchema"
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"userName": {
"type": "string"
}
}
},
"UserSearchHitDTO": {
"type": "object",
"properties": {
@@ -9769,6 +9893,12 @@
}
}
},
"getUsers": {
"description": "",
"schema": {
"$ref": "#/definitions/GetUsersBody"
}
},
"goneError": {
"description": "GoneError is returned when the requested endpoint was removed.",
"schema": {
+207
View File
@@ -8607,6 +8607,30 @@
}
},
"/scim/users/": {
"get": {
"tags": [
"users"
],
"summary": "Fetches all users in UserSchema format.",
"operationId": "getUsers",
"responses": {
"200": {
"$ref": "#/responses/getUsers"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
},
"post": {
"tags": [
"users"
@@ -8617,6 +8641,9 @@
"200": {
"$ref": "#/responses/okResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
@@ -8630,6 +8657,56 @@
"$ref": "#/responses/internalServerError"
}
}
},
"delete": {
"tags": [
"user"
],
"summary": "Deletes user.",
"operationId": "deleteUser",
"responses": {
"200": {
"$ref": "#/responses/okResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/scim/users/:id": {
"get": {
"tags": [
"user"
],
"summary": "Gets user by id.",
"operationId": "getUser",
"responses": {
"200": {
"$ref": "#/responses/okResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/search": {
@@ -15065,6 +15142,21 @@
}
}
},
"EmailSchema": {
"description": "Implementation of SCIM specification email schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.2",
"type": "object",
"properties": {
"primary": {
"type": "boolean"
},
"type": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"EmbeddedContactPoint": {
"description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.",
"type": "object",
@@ -15779,6 +15871,35 @@
}
}
},
"GetUsersBody": {
"type": "object",
"properties": {
"Resources": {
"type": "array",
"items": {
"$ref": "#/definitions/UserSchema"
}
},
"itemsPerPage": {
"type": "integer",
"format": "int64"
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"startIndex": {
"type": "integer",
"format": "int64"
},
"totalResults": {
"type": "integer",
"format": "int64"
}
}
},
"GettableAlertmanagers": {
"type": "object",
"properties": {
@@ -17075,6 +17196,29 @@
"$ref": "#/definitions/Matcher"
}
},
"MetaSchema": {
"description": "Implementation of SCIM specification meta schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-3.1",
"type": "object",
"properties": {
"created": {
"type": "string",
"format": "date-time"
},
"lastModified": {
"type": "string",
"format": "date-time"
},
"location": {
"type": "string"
},
"resourceType": {
"type": "string"
},
"version": {
"type": "string"
}
}
},
"Metadata": {
"description": "Metadata contains user accesses for a given resource\nEx: map[string]bool{\"create\":true, \"delete\": true}",
"type": "object",
@@ -17301,6 +17445,24 @@
}
}
},
"NameSchema": {
"description": "Implementation of SCIM specification name schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1",
"type": "object",
"properties": {
"familyName": {
"type": "string"
},
"formatted": {
"type": "string"
},
"givenName": {
"type": "string"
},
"middleName": {
"type": "string"
}
}
},
"NamespaceConfigResponse": {
"type": "object",
"additionalProperties": {
@@ -22163,6 +22325,45 @@
}
}
},
"UserSchema": {
"description": "Implementation of SCIM specification user schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1",
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"displayName": {
"type": "string"
},
"emails": {
"type": "array",
"items": {
"$ref": "#/definitions/EmailSchema"
}
},
"externalId": {
"type": "string"
},
"id": {
"type": "string"
},
"meta": {
"$ref": "#/definitions/MetaSchema"
},
"name": {
"$ref": "#/definitions/NameSchema"
},
"schemas": {
"type": "array",
"items": {
"type": "string"
}
},
"userName": {
"type": "string"
}
}
},
"UserSearchHitDTO": {
"type": "object",
"properties": {
@@ -24138,6 +24339,12 @@
}
}
},
"getUsers": {
"description": "(empty)",
"schema": {
"$ref": "#/definitions/GetUsersBody"
}
},
"goneError": {
"description": "GoneError is returned when the requested endpoint was removed.",
"schema": {
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
import { UseFormSetValue } from 'react-hook-form';
import { AppEvents } from '@grafana/data';
import { getAppEvents, getBackendSrv } from '@grafana/runtime';
+1 -2
View File
@@ -1,6 +1,5 @@
import { ReactElement } from 'react';
import { Validate } from 'react-hook-form';
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
import { Validate, UseFormSetValue } from 'react-hook-form';
import { IconName, SelectableValue } from '@grafana/data';
import { Settings } from 'app/types';
@@ -263,19 +263,14 @@ export const browseDashboardsAPI = createApi({
for (const dashboardUID of selectedDashboards) {
const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID);
const options = {
await getDashboardAPI().saveDashboard({
dashboard: fullDash.dashboard,
folderUid: destinationUID,
overwrite: false,
message: '',
};
await baseQuery({
url: `/dashboards/db`,
method: 'POST',
data: options,
});
}
return { data: undefined };
},
onQueryStarted: ({ destinationUID, selectedItems }, { queryFulfilled, dispatch }) => {
@@ -314,11 +309,8 @@ export const browseDashboardsAPI = createApi({
// Delete all the dashboards sequentially
// TODO error handling here
for (const dashboardUID of selectedDashboards) {
const response = await baseQuery({
url: `/dashboards/uid/${dashboardUID}`,
method: 'DELETE',
showSuccessAlert: false,
});
const response = getDashboardAPI().deleteDashboard(dashboardUID, false);
// @ts-expect-error
const name = response?.data?.title;
@@ -1,15 +1,20 @@
import { useMemo } from 'react';
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { getDashboardSceneFor } from '../utils/utils';
import { createUsagesNetwork, transformUsagesToNetwork } from '../variables/utils';
import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
import { VariableEditorForm } from './variables/VariableEditorForm';
import { VariableEditorList } from './variables/VariableEditorList';
import { VariablesUnknownTable } from './variables/VariablesUnknownTable';
import {
EditableVariableType,
RESERVED_GLOBAL_VARIABLE_NAME_REGEX,
@@ -17,6 +22,7 @@ import {
getVariableDefault,
getVariableScene,
} from './variables/utils';
export interface VariablesEditViewState extends DashboardEditViewState {
editIndex?: number | undefined;
}
@@ -197,6 +203,22 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
return [false, null];
};
public getSaveModel = () => {
return transformSceneToSaveModel(this.getDashboard());
};
public getUsages = () => {
const model = this.getSaveModel();
const usages = createUsagesNetwork(this.getVariables(), model);
return usages;
};
public getUsagesNetwork = () => {
const usages = this.getUsages();
const usagesNetwork = transformUsagesToNetwork(usages);
return usagesNetwork;
};
}
function VariableEditorSettingsListView({ model }: SceneComponentProps<VariablesEditView>) {
@@ -206,6 +228,9 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
const { onDelete, onDuplicated, onOrderChanged, onEdit, onTypeChange, onGoBack, onAdd } = model;
const { variables } = model.getVariableSet().useState();
const { editIndex } = model.useState();
const usagesNetwork = useMemo(() => model.getUsagesNetwork(), [model]);
const usages = useMemo(() => model.getUsages(), [model]);
const saveModel = model.getSaveModel();
if (editIndex !== undefined && variables[editIndex]) {
const variable = variables[editIndex];
@@ -230,12 +255,15 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
<NavToolbarActions dashboard={dashboard} />
<VariableEditorList
variables={variables}
usages={usages}
usagesNetwork={usagesNetwork}
onDelete={onDelete}
onDuplicate={onDuplicated}
onChangeOrder={onOrderChanged}
onAdd={onAdd}
onEdit={onEdit}
/>
<VariablesUnknownTable variables={variables} dashboard={saveModel} />
</Page>
);
}
@@ -10,11 +10,14 @@ import { useStyles2, Stack, Button, EmptyState, TextLink } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { VariablesDependenciesButton } from '../../variables/VariablesDependenciesButton';
import { UsagesToNetwork, VariableUsageTree } from '../../variables/utils';
import { VariableEditorListRow } from './VariableEditorListRow';
export interface Props {
variables: Array<SceneVariable<SceneVariableState>>;
usages: VariableUsageTree[];
usagesNetwork: UsagesToNetwork[];
onAdd: () => void;
onChangeOrder: (fromIndex: number, toIndex: number) => void;
onDuplicate: (identifier: string) => void;
@@ -24,6 +27,8 @@ export interface Props {
export function VariableEditorList({
variables,
usages,
usagesNetwork,
onChangeOrder,
onDelete,
onDuplicate,
@@ -71,6 +76,8 @@ export function VariableEditorList({
onDelete={onDelete}
onDuplicate={onDuplicate}
onEdit={onEdit}
usageTree={usages}
usagesNetwork={usagesNetwork}
/>
);
})}
@@ -6,13 +6,18 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { SceneVariable } from '@grafana/scenes';
import { Button, ConfirmModal, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
import { Button, ConfirmModal, Icon, IconButton, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import { VariableUsagesButton } from '../../variables/VariableUsagesButton';
import { UsagesToNetwork, VariableUsageTree, getVariableUsages } from '../../variables/utils';
import { getDefinition } from './utils';
export interface VariableEditorListRowProps {
index: number;
variable: SceneVariable;
usageTree: VariableUsageTree[];
usagesNetwork: UsagesToNetwork[];
onEdit: (identifier: string) => void;
onDuplicate: (identifier: string) => void;
onDelete: (identifier: string) => void;
@@ -21,6 +26,8 @@ export interface VariableEditorListRowProps {
export function VariableEditorListRow({
index,
variable,
usageTree,
usagesNetwork,
onEdit: propsOnEdit,
onDuplicate: propsOnDuplicate,
onDelete: propsOnDelete,
@@ -30,6 +37,8 @@ export function VariableEditorListRow({
const definition = getDefinition(variable);
const variableState = variable.state;
const identifier = variableState.name;
const usages = getVariableUsages(identifier, usageTree);
const passed = usages > 0 || variableState.type === 'adhoc';
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleDeleteVariableModal = (show: boolean) => () => {
setShowDeleteModal(show);
@@ -79,6 +88,12 @@ export function VariableEditorListRow({
<td role="gridcell" className={styles.column}>
<div className={styles.icons}>
<VariableCheckIndicator passed={passed} />
<VariableUsagesButton
id={variableState.name}
isAdhoc={variableState.type === 'adhoc'}
usages={usagesNetwork}
/>
<IconButton
onClick={(event) => {
event.preventDefault();
@@ -122,6 +137,35 @@ export function VariableEditorListRow({
);
}
interface VariableCheckIndicatorProps {
passed: boolean;
}
function VariableCheckIndicator({ passed }: VariableCheckIndicatorProps): ReactElement {
const styles = useStyles2(getStyles);
if (passed) {
return (
<Tooltip content="This variable is referenced by other variables or dashboard.">
<Icon
name="check"
className={styles.iconPassed}
aria-label="This variable is referenced by other variables or dashboard."
/>
</Tooltip>
);
}
return (
<Tooltip content="This variable is not referenced by other variables or dashboard.">
<Icon
name="exclamation-triangle"
className={styles.iconFailed}
aria-label="This variable is not referenced by any variable or dashboard."
/>
</Tooltip>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
dragHandle: css({
@@ -0,0 +1,151 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as runtime from '@grafana/runtime';
import { TestVariable } from '@grafana/scenes';
import * as utils from '../../variables/utils';
import { UsagesToNetwork } from '../../variables/utils';
import {
SLOW_VARIABLES_EXPANSION_THRESHOLD,
VariablesUnknownTable,
VariablesUnknownTableProps,
} from './VariablesUnknownTable';
async function getTestContext(
overrides: Partial<VariablesUnknownTableProps> | undefined = {},
usages: UsagesToNetwork[] = []
) {
jest.clearAllMocks();
const reportInteractionSpy = jest.spyOn(runtime, 'reportInteraction').mockImplementation();
const getUnknownsNetworkSpy = jest.spyOn(utils, 'getUnknownsNetwork').mockResolvedValue(usages);
const defaults: VariablesUnknownTableProps = {
variables: [],
dashboard: null,
};
const props = { ...defaults, ...overrides };
const { rerender } = render(<VariablesUnknownTable {...props} />);
await waitFor(() =>
expect(screen.getByRole('heading', { name: /renamed or missing variables/i })).toBeInTheDocument()
);
return { reportInteractionSpy, getUnknownsNetworkSpy, rerender };
}
describe('VariablesUnknownTable', () => {
describe('when rendered', () => {
it('then it should render the section header', async () => {
await getTestContext();
});
});
describe('when expanding the section', () => {
it('then it should call getUnknownsNetwork', async () => {
const { getUnknownsNetworkSpy } = await getTestContext();
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1));
});
it('then it should report the interaction', async () => {
const { reportInteractionSpy } = await getTestContext();
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
expect(reportInteractionSpy).toHaveBeenCalledTimes(1);
expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded');
});
describe('but when expanding it again without changes to variables or dashboard', () => {
it('then it should not call getUnknownsNetwork', async () => {
const { getUnknownsNetworkSpy } = await getTestContext();
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'));
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false'));
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'));
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
});
});
describe('and there are no renamed or missing variables', () => {
it('then it should render the correct message', async () => {
await getTestContext();
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
expect(screen.getByText('No renamed or missing variables found.')).toBeInTheDocument();
});
});
describe('and there are renamed or missing variables', () => {
it('then it should render the table', async () => {
const variable = new TestVariable({ name: 'Renamed Variable', query: 'A.*', value: '', text: '', options: [] });
const usages = [{ variable, nodes: [], edges: [], showGraph: false }];
const { reportInteractionSpy } = await getTestContext({}, usages);
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
expect(screen.queryByText('No renamed or missing variables found.')).not.toBeInTheDocument();
expect(screen.getByText('Renamed Variable')).toBeInTheDocument();
expect(screen.getByLabelText('Show usages')).toBeInTheDocument();
// make sure we don't report the interaction for slow expansion
expect(reportInteractionSpy).toHaveBeenCalledTimes(1);
expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded');
});
describe('but when the unknown processing takes a while', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833
user = userEvent.setup({ delay: null });
});
afterEach(() => {
jest.useRealTimers();
});
it('then it should report slow expansion', async () => {
const variable = new TestVariable({
name: 'Renamed Variable',
query: 'A.*',
value: '',
text: '',
options: [],
});
const usages = [{ variable, nodes: [], edges: [], showGraph: false }];
const { getUnknownsNetworkSpy, reportInteractionSpy } = await getTestContext({}, usages);
getUnknownsNetworkSpy.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(usages);
}, SLOW_VARIABLES_EXPANSION_THRESHOLD);
});
});
await user.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
jest.advanceTimersByTime(SLOW_VARIABLES_EXPANSION_THRESHOLD);
// make sure we report the interaction for slow expansion
await waitFor(() =>
expect(reportInteractionSpy).toHaveBeenCalledWith('Slow unknown variables expansion', {
elapsed: expect.any(Number),
})
);
});
});
});
});
});
@@ -0,0 +1,154 @@
import { css } from '@emotion/css';
import { ReactElement, useEffect, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { CollapsableSection, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { VariableUsagesButton } from '../../variables/VariableUsagesButton';
import { getUnknownsNetwork, UsagesToNetwork } from '../../variables/utils';
export const SLOW_VARIABLES_EXPANSION_THRESHOLD = 1000;
export interface VariablesUnknownTableProps {
variables: Array<SceneVariable<SceneVariableState>>;
dashboard: Dashboard | null;
}
export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknownTableProps): ReactElement {
const [open, setOpen] = useState(false);
const [changed, setChanged] = useState(0);
const style = useStyles2(getStyles);
useEffect(() => setChanged((prevState) => prevState + 1), [variables, dashboard]);
const [{ loading, value: usages }, getUnknowns] = useAsyncFn(async () => {
const start = Date.now();
const unknownsNetwork = await getUnknownsNetwork(variables, dashboard);
const stop = Date.now();
const elapsed = stop - start;
if (elapsed >= SLOW_VARIABLES_EXPANSION_THRESHOLD) {
reportInteraction('Slow unknown variables expansion', { elapsed });
}
setChanged(0);
return unknownsNetwork;
}, [variables, dashboard]);
const onToggle = (isOpen: boolean) => {
if (isOpen) {
reportInteraction('Unknown variables section expanded');
// make sure we only fetch when opened and variables or dashboard have changed
if (changed > 0) {
getUnknowns();
}
}
setOpen(isOpen);
};
return (
<div className={style.container}>
<CollapsableSection label={<CollapseLabel />} isOpen={open} onToggle={onToggle}>
{loading || !usages ? (
<Stack justifyContent="center" direction="column">
<Stack justifyContent="center">
<span>
<Trans i18nKey="variables.unknown-table.loading">Loading...</Trans>
</span>
<Spinner />
</Stack>
</Stack>
) : usages.length > 0 ? (
<UnknownTable usages={usages} />
) : (
<NoUnknowns />
)}
</CollapsableSection>
</div>
);
}
function CollapseLabel(): ReactElement {
const style = useStyles2(getStyles);
return (
<h5>
<Trans i18nKey="variables.unknown-table.renamed-or-missing-variables">Renamed or missing variables</Trans>
<Tooltip content="Click to expand a list with all variable references that have been renamed or are missing from the dashboard.">
<Icon name="info-circle" className={style.infoIcon} />
</Tooltip>
</h5>
);
}
function NoUnknowns(): ReactElement {
return (
<span>
<Trans i18nKey="variables.unknown-table.no-unknowns">No renamed or missing variables found.</Trans>
</span>
);
}
function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement {
const style = useStyles2(getStyles);
return (
<table className="filter-table filter-table--hover">
<thead>
<tr>
<th>
<Trans i18nKey="variables.unknown-table.variable">Variable</Trans>
</th>
<th colSpan={5} />
</tr>
</thead>
<tbody>
{usages.map((usage) => {
const name = typeof usage.variable === 'string' ? usage.variable : usage.variable.state.name;
return (
<tr key={name}>
<td className={style.firstColumn}>
<span>{name}</span>
</td>
<td className={style.defaultColumn} />
<td className={style.defaultColumn} />
<td className={style.defaultColumn} />
<td className={style.lastColumn}>
<VariableUsagesButton id={name} usages={usages} isAdhoc={false} />
</td>
</tr>
);
})}
</tbody>
</table>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
marginTop: theme.spacing(4),
paddingTop: theme.spacing(4),
}),
infoIcon: css({
marginLeft: theme.spacing(1),
}),
defaultColumn: css({
width: '1%',
}),
firstColumn: css({
width: '1%',
verticalAlign: 'top',
color: theme.colors.text.maxContrast,
}),
lastColumn: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
textAlign: 'right',
}),
});
@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import { reportInteraction } from '@grafana/runtime';
import { IconButton } from '@grafana/ui';
import { NetworkGraphModal } from 'app/features/variables/inspect/NetworkGraphModal';
import { UsagesToNetwork } from './utils';
interface Props {
id: string;
usages: UsagesToNetwork[];
isAdhoc: boolean;
}
export const VariableUsagesButton = ({ id, usages, isAdhoc }: Props) => {
const network = useMemo(
() => usages.find((n) => (typeof n.variable === 'string' ? n.variable : n.variable.state.name) === id),
[usages, id]
);
if (usages.length === 0 || isAdhoc || !network) {
return null;
}
const nodes = network.nodes.map((n) => {
if (n.label.includes(`$${id}`)) {
return { ...n, color: '#FB7E81' };
}
return n;
});
return (
<NetworkGraphModal show={false} title={`Showing usages for: $${id}`} nodes={nodes} edges={network.edges}>
{({ showModal }) => {
return (
<IconButton
onClick={() => {
reportInteraction('Show variable usages');
showModal();
}}
name="code-branch"
tooltip="Show usages"
/>
);
}}
</NetworkGraphModal>
);
};
@@ -1,10 +1,17 @@
import { TestVariable } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema';
import { variableAdapters } from 'app/features/variables/adapters';
import { createCustomVariableAdapter } from 'app/features/variables/custom/adapter';
import { createDataSourceVariableAdapter } from 'app/features/variables/datasource/adapter';
import { createQueryVariableAdapter } from 'app/features/variables/query/adapter';
import { createDependencyEdges, createDependencyNodes } from './utils';
import {
createDependencyEdges,
getVariableName,
createDependencyNodes,
createUsagesNetwork,
transformUsagesToNetwork,
} from './utils';
variableAdapters.setInit(() => [
createDataSourceVariableAdapter(),
@@ -12,6 +19,54 @@ variableAdapters.setInit(() => [
createQueryVariableAdapter(),
]);
const dashboardMock: Dashboard = {
panels: [
{
datasource: {
type: 'prometheus',
uid: 'gdev-prometheus',
},
targets: [
{
datasource: {
type: 'prometheus',
uid: 'gdev-prometheus',
},
disableTextWrap: false,
editorMode: 'code',
expr: 'go_gc_duration_seconds{job=$query0)',
fullMetaSearch: false,
includeNullMetadata: true,
instant: false,
legendFormat: '__auto',
range: true,
refId: 'A',
useBackend: false,
},
{
datasource: {
type: 'prometheus',
uid: 'gdev-prometheus',
},
disableTextWrap: false,
editorMode: 'code',
expr: 'go_gc_duration_seconds{job=$query1)',
fullMetaSearch: false,
includeNullMetadata: true,
instant: false,
legendFormat: '__auto',
range: true,
refId: 'A',
useBackend: false,
},
],
title: 'Panel Title',
type: 'timeseries',
},
],
schemaVersion: 40,
};
describe('createDependencyNodes', () => {
it('should create node for each variable', () => {
const variables = [
@@ -38,3 +93,73 @@ describe('createDependencyEdges', () => {
expect(graphEdges).toContainEqual({ from: 'C', to: 'B' });
});
});
describe('createUsagesNetwork', () => {
it('should create usage network for variables', () => {
const variables = [
new TestVariable({
type: 'query',
name: 'query0',
loading: false,
error: null,
}),
new TestVariable({
type: 'query',
name: 'query1',
loading: false,
error: null,
}),
];
const usagesNetwork = createUsagesNetwork(variables, dashboardMock);
expect(usagesNetwork).toHaveLength(2);
expect(usagesNetwork[0].variable.state.name).toBe('query0');
expect(usagesNetwork[1].variable.state.name).toBe('query1');
});
it('should not create usage network for variables that are not part of the dashboard', () => {
const variables = [
new TestVariable({
type: 'query',
name: 'query3',
loading: false,
error: null,
}),
];
const usagesNetwork = createUsagesNetwork(variables, dashboardMock);
expect(usagesNetwork).toHaveLength(0);
});
});
describe('transformUsagesToNetwork', () => {
it('should transform usages to network', () => {
const variables = [
new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] }),
new TestVariable({ name: 'B', query: 'B.*', value: '', text: '', options: [] }),
];
const usages = [
{ variable: variables[0], tree: { key: 'value' } },
{ variable: variables[1], tree: { key: 'value' } },
];
const network = transformUsagesToNetwork(usages);
expect(network).toHaveLength(2);
expect(network[0].nodes).toContainEqual({ id: 'dashboard', label: 'dashboard' });
expect(network[0].edges).toHaveLength(2);
});
});
describe('getVariableName', () => {
it('should return undefined if no match is found', () => {
expect(getVariableName('no variable here')).toBeUndefined();
});
it('should return undefined if variable matches inherited object prop names', () => {
expect(getVariableName('${toString}')).toBeUndefined();
});
it('should return the variable name if it exists and does not match inherited object prop names', () => {
expect(getVariableName('${myVariable}')).toBe('myVariable');
});
});
@@ -1,5 +1,9 @@
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
import { GraphEdge, GraphNode } from 'app/features/variables/inspect/utils';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { safeStringifyValue } from 'app/core/utils/explore';
import { GraphEdge, GraphNode, getPropsWithVariable } from 'app/features/variables/inspect/utils';
export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g;
export function createDependencyNodes(variables: Array<SceneVariable<SceneVariableState>>): GraphNode[] {
return variables.map((variable) => ({ id: variable.state.name, label: `${variable.state.name}` }));
@@ -26,3 +30,222 @@ export const createDependencyEdges = (variables: Array<SceneVariable<SceneVariab
return edges;
};
export interface VariableUsageTree {
variable: SceneVariable<SceneVariableState>;
tree: unknown;
}
export interface UsagesToNetwork {
/** string when unknown/missing variable otherwise SceneVariable */
variable: string | SceneVariable<SceneVariableState>;
nodes: GraphNode[];
edges: GraphEdge[];
showGraph: boolean;
}
export function createUsagesNetwork(variables: Array<SceneVariable<SceneVariableState>>, dashboard: Dashboard) {
if (!dashboard) {
return [];
}
let usages: VariableUsageTree[] = [];
for (const variable of variables) {
const variableId = variable.state.name;
const props = getPropsWithVariable(variableId, { key: 'model', value: dashboard }, {});
if (Object.keys(props).length) {
usages.push({ variable, tree: props });
}
}
return usages;
}
export function transformUsagesToNetwork(
usages: Array<VariableUsageTree | UnknownVariableUsageTree>
): UsagesToNetwork[] {
const results: UsagesToNetwork[] = [];
for (const usage of usages) {
const { variable, tree } = usage;
const result: UsagesToNetwork = {
variable,
nodes: [{ id: 'dashboard', label: 'dashboard' }],
edges: [],
showGraph: false,
};
results.push(traverseTree(result, { id: 'dashboard', value: tree }));
}
return results;
}
export const traverseTree = (usage: UsagesToNetwork, parent: { id: string; value: unknown }): UsagesToNetwork => {
const { id, value } = parent;
const { nodes, edges } = usage;
if (value && typeof value === 'string') {
const leafId = `${parent.id}-${value}`;
nodes.push({ id: leafId, label: value });
edges.push({ from: leafId, to: id });
return usage;
}
if (isRecord(value)) {
const keys = Object.keys(value);
for (const key of keys) {
const leafId = `${parent.id}-${key}`;
nodes.push({ id: leafId, label: key });
edges.push({ from: leafId, to: id });
usage = traverseTree(usage, { id: leafId, value: value[key] });
}
return usage;
}
return usage;
};
export const getVariableUsages = (variableId: string, usages: VariableUsageTree[]): number => {
const usage = usages.find((usage) => usage.variable.state.name === variableId);
if (!usage) {
return 0;
}
if (isRecord(usage.tree)) {
return countLeaves(usage.tree);
}
return 0;
};
const countLeaves = (object: object): number => {
const total = Object.values(object).reduce<number>((count, value) => {
if (typeof value === 'object') {
return count + countLeaves(value);
}
return count + 1;
}, 0);
return total;
};
export async function getUnknownsNetwork(
variables: Array<SceneVariable<SceneVariableState>>,
dashboard: Dashboard | null
): Promise<UsagesToNetwork[]> {
return new Promise((resolve, reject) => {
// can be an expensive call so we avoid blocking the main thread
setTimeout(() => {
try {
const unknowns = createUnknownsNetwork(variables, dashboard);
resolve(transformUsagesToNetwork(unknowns));
} catch (e) {
reject(e);
}
}, 200);
});
}
type UnknownVariableUsageTree = {
variable: string;
tree: unknown;
};
function createUnknownsNetwork(
variables: Array<SceneVariable<SceneVariableState>>,
dashboard: Dashboard | null
): UnknownVariableUsageTree[] {
if (!dashboard) {
return [];
}
let unknown: UnknownVariableUsageTree[] = [];
const unknownVariables = getUnknownVariableStrings(variables, dashboard);
for (const unknownVariable of unknownVariables) {
const props = getPropsWithVariable(unknownVariable, { key: 'model', value: dashboard }, {});
if (Object.keys(props).length) {
unknown.push({ variable: unknownVariable, tree: props });
}
}
return unknown;
}
export const getUnknownVariableStrings = (variables: Array<SceneVariable<SceneVariableState>>, model: Dashboard) => {
variableRegex.lastIndex = 0;
const unknownVariableNames: string[] = [];
const modelAsString = safeStringifyValue(model, 2);
const matches = modelAsString.match(variableRegex);
if (!matches) {
return unknownVariableNames;
}
for (const match of matches) {
if (!match) {
continue;
}
if (match.indexOf('$__') !== -1) {
// ignore builtin variables
continue;
}
if (match.indexOf('${__') !== -1) {
// ignore builtin variables
continue;
}
if (match.indexOf('$hashKey') !== -1) {
// ignore Angular props
continue;
}
const variableName = getVariableName(match);
if (variables.some((variable) => variable.state.name === variableName)) {
// ignore defined variables
continue;
}
if (unknownVariableNames.find((name) => name === variableName)) {
continue;
}
if (variableName) {
unknownVariableNames.push(variableName);
}
}
return unknownVariableNames;
};
export function getVariableName(expression: string) {
const match = variableRegexExec(expression);
if (!match) {
return undefined;
}
const variableName = match.slice(1).find((match) => match !== undefined);
// ignore variables that match inherited object prop names
if (variableName! in {}) {
return undefined;
}
return variableName;
}
// Helper function since lastIndex is not reset
export const variableRegexExec = (variableString: string) => {
variableRegex.lastIndex = 0;
return variableRegex.exec(variableString);
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more