Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
34be977c99
216 files changed
+10915
-2863
No files matched your search
+12
-22
@@ -1615,21 +1615,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"]
|
||||
"public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
@@ -1682,9 +1669,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
@@ -2434,6 +2418,14 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "7"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "8"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
@@ -2564,9 +2556,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not re-export imported variable (\`./VersionHistoryButtons\`)", "3"],
|
||||
[0, 0, 0, "Do not re-export imported variable (\`./VersionHistoryComparison\`)", "4"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/sharing/ShareExportTab.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/sharing/public-dashboards/ConfigPublicDashboard.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
@@ -5637,7 +5626,8 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/swagger/SwaggerPage.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/swagger/index.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
|
||||
+1
-1
@@ -40,7 +40,6 @@
|
||||
|
||||
/docs/sources/alerting/ @brendamuir
|
||||
/docs/sources/dashboards/ @imatwawana
|
||||
/docs/sources/explore/ @grafana/observability-traces-and-profiling @lwandz13
|
||||
/docs/sources/panels-visualizations/ @imatwawana
|
||||
/docs/sources/release-notes/ @irenerl24 @GrafanaWriter
|
||||
/docs/sources/upgrade-guide/ @imatwawana
|
||||
@@ -673,6 +672,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
# Kind definitions
|
||||
/kinds/dashboard @grafana/dashboards-squad
|
||||
/kinds/ @grafana/grafana-as-code
|
||||
kindsv2/ @grafana/dashboards-squad
|
||||
|
||||
# Kind system and code generation
|
||||
embed.go @grafana/grafana-as-code
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
type: string
|
||||
latest:
|
||||
required: false
|
||||
default: false
|
||||
default: "0"
|
||||
description: Mark this release as latest (`1`) or not (`0`, default)
|
||||
type: string
|
||||
dry_run:
|
||||
@@ -23,6 +23,7 @@ on:
|
||||
type: string
|
||||
latest:
|
||||
required: false
|
||||
default: "0"
|
||||
description: Mark this release as latest (`1`) or not (`0`, default)
|
||||
type: string
|
||||
dry_run:
|
||||
|
||||
@@ -8,10 +8,11 @@ on:
|
||||
dry_run:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
version:
|
||||
required: true
|
||||
latest:
|
||||
type: bool
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types:
|
||||
@@ -30,17 +31,18 @@ jobs:
|
||||
latest: ${{ steps.output.outputs.latest }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The github-release action expects a `LATEST` value of a string of either '1' or '0'
|
||||
- if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
echo setting up GITHUB_ENV for ${{ github.event_name }}
|
||||
echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV
|
||||
echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV
|
||||
echo "LATEST=${{ inputs.latest }}" >> $GITHUB_ENV
|
||||
echo "LATEST=${{ inputs.latest && '1' || '0' }}" >> $GITHUB_ENV
|
||||
- if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') }}
|
||||
run: |
|
||||
echo "VERSION=$(echo ${{ github.head_ref }} | sed -e 's/release\/.*\///g')" >> $GITHUB_ENV
|
||||
echo "DRY_RUN=${{ contains(github.event.pull_request.labels.*.name, 'release/dry-run') }}" >> $GITHUB_ENV
|
||||
echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') }}" >> $GITHUB_ENV
|
||||
echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') && '1' || '0' }}" >> $GITHUB_ENV
|
||||
- id: output
|
||||
run: |
|
||||
echo "dry_run: $DRY_RUN"
|
||||
|
||||
+111
@@ -1,3 +1,114 @@
|
||||
<!-- 11.3.1 START -->
|
||||
|
||||
# 11.3.1 (2024-11-19)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96135](https://github.com/grafana/grafana/pull/96135), [@fayzal-g](https://github.com/fayzal-g)
|
||||
- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96345](https://github.com/grafana/grafana/pull/96345), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **User:** Check SignedInUser OrgID in RevokeInvite [#95490](https://github.com/grafana/grafana/pull/95490), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Alerting:** Fix escaping of silence matchers in utf8 mode [#95347](https://github.com/grafana/grafana/pull/95347), [@tomratcliffe](https://github.com/tomratcliffe)
|
||||
- **Alerting:** Fix overflow for long receiver names [#95133](https://github.com/grafana/grafana/pull/95133), [@gillesdemey](https://github.com/gillesdemey)
|
||||
- **Alerting:** Fix saving advanced mode toggle state in the alert rule editor [#95981](https://github.com/grafana/grafana/pull/95981), [@alexander-akhmetov](https://github.com/alexander-akhmetov)
|
||||
- **Alerting:** Fix setting datasource uid, when datasource is string in old version [#96273](https://github.com/grafana/grafana/pull/96273), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
|
||||
- **Alerting:** Force refetch prom rules when refreshing panel [#96125](https://github.com/grafana/grafana/pull/96125), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
|
||||
- **Anonymous User:** Adds validator service for anonymous users [#94994](https://github.com/grafana/grafana/pull/94994), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
|
||||
- **Azure Monitor:** Support metric namespaces fallback [#95155](https://github.com/grafana/grafana/pull/95155), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Fix duplicated traces in multi-resource trace query [#95247](https://github.com/grafana/grafana/pull/95247), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Handle namespace request rejection [#95909](https://github.com/grafana/grafana/pull/95909), [@aangelisc](https://github.com/aangelisc)
|
||||
- **CloudWatch:** Interpolate region in log context query [#94990](https://github.com/grafana/grafana/pull/94990), [@iwysiu](https://github.com/iwysiu)
|
||||
- **Dashboard datasource:** Return annotations as series when query topic is "annotations" [#95971](https://github.com/grafana/grafana/pull/95971), [@kaydelaney](https://github.com/kaydelaney)
|
||||
- **Dashboard:** Append orgId to URL [#95963](https://github.com/grafana/grafana/pull/95963), [@bfmatei](https://github.com/bfmatei)
|
||||
- **Dashboards:** Fixes performance issue expanding a row [#95321](https://github.com/grafana/grafana/pull/95321), [@torkelo](https://github.com/torkelo)
|
||||
- **Flame Graph:** Fix crash when it receives empty data [#96211](https://github.com/grafana/grafana/pull/96211), [@yincongcyincong](https://github.com/yincongcyincong)
|
||||
- **Folders:** Add admin permissions upon creation of a folder w. SA [#95365](https://github.com/grafana/grafana/pull/95365), [@eleijonmarck](https://github.com/eleijonmarck)
|
||||
- **Folders:** Don't show error pop-up if the user can't fetch the root folder [#95600](https://github.com/grafana/grafana/pull/95600), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95232](https://github.com/grafana/grafana/pull/95232), [@kalleep](https://github.com/kalleep)
|
||||
- **ServerLock:** Fix pg concurrency/locking issue [#95935](https://github.com/grafana/grafana/pull/95935), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
- **Service Accounts:** Run service account creation in transaction [#94803](https://github.com/grafana/grafana/pull/94803), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
- **Table:** Fix text wrapping applying to wrong field [#95425](https://github.com/grafana/grafana/pull/95425), [@codeincarnate](https://github.com/codeincarnate)
|
||||
- **Unified Storage:** Use ssl_mode instead of sslmode [#95662](https://github.com/grafana/grafana/pull/95662), [@chaudyg](https://github.com/chaudyg)
|
||||
|
||||
<!-- 11.3.1 END -->
|
||||
<!-- 11.2.4 START -->
|
||||
|
||||
# 11.2.4 (2024-11-19)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96133](https://github.com/grafana/grafana/pull/96133), [@fayzal-g](https://github.com/fayzal-g)
|
||||
- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96344](https://github.com/grafana/grafana/pull/96344), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **Transformations:** Add 'transpose' transform [#95076](https://github.com/grafana/grafana/pull/95076), [@jmdane](https://github.com/jmdane)
|
||||
- **User:** Check SignedInUser OrgID in RevokeInvite [#95489](https://github.com/grafana/grafana/pull/95489), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Alerting:** Force refetch prom rules when refreshing panel [#96124](https://github.com/grafana/grafana/pull/96124), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
|
||||
- **Anonymous User:** Adds validator service for anonymous users [#94993](https://github.com/grafana/grafana/pull/94993), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
|
||||
- **Azure Monitor:** Support metric namespaces fallback [#95154](https://github.com/grafana/grafana/pull/95154), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Fix duplicated traces in multi-resource trace query [#95246](https://github.com/grafana/grafana/pull/95246), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Handle namespace request rejection [#95908](https://github.com/grafana/grafana/pull/95908), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Folders:** Add admin permissions upon creation of a folder w. SA [#95416](https://github.com/grafana/grafana/pull/95416), [@eleijonmarck](https://github.com/eleijonmarck)
|
||||
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95231](https://github.com/grafana/grafana/pull/95231), [@kalleep](https://github.com/kalleep)
|
||||
- **ServerLock:** Fix pg concurrency/locking issue [#95934](https://github.com/grafana/grafana/pull/95934), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94959](https://github.com/grafana/grafana/pull/94959), [@samjewell](https://github.com/samjewell)
|
||||
|
||||
<!-- 11.2.4 END -->
|
||||
<!-- 11.1.9 START -->
|
||||
|
||||
# 11.1.9 (2024-11-19)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96132](https://github.com/grafana/grafana/pull/96132), [@fayzal-g](https://github.com/fayzal-g)
|
||||
- **User:** Check SignedInUser OrgID in RevokeInvite [#95488](https://github.com/grafana/grafana/pull/95488), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Alerting:** Force refetch prom rules when refreshing panel [#96123](https://github.com/grafana/grafana/pull/96123), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
|
||||
- **Anonymous User:** Adds validator service for anonymous users [#94992](https://github.com/grafana/grafana/pull/94992), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
|
||||
- **Azure Monitor:** Support metric namespaces fallback [#95153](https://github.com/grafana/grafana/pull/95153), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Fix duplicated traces in multi-resource trace query [#95245](https://github.com/grafana/grafana/pull/95245), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Handle namespace request rejection [#95907](https://github.com/grafana/grafana/pull/95907), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95230](https://github.com/grafana/grafana/pull/95230), [@kalleep](https://github.com/kalleep)
|
||||
- **Prometheus:** Fix interpolating adhoc filters with template variables [#95977](https://github.com/grafana/grafana/pull/95977), [@cazeaux](https://github.com/cazeaux)
|
||||
- **ServerLock:** Fix pg concurrency/locking issue [#95933](https://github.com/grafana/grafana/pull/95933), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94969](https://github.com/grafana/grafana/pull/94969), [@scottlepp](https://github.com/scottlepp)
|
||||
|
||||
<!-- 11.1.9 END -->
|
||||
<!-- 11.0.8 START -->
|
||||
|
||||
# 11.0.8 (2024-11-19)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96131](https://github.com/grafana/grafana/pull/96131), [@fayzal-g](https://github.com/fayzal-g)
|
||||
- **User:** Check SignedInUser OrgID in RevokeInvite [#95487](https://github.com/grafana/grafana/pull/95487), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Anonymous User:** Adds validator service for anonymous users [#95151](https://github.com/grafana/grafana/pull/95151), [@leandro-deveikis](https://github.com/leandro-deveikis)
|
||||
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
|
||||
- **Azure Monitor:** Support metric namespaces fallback [#95152](https://github.com/grafana/grafana/pull/95152), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Fix duplicated traces in multi-resource trace query [#95244](https://github.com/grafana/grafana/pull/95244), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Azure:** Handle namespace request rejection [#95906](https://github.com/grafana/grafana/pull/95906), [@aangelisc](https://github.com/aangelisc)
|
||||
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95229](https://github.com/grafana/grafana/pull/95229), [@kalleep](https://github.com/kalleep)
|
||||
- **Prometheus:** Fix interpolating adhoc filters with template variables [#95986](https://github.com/grafana/grafana/pull/95986), [@cazeaux](https://github.com/cazeaux)
|
||||
- **ServerLock:** Fix pg concurrency/locking issue [#95932](https://github.com/grafana/grafana/pull/95932), [@mgyongyosi](https://github.com/mgyongyosi)
|
||||
- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94971](https://github.com/grafana/grafana/pull/94971), [@samjewell](https://github.com/samjewell)
|
||||
|
||||
<!-- 11.0.8 END -->
|
||||
<!-- 10.4.13 START -->
|
||||
|
||||
# 10.4.13 (2024-11-19)
|
||||
|
||||
<!-- 10.4.13 END -->
|
||||
<!-- 11.3.0+security-01 START -->
|
||||
|
||||
# 11.3.0+security-01 (2024-11-12)
|
||||
|
||||
+2
-1
@@ -30,7 +30,7 @@ COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./
|
||||
COPY scripts scripts
|
||||
COPY emails emails
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_ENV=production
|
||||
RUN yarn build
|
||||
|
||||
FROM ${GO_IMAGE} as go-builder
|
||||
@@ -77,6 +77,7 @@ RUN if [[ "$BINGO" = "true" ]]; then \
|
||||
COPY embed.go Makefile build.go package.json ./
|
||||
COPY cue.mod cue.mod
|
||||
COPY kinds kinds
|
||||
COPY kindsv2 kindsv2
|
||||
COPY local local
|
||||
COPY packages/grafana-schema packages/grafana-schema
|
||||
COPY public/app/plugins public/app/plugins
|
||||
|
||||
@@ -146,6 +146,11 @@ gen-cue: ## Do all CUE/Thema code generation
|
||||
go generate ./kinds/gen.go
|
||||
go generate ./public/app/plugins/gen.go
|
||||
|
||||
.PHONY: gen-cuev2
|
||||
gen-cuev2: ## Do all CUE code generation
|
||||
@echo "generate code from .cue files (v2)"
|
||||
go generate ./kindsv2/gen.go
|
||||
|
||||
.PHONY: gen-feature-toggles
|
||||
gen-feature-toggles:
|
||||
## First go test run fails because it will re-generate the feature toggles.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM ubuntu:xenial
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y install collectd curl python-pip
|
||||
|
||||
@@ -2,18 +2,18 @@ FROM centos:6.6
|
||||
|
||||
RUN yum install -y initscripts curl tar gcc libc6-dev git
|
||||
|
||||
ENV GOLANG_VERSION 1.4.2
|
||||
ENV GOLANG_VERSION=1.4.2
|
||||
|
||||
RUN curl -sSL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz \
|
||||
| tar -v -C /usr/src -xz
|
||||
|
||||
RUN cd /usr/src/go/src && ./make.bash --no-clean 2>&1
|
||||
|
||||
ENV PATH /usr/src/go/bin:$PATH
|
||||
ENV PATH=/usr/src/go/bin:$PATH
|
||||
|
||||
RUN mkdir -p /go/src /go/bin && chmod -R 777 /go
|
||||
ENV GOPATH /go
|
||||
ENV PATH /go/bin:$PATH
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=/go/bin:$PATH
|
||||
|
||||
WORKDIR /go/src/github.com/grafana/grafana
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ WORKDIR /go/src/webhook
|
||||
RUN mkdir /tmp/logs
|
||||
RUN go build -o /bin webhook-listener.go
|
||||
|
||||
ENV PORT 8080
|
||||
ENV PORT=8080
|
||||
|
||||
ENTRYPOINT [ "/bin/webhook-listener" ]
|
||||
@@ -114,9 +114,13 @@ Each derived field consists of the following:
|
||||
|
||||
- **Type** - Defines the type of the derived field. It can be either:
|
||||
|
||||
- **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group.
|
||||
{{% admonition type="caution" %}}
|
||||
Using complex regular expressions in either type can impact browser performance when processing large volumes of logs. Consider using simpler patterns when possible.
|
||||
{{% /admonition %}}
|
||||
|
||||
- **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. The label's value will be used as the value of the derived field.
|
||||
- **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group.
|
||||
|
||||
- **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. When using this type, the input will match as a regular expression against label keys, allowing you to match variations like `traceid` and `trace_id` with a single regex pattern like `trace[_]?id`. The value of the matched label will be used as the value of the derived field.
|
||||
|
||||
- **URL/query** Sets the full link URL if the link is external, or a query for the target data source if the link is internal. You can interpolate the value from the field with the `${__value.raw}` macro.
|
||||
|
||||
|
||||
@@ -163,17 +163,7 @@ For details about these metrics, refer to [Internal Grafana metrics](ref:set-up-
|
||||
|
||||
## Amazon Managed Service for Prometheus
|
||||
|
||||
The Prometheus data source works with Amazon Managed Service for Prometheus.
|
||||
|
||||
If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain.
|
||||
|
||||
For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
|
||||
|
||||
### AWS Signature Version 4 authentication
|
||||
|
||||
To connect the Prometheus data source to Amazon Managed Service for Prometheus using SigV4 authentication, refer to the AWS guide to [Set up Grafana open source or Grafana Enterprise for use with AMP](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-standalone-grafana.html).
|
||||
|
||||
If you run Grafana in an Amazon EKS cluster, follow the AWS guide to [Query using Grafana running in an Amazon EKS cluster](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-grafana-7.3.html).
|
||||
The Prometheus data source with Amazon Managed Service for Prometheus is deprecated. Please use the [Amazon Managed service for Prometheus data source](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/). Migrations steps are detailed in the link.
|
||||
|
||||
## Azure authentication settings
|
||||
|
||||
|
||||
@@ -34,12 +34,14 @@ Grafana Enterprise includes integrations with more ways to authenticate your use
|
||||
Supported auth providers:
|
||||
|
||||
- [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
|
||||
- [Azure AD OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/azuread#team-sync-enterprise-only" >}})
|
||||
- [GitHub OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/github#configure-team-synchronization" >}})
|
||||
- [GitLab OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/gitlab#configure-team-synchronization" >}})
|
||||
- [LDAP]({{< relref "../setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}})
|
||||
- [Okta]({{< relref "../setup-grafana/configure-security/configure-authentication/okta#configure-team-synchronization-enterprise-only" >}})
|
||||
- [SAML]({{< relref "../setup-grafana/configure-security/configure-authentication/saml#configure-team-sync" >}})
|
||||
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
|
||||
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/generic-oauth#configure-group-synchronization" >}})
|
||||
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/github#configure-group-synchronization)
|
||||
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/gitlab#configure-group-synchronization)
|
||||
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/google#configure-group-synchronization)
|
||||
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
|
||||
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
|
||||
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/saml#configure-group-synchronization)
|
||||
|
||||
### Enhanced LDAP integration
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ Use this transformation to select a source of data and extract content from it i
|
||||
- **Format** - Choose one of the following:
|
||||
- **JSON** - Parse JSON content from the source.
|
||||
- **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source.
|
||||
- **RegExp** - Parse content using a regular expression with [named capturing group(s)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group).
|
||||
- **Auto** - Discover fields automatically.
|
||||
- **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization.
|
||||
- **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output.
|
||||
|
||||
@@ -25,7 +25,7 @@ Node graphs are useful when you need to visualize elements that are related to e
|
||||
|
||||
Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams.
|
||||
|
||||

|
||||

|
||||
|
||||
The appearance of nodes and edges can also be customized in several ways including color, borders, and line style.
|
||||
|
||||
@@ -79,36 +79,7 @@ Similar to the nodes dataset, the edges dataset needs one unique ID field for ea
|
||||
|
||||
If a node lacks edge connections, it’s displayed on its own outside of the network.
|
||||
|
||||
## Panel options
|
||||
|
||||
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Node graph options
|
||||
|
||||
Use the following options to refine your node graph visualization.
|
||||
|
||||
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
|
||||
|
||||
## Nodes options
|
||||
|
||||
The **Nodes** options section provides configurations for node behaviors.
|
||||
|
||||
- **Main stat unit** - Choose which unit the main stat displays in the graph's nodes.
|
||||
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's nodes.
|
||||
- **Arc sections** - Configure which fields define the size of the colored circle around the node and select a color for each. You can add multiple fields.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Defining arc sections overrides the automatic detection of `arc__*` and `color` fields described in the **Optional fields** section of [Nodes data frame structure](#nodes-data-frame-structure).
|
||||
{{< /admonition >}}
|
||||
|
||||
## Edges options
|
||||
|
||||
The **Edges** options section provides configurations for node edges behaviors.
|
||||
|
||||
- **Main stat unit** - Choose which unit the main stat displays in the graph's edges.
|
||||
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's edges.
|
||||
|
||||
## Data requirements
|
||||
### Data requirements
|
||||
|
||||
A node graph requires a specific shape of the data to be able to display its nodes and edges. This means not every data source or query can be visualized with this graph. If you want to use this as a data source developer see the section about data API.
|
||||
|
||||
@@ -119,7 +90,7 @@ A node graph consists of _nodes_ and _edges_.
|
||||
|
||||
Both nodes and edges can have associated metadata or statistics. The data source defines what information and values is shown, so different data sources can show different type of values or not show some values.
|
||||
|
||||
### Nodes
|
||||
#### Nodes
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Node graphs can show only 1,500 nodes. If this limit is crossed a warning will be visible in upper right corner, and some nodes will be hidden. You can expand hidden parts of the graph by clicking on the "Hidden nodes" markers in the graph.
|
||||
@@ -131,47 +102,88 @@ For example, you can have the percentage of errors represented by a red portion
|
||||
Additional details can be displayed in a context menu which is displayed when you click on the node.
|
||||
There also can be additional links in the context menu that can target either other parts of Grafana or any external link.
|
||||
|
||||

|
||||

|
||||
|
||||
### Edges
|
||||
#### Edges
|
||||
|
||||
Edges can also show statistics when you hover over the edge. Similar to nodes, you can open a context menu with additional details and links by clicking on the edge.
|
||||
|
||||
The first data source supporting this visualization is X-Ray data source for its Service map feature. For more information, refer to the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource).
|
||||
|
||||
## Configuration options
|
||||
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
### Panel options
|
||||
|
||||
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
### Node graph options
|
||||
|
||||
Use the following options to refine your node graph visualization.
|
||||
|
||||
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
|
||||
|
||||
### Nodes options
|
||||
|
||||
The **Nodes** options section provides configurations for node behaviors.
|
||||
|
||||
- **Main stat unit** - Choose which unit the main stat displays in the graph's nodes.
|
||||
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's nodes.
|
||||
- **Arc sections** - Configure which fields define the size of the colored circle around the node and select a color for each. You can add multiple fields.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Defining arc sections overrides the automatic detection of `arc__*` and `color` fields described in the **Optional fields** section of [Nodes data frame structure](#nodes-data-frame-structure).
|
||||
{{< /admonition >}}
|
||||
|
||||
### Edges options
|
||||
|
||||
The **Edges** options section provides configurations for node edges behaviors.
|
||||
|
||||
- **Main stat unit** - Choose which unit the main stat displays in the graph's edges.
|
||||
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's edges.
|
||||
|
||||
### Data links
|
||||
|
||||
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
In node graphs, some data fields may have pre-configured data links. To add a different data link in those cases, use a [field override](#field-overrides).
|
||||
|
||||
### Field overrides
|
||||
|
||||
{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Navigating the node graph
|
||||
|
||||
You can pan and zoom in or out a node graph.
|
||||
You can use pan, zoom, and other functions to navigate a node graph.
|
||||
|
||||
### Pan
|
||||
|
||||
You can pan the view by clicking outside any node or edge and dragging your mouse.
|
||||
|
||||
### Zoom in or out
|
||||
### Zoom
|
||||
|
||||
Use the buttons in the upper left corner or use the mouse wheel, touchpad scroll, together with either Ctrl or Cmd key to zoom in or out.
|
||||
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
|
||||
|
||||
### Explore hidden nodes
|
||||
### Hidden nodes
|
||||
|
||||
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
|
||||
|
||||

|
||||

|
||||
|
||||
### Grid view
|
||||
|
||||
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
|
||||
|
||||
<!-- Screenshot from v11.2 -->
|
||||
|
||||

|
||||

|
||||
|
||||
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
|
||||
|
||||

|
||||

|
||||
|
||||
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
|
||||
|
||||

|
||||

|
||||
|
||||
## Data API
|
||||
|
||||
@@ -217,15 +229,15 @@ Required fields:
|
||||
|
||||
Optional fields:
|
||||
|
||||
| Field name | Type | Description |
|
||||
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| title | string | Name of the node visible in just under the node. |
|
||||
| subtitle | string | Additional, name, type or other identifier shown under the title. |
|
||||
| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
|
||||
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
|
||||
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
||||
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
||||
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behaviour depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
|
||||
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana built in icons are allowed (see the available icons [here](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)). |
|
||||
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
|
||||
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
|
||||
| Field name | Type | Description |
|
||||
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| title | string | Name of the node visible in just under the node. |
|
||||
| subtitle | string | Additional, name, type or other identifier shown under the title. |
|
||||
| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
|
||||
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
|
||||
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
||||
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
||||
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
|
||||
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)) are allowed. |
|
||||
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
|
||||
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
|
||||
@@ -78,6 +78,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes |
|
||||
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
|
||||
| `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes |
|
||||
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes |
|
||||
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
|
||||
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
|
||||
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
|
||||
@@ -115,7 +116,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector |
|
||||
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars |
|
||||
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP |
|
||||
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one |
|
||||
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login |
|
||||
| `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation |
|
||||
|
||||
|
||||
+4
-4
@@ -353,14 +353,14 @@ This setting is ignored if multiple auth providers are configured to use auto lo
|
||||
auto_login = true
|
||||
```
|
||||
|
||||
### Team Sync (Enterprise only)
|
||||
### Group sync (Enterprise only)
|
||||
|
||||
With Team Sync you can map your Entra ID groups to teams in Grafana so that your users will automatically be added to
|
||||
the correct teams.
|
||||
With group sync you can map your Entra ID groups to teams and roles in Grafana. This allows users to automatically be added to
|
||||
the correct teams and be granted the correct roles in Grafana.
|
||||
|
||||
You can reference Entra ID groups by group object ID, like `8bab1c86-8fba-33e5-2089-1d1c80ec267d`.
|
||||
|
||||
To learn more, refer to the [Team Sync]({{< relref "../../configure-team-sync" >}}) documentation.
|
||||
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
|
||||
|
||||
## Common troubleshooting
|
||||
|
||||
|
||||
+6
-8
@@ -28,18 +28,16 @@ The enhanced LDAP integration adds additional functionality on top of the [LDAP
|
||||
|
||||
> To control user access with role-based permissions, refer to [role-based access control]({{< relref "../../../../administration/roles-and-permissions/access-control" >}}).
|
||||
|
||||
## LDAP group synchronization for teams
|
||||
## LDAP group synchronization
|
||||
|
||||
With enhanced LDAP integration, you can set up synchronization between LDAP groups and teams. This enables LDAP users that are members
|
||||
of certain LDAP groups to automatically be added or removed as members to certain teams in Grafana.
|
||||
With enhanced LDAP integration, you can set up synchronization between LDAP groups and Grafana teams and roles. This enables users that are members
|
||||
of certain LDAP groups to automatically be added to teams and gain roles in Grafana.
|
||||
|
||||
The below example shows an LDAP group member mapped to a Grafana team.
|
||||
|
||||

|
||||
|
||||
Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized from LDAP in the team members list, see `LDAP` label in screenshot.
|
||||
This mechanism allows Grafana to remove an existing synchronized user from a team when its LDAP group membership changes. This mechanism also allows you to manually add
|
||||
a user as member of a team, and it will not be removed when the user signs in. This gives you flexibility to combine LDAP group memberships and Grafana team memberships.
|
||||
|
||||
[Learn more about team sync.]({{< relref "../../configure-team-sync" >}})
|
||||
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
|
||||
+9
-7
@@ -127,7 +127,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic
|
||||
c. Enable the refresh token on the provider if required.
|
||||
|
||||
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
|
||||
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
|
||||
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}).
|
||||
1. Restart Grafana.
|
||||
|
||||
You should now see a Generic OAuth login button on the login page and be able to log in or sign up with your OAuth2 provider.
|
||||
@@ -328,19 +328,21 @@ org_attribute_path = info.roles
|
||||
org_mapping = org_foo:org_foo:Viewer org_bar:org_bar:Editor *:org_baz:Editor
|
||||
```
|
||||
|
||||
### Configure team synchronization
|
||||
## Configure group synchronization
|
||||
|
||||
> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can link your OAuth2 groups to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
Teams for each user are synchronized when the user logs in.
|
||||
Grafana supports synchronization of OAuth2 groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or automatically granting them the mapped roles.
|
||||
Teams and roles get synchronized when the user logs in.
|
||||
|
||||
Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`.
|
||||
For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options]({{< relref "#configuration-options" >}}).
|
||||
|
||||
To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}).
|
||||
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
|
||||
|
||||
#### Team synchronization example
|
||||
#### Group attribute synchronization example
|
||||
|
||||
Configuration:
|
||||
|
||||
|
||||
+6
-6
@@ -110,7 +110,7 @@ To configure GitHub authentication with Grafana, follow these steps:
|
||||
Review the list of other GitHub [configuration options]({{< relref "#configuration-options" >}}) and complete them, as necessary.
|
||||
|
||||
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
|
||||
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
|
||||
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}).
|
||||
1. Restart Grafana.
|
||||
|
||||
You should now see a GitHub login button on the login page and be able to log in or sign up with your GitHub accounts.
|
||||
@@ -216,14 +216,14 @@ allowed_domains = mycompany.com mycompany.org
|
||||
role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer'
|
||||
```
|
||||
|
||||
## Configure team synchronization
|
||||
## Configure group synchronization
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and Grafana Cloud.
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
Teams for each user are synchronized when the user logs in.
|
||||
Grafana supports synchronization of teams from your GitHub organization with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
|
||||
Teams and roles get synchronized when the user logs in.
|
||||
|
||||
GitHub teams can be referenced in two ways:
|
||||
|
||||
@@ -232,7 +232,7 @@ GitHub teams can be referenced in two ways:
|
||||
|
||||
Examples: `https://github.com/orgs/grafana/teams/developers` or `@grafana/developers`.
|
||||
|
||||
To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}).
|
||||
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
|
||||
|
||||
## Configuration options
|
||||
|
||||
|
||||
+8
-8
@@ -119,7 +119,7 @@ To configure GitLab authentication with Grafana, follow these steps:
|
||||
a. Set `use_refresh_token` to `true` in `[auth.gitlab]` section in Grafana configuration file.
|
||||
|
||||
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
|
||||
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
|
||||
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}).
|
||||
1. Restart Grafana.
|
||||
|
||||
You should now see a GitLab login button on the login page and be able to log in or sign up with your GitLab accounts.
|
||||
@@ -242,20 +242,20 @@ use_pkce = true
|
||||
use_refresh_token = true
|
||||
```
|
||||
|
||||
## Configure team synchronization
|
||||
## Configure group synchronization
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{% /admonition %}}
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
Teams for each user are synchronized when the user logs in.
|
||||
Grafana supports synchronization of GitLab groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
|
||||
Teams and roles get synchronized when the user logs in.
|
||||
|
||||
GitLab groups are referenced by the group name. For example, `developers`. To reference a subgroup `frontend`, use `developers/frontend`.
|
||||
Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters.
|
||||
Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup.
|
||||
|
||||
To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}).
|
||||
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
|
||||
|
||||
## Configuration options
|
||||
|
||||
|
||||
+10
-5
@@ -165,9 +165,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo
|
||||
auto_login = true
|
||||
```
|
||||
|
||||
### Configure team sync for Google OAuth
|
||||
### Configure group synchronization
|
||||
|
||||
With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example.
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
Grafana supports syncing users to teams and roles based on their Google groups.
|
||||
|
||||
To set up group sync for Google OAuth:
|
||||
|
||||
1. Enable the Google Cloud Identity API on your [organization's dashboard](https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com/).
|
||||
|
||||
@@ -181,10 +187,9 @@ With team sync, you can easily add users to teams by utilizing their Google grou
|
||||
scopes = openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly
|
||||
```
|
||||
|
||||
1. Configure team sync in your Grafana team's `External group sync` tab.
|
||||
The external group ID for a Google group is the group's email address, such as `dev@grafana.com`.
|
||||
The external group ID for a Google group is the group's email address, such as `dev@grafana.com`.
|
||||
|
||||
To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}).
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
|
||||
#### Configure allowed groups
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ This method of authentication is useful for integrating with other systems that
|
||||
use JWKS but can't directly integrate with Grafana or if you want to use pass-through
|
||||
authentication in an app embedding Grafana.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Grafana does not currently support refresh tokens.
|
||||
{{% /admonition %}}
|
||||
|
||||
## Enable JWT
|
||||
|
||||
To use JWT authentication:
|
||||
@@ -183,7 +187,8 @@ key_id = my-key-id
|
||||
|
||||
By default, only `"exp"`, `"nbf"` and `"iat"` claims are validated.
|
||||
|
||||
You might also want to validate that other claims are really what you expect them to be.
|
||||
Consider validating that other claims match your expectations by using the `expect_claims` configuration option.
|
||||
Token claims must match exactly the values set here.
|
||||
|
||||
```ini
|
||||
# This can be seen as a required "subset" of a JWT Claims Set.
|
||||
|
||||
+12
-8
@@ -94,7 +94,7 @@ roles
|
||||
```
|
||||
|
||||
{{% admonition type="warning" %}}
|
||||
these scopes do not add group claims to the id_token. Without group claims, teamsync will not work. Teamsync is covered further down in this document.
|
||||
These scopes do not add group claims to the id_token. Without group claims, group synchronization will not work. Group synchronization is covered further down in this document.
|
||||
{{% /admonition %}}
|
||||
|
||||
3. For role mapping to work with the example configuration above,
|
||||
@@ -106,16 +106,18 @@ editor
|
||||
viewer
|
||||
```
|
||||
|
||||
## Teamsync
|
||||
## Group synchronization
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{% /admonition %}}
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
[Teamsync]({{< relref "../../configure-team-sync" >}}) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership.
|
||||
By using group synchronization, you can link your Keycloak groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
|
||||
This is useful if you want to give your users access to specific resources based on their group membership.
|
||||
Teams and roles get synchronized when the user logs in.
|
||||
|
||||
To enable teamsync, you need to add a `groups` mapper to the client configuration in Keycloak.
|
||||
This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams in Grafana.
|
||||
To enable group synchronization, you need to add a `groups` mapper to the client configuration in Keycloak.
|
||||
This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams and roles in Grafana.
|
||||
|
||||
1. In the client configuration, head to `Mappers` and create a mapper with the following settings:
|
||||
|
||||
@@ -141,6 +143,8 @@ If you use nested groups containing special characters such as quotes or colons,
|
||||
groups_attribute_path = reverse("Global:department")
|
||||
```
|
||||
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
|
||||
## Enable Single Logout
|
||||
|
||||
To enable Single Logout, you need to add the following option to the configuration of Grafana:
|
||||
|
||||
+8
-10
@@ -169,7 +169,7 @@ To integrate your Okta OIDC provider with Grafana using our Okta OIDC integratio
|
||||
|
||||
1. Optional: [Configure a refresh token]({{< relref "#configure-a-refresh-token" >}}).
|
||||
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
|
||||
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization-enterprise-only" >}}).
|
||||
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization-enterprise-only" >}}).
|
||||
1. Restart Grafana.
|
||||
|
||||
You should now see a Okta OIDC login button on the login page and be able to log in or sign up with your OIDC provider.
|
||||
@@ -243,20 +243,18 @@ org_attribute_path = groups
|
||||
org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"]
|
||||
```
|
||||
|
||||
### Configure team synchronization (Enterprise only)
|
||||
### Configure group synchronization (Enterprise only)
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud]({{< relref "../../../../introduction/grafana-cloud" >}}).
|
||||
{{% /admonition %}}
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
|
||||
Map your Okta groups to teams in Grafana so that your users will automatically be added to
|
||||
the correct teams.
|
||||
By using group synchronization, you can link your Okta groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
|
||||
Teams and roles get synchronized when the user logs in.
|
||||
|
||||
Okta groups can be referenced by group names, like `Admins` or `Editors`.
|
||||
|
||||
To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}).
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
|
||||
## Configuration options
|
||||
|
||||
|
||||
+2
-2
@@ -113,8 +113,8 @@ Sign in to Grafana and navigate to **Administration > Authentication > Configure
|
||||
|
||||
1. If you wish to [map user information from SAML assertions]({{< relref "../saml#assertion-mapping" >}}), complete the **Assertion attributes mappings** section.
|
||||
|
||||
You also need to configure the **Groups attribute** field if you want to use team sync. Team sync automatically maps users to Grafana teams based on their SAML group membership.
|
||||
Learn more about [team sync]({{< relref "../../configure-team-sync" >}}) and [configuring team sync for SAML]({{< relref "../saml#configure-team-sync" >}}).
|
||||
You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership.
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
|
||||
1. If you want to automatically assign users' roles based on their SAML roles, complete the **Role mapping** section.
|
||||
|
||||
|
||||
+8
-5
@@ -410,12 +410,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo
|
||||
auto_login = true
|
||||
```
|
||||
|
||||
### Configure team sync
|
||||
### Configure group synchronization
|
||||
|
||||
To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab.
|
||||
Group synchronization allows you to map user groups from an identity provider to Grafana teams and roles.
|
||||
|
||||
To use SAML group synchronization, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups.
|
||||
Then Grafana will use attribute values extracted from SAML assertion to add user to Grafana teams and grant them roles.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Teamsync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature.
|
||||
Team sync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature.
|
||||
{{% /admonition %}}
|
||||
|
||||
Given the following partial SAML assertion:
|
||||
@@ -445,12 +448,12 @@ The configuration would look like this:
|
||||
assertion_attribute_groups = groups
|
||||
```
|
||||
|
||||
The following `External Group ID`s would be valid for input in the desired team's _External group sync_ tab:
|
||||
The following `External Group ID`s would be valid for configuring team sync or role sync in Grafana:
|
||||
|
||||
- `admins_group`
|
||||
- `division_1`
|
||||
|
||||
[Learn more about Team Sync]({{< relref "../../configure-team-sync" >}})
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
|
||||
### Configure role sync
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ If a group mapping is removed, the role assignment to users for the group mappin
|
||||
|
||||
Role mappings are tied to organizations, so you can have different mappings for different organizations.
|
||||
|
||||
## Supported providers
|
||||
|
||||
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
|
||||
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
|
||||
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization)
|
||||
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization)
|
||||
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization)
|
||||
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
|
||||
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
|
||||
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization)
|
||||
|
||||
## Create role mappings for a new group
|
||||
|
||||
For information about creating group mappings via the API, refer to [create group mappings reference](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/group_attribute_sync#create-group-mappings).
|
||||
|
||||
@@ -28,14 +28,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea
|
||||
## Supported providers
|
||||
|
||||
- [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
|
||||
- [Azure AD]({{< relref "./configure-authentication/azuread#team-sync-enterprise-only" >}})
|
||||
- [Generic OAuth integration]({{< relref "./configure-authentication/generic-oauth#configure-team-synchronization" >}})
|
||||
- [GitHub OAuth]({{< relref "./configure-authentication/github#configure-team-synchronization" >}})
|
||||
- [GitLab OAuth]({{< relref "./configure-authentication/gitlab#configure-team-synchronization" >}})
|
||||
- [Google OAuth]({{< relref "./configure-authentication/google#configure-team-sync-for-google-oauth" >}})
|
||||
- [LDAP]({{< relref "./configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}})
|
||||
- [Okta]({{< relref "./configure-authentication/okta#configure-team-synchronization-enterprise-only" >}})
|
||||
- [SAML]({{< relref "./configure-authentication/saml#configure-team-sync" >}})
|
||||
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-authentication/azuread#group-sync-enterprise-only)
|
||||
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
|
||||
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
|
||||
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/github#configure-group-synchronization)
|
||||
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/gitlab#configure-group-synchronization)
|
||||
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/google#configure-group-synchronization)
|
||||
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
|
||||
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
|
||||
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/saml#configure-group-synchronization)
|
||||
|
||||
## Synchronize a Grafana team with an external group
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ To demonstrate the observation of data using the Grafana stack, download and run
|
||||
1. Download and save a Docker compose file to run Grafana, Loki and Promtail.
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml
|
||||
wget https://raw.githubusercontent.com/grafana/loki/refs/heads/main/production/docker-compose.yaml -O docker-compose.yaml
|
||||
```
|
||||
|
||||
2. Run the Grafana stack.
|
||||
@@ -234,7 +234,7 @@ In this section, we use the default options for Grafana-managed alert rule creat
|
||||
|
||||
It should return alert instances from log lines with a status code that is not 200 (OK), and that has met the alert condition. The condition for the alert rule to fire is any occurrence that goes over the threshold of `0`. Since the Loki query has returned more than zero alert instances, the alert rule is `Firing`.
|
||||
|
||||
{{< figure src="/media/docs/alerting/expression-loki-alert.png" max-width="1200px" caption="Preview of a firing alert instances" >}}
|
||||
{{< figure src="/media/docs/alerting/firing-loki-alert-rule.png" max-width="1200px" caption="Preview of a firing alert instances" >}}
|
||||
|
||||
### Set evaluation behavior
|
||||
|
||||
|
||||
@@ -82,7 +82,11 @@ During these times, no scheduled releases will be executed. However, this does n
|
||||
|
||||
We've enhanced our naming convention for security release versions to make it easier to clearly identify our security releases from our standard patching releases.
|
||||
|
||||
In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update.
|
||||
In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update. <br>
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Docker does not allow the plus sign (`+`) in image tag names. A plus sign (`+`) will be a rendered as a dash (`-`) in the docker tag.
|
||||
{{< /admonition >}}
|
||||
|
||||
Our new approach directly addresses this issue. Going forward, security releases will be appended with "+security" to indicate that the release is the indicated version PLUS the security fix.
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { e2e } from '../utils';
|
||||
import { fromBaseUrl } from '../utils/support/url';
|
||||
|
||||
describe('Verify i18n', () => {
|
||||
const I18N_USER = 'i18n-test';
|
||||
const I18N_PASSWORD = 'i18n-test';
|
||||
|
||||
// create a new user to isolate the language changes from other tests
|
||||
before(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: fromBaseUrl('/api/admin/users'),
|
||||
body: {
|
||||
email: I18N_USER,
|
||||
login: I18N_USER,
|
||||
name: I18N_USER,
|
||||
password: I18N_PASSWORD,
|
||||
},
|
||||
}).then((response) => {
|
||||
cy.wrap(response.body.uid).as('uid');
|
||||
});
|
||||
});
|
||||
|
||||
// remove the user created in the before hook
|
||||
after(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
cy.get('@uid').then((uid) => {
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: fromBaseUrl(`/api/admin/users/${uid}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(I18N_USER, I18N_PASSWORD);
|
||||
});
|
||||
|
||||
// map between languages in the language picker and the corresponding translation of the 'Language' label
|
||||
const languageMap: Record<string, string> = {
|
||||
Deutsch: 'Sprache',
|
||||
English: 'Language',
|
||||
Español: 'Idioma',
|
||||
Français: 'Langue',
|
||||
'Português Brasileiro': 'Idioma',
|
||||
'中文(简体)': '语言',
|
||||
};
|
||||
|
||||
// basic test which loops through the defined languages in the picker
|
||||
// and verifies that the corresponding label is translated correctly
|
||||
it('loads all the languages correctly', () => {
|
||||
cy.visit('/profile');
|
||||
const LANGUAGE_SELECTOR = '[id="locale-select"]';
|
||||
|
||||
cy.wrap(Object.entries(languageMap)).each(([language, label]: [string, string]) => {
|
||||
cy.get(LANGUAGE_SELECTOR).should('not.be.disabled');
|
||||
cy.get(LANGUAGE_SELECTOR).click();
|
||||
cy.get(LANGUAGE_SELECTOR).clear().type(language).type('{downArrow}{enter}');
|
||||
e2e.components.UserProfile.preferencesSaveButton().click();
|
||||
cy.contains('label', label).should('be.visible');
|
||||
cy.get(LANGUAGE_SELECTOR).should('have.value', language);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
cloud.google.com/go/kms v1.18.5 // @grafana/grafana-backend-group
|
||||
cloud.google.com/go/storage v1.43.0 // @grafana/grafana-backend-group
|
||||
connectrpc.com/connect v1.17.0 // @grafana/observability-traces-and-profiling
|
||||
cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code
|
||||
cuelang.org/go v0.8.2 // @grafana/grafana-as-code
|
||||
filippo.io/age v1.1.1 // @grafana/identity-access-team
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 // @grafana/grafana-backend-group
|
||||
@@ -75,7 +75,7 @@ require (
|
||||
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/codejen v0.0.3 // @grafana/dataviz-squad
|
||||
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
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // @grafana/observability-metrics
|
||||
@@ -109,7 +109,7 @@ require (
|
||||
github.com/hashicorp/go-version v1.7.0 // @grafana/grafana-backend-group
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-backend
|
||||
github.com/hashicorp/hcl/v2 v2.17.0 // @grafana/alerting-backend
|
||||
github.com/huandu/xstrings v1.3.3 // @grafana/partner-datasources
|
||||
github.com/huandu/xstrings v1.5.0 // @grafana/partner-datasources
|
||||
github.com/influxdata/influxdb-client-go/v2 v2.13.0 // @grafana/observability-metrics
|
||||
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group
|
||||
@@ -266,7 +266,7 @@ require (
|
||||
github.com/edsrzf/mmap-go v1.1.0 // indirect
|
||||
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/emicklei/proto v1.10.0 // indirect
|
||||
github.com/emicklei/proto v1.13.2 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
|
||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@@ -382,7 +382,7 @@ require (
|
||||
github.com/prometheus/common/sigv4 v0.1.0 // indirect
|
||||
github.com/prometheus/exporter-toolkit v0.11.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect
|
||||
github.com/redis/rueidis v1.0.45 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
|
||||
@@ -1833,8 +1833,8 @@ github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8
|
||||
github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw=
|
||||
github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
|
||||
github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY=
|
||||
github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
|
||||
@@ -2268,8 +2268,8 @@ github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6S
|
||||
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/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
|
||||
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
|
||||
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=
|
||||
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws=
|
||||
github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk=
|
||||
@@ -2472,8 +2472,9 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
|
||||
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
|
||||
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
||||
@@ -3036,8 +3037,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus/prometheus v0.52.0 h1:f7kHJgr7+zShpWdTCeKqbCWR7nKTScgLYQwRux9h1V0=
|
||||
github.com/prometheus/prometheus v0.52.0/go.mod h1:3z74cVsmVH0iXOR5QBjB7Pa6A0KJeEAK5A6UsmAFb1g=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
|
||||
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
|
||||
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
|
||||
@@ -6,6 +6,7 @@ go 1.23.1
|
||||
use (
|
||||
. // skip:golangci-lint
|
||||
./apps/playlist
|
||||
./kindsv2
|
||||
./pkg/aggregator
|
||||
./pkg/apimachinery
|
||||
./pkg/apiserver
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:generate go run gen.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"cuelang.org/go/cue/cuecontext"
|
||||
"github.com/grafana/cog"
|
||||
)
|
||||
|
||||
type codegenTargets struct {
|
||||
schemaPath string
|
||||
outputPath string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cueCtx := cuecontext.New()
|
||||
|
||||
targets := []codegenTargets{
|
||||
{
|
||||
schemaPath: "../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue",
|
||||
outputPath: "../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts",
|
||||
},
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
rawSchema, err := os.ReadFile(target.schemaPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
value := cueCtx.CompileBytes(rawSchema)
|
||||
if value.Err() != nil {
|
||||
panic(value.Err())
|
||||
}
|
||||
|
||||
codegenPipeline := cog.TypesFromSchema().
|
||||
CUEValue("dashboard", value).
|
||||
Typescript()
|
||||
|
||||
tsBytes, err := codegenPipeline.Run(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(target.outputPath, tsBytes, 0644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
module github.com/grafana/grafana/kindsv2
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
cuelang.org/go v0.8.2
|
||||
github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070
|
||||
)
|
||||
|
||||
require (
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 // indirect
|
||||
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/proto v1.13.2 // indirect
|
||||
github.com/expr-lang/expr v1.16.9 // indirect
|
||||
github.com/getkin/kin-openapi v0.128.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
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/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/yalue/merged_fs v1.3.0 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 h1:R5wwEcbEZSBmeyg91MJZTxfd7WpBo2jPof3AYjRbxwY=
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg=
|
||||
cuelang.org/go v0.8.2 h1:vWfHI1kQlBvwkna7ktAqXjV5LUEAgU6vyMlJjvZZaDw=
|
||||
cuelang.org/go v0.8.2/go.mod h1:CoDbYolfMms4BhWUlhD+t5ORnihR7wvjcfgyO9lL5FI=
|
||||
github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
|
||||
github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY=
|
||||
github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
|
||||
github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI=
|
||||
github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4=
|
||||
github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
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/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/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/cog v0.0.0-20241029201114-f7f7db0c1070 h1:ncAkFmIq3UbPiPhQ2CuBzdTuwnDqLsFFNqEIXKaiPGA=
|
||||
github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070/go.mod h1:FqZi9WZ/Uzvs3tvo7l+OViThCpfvu3KDGDqHCB2LNbg=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
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/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ=
|
||||
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
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/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=
|
||||
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+1
-1
@@ -279,7 +279,7 @@
|
||||
"@grafana/ui": "workspace:*",
|
||||
"@hello-pangea/dnd": "17.0.0",
|
||||
"@kusto/monaco-kusto": "^10.0.0",
|
||||
"@leeoniya/ufuzzy": "1.0.14",
|
||||
"@leeoniya/ufuzzy": "1.0.16",
|
||||
"@lezer/common": "1.2.3",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@lezer/lr": "1.4.2",
|
||||
|
||||
@@ -85,12 +85,30 @@ function createDebug(): GrafanaTheme2 {
|
||||
border: '#ff000088',
|
||||
text: '#cccc00',
|
||||
contrastText: '#ffff00',
|
||||
shade: '#9900dd',
|
||||
},
|
||||
secondary: {
|
||||
...baseDarkColors.secondary,
|
||||
border: '#ff000088',
|
||||
text: '#cccc00',
|
||||
contrastText: '#ffff00',
|
||||
shade: '#9900dd',
|
||||
},
|
||||
info: {
|
||||
...baseDarkColors.info,
|
||||
shade: '#9900dd',
|
||||
},
|
||||
warning: {
|
||||
...baseDarkColors.warning,
|
||||
shade: '#9900dd',
|
||||
},
|
||||
success: {
|
||||
...baseDarkColors.success,
|
||||
shade: '#9900dd',
|
||||
},
|
||||
error: {
|
||||
...baseDarkColors.error,
|
||||
shade: '#9900dd',
|
||||
},
|
||||
action: {
|
||||
hover: '#9900dd',
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface ExploreLogsPanelState {
|
||||
labelFieldName?: string;
|
||||
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
|
||||
refId?: string;
|
||||
displayedFields?: string[];
|
||||
}
|
||||
|
||||
export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@emotion/css": "11.13.4",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/ui": "11.4.0-pre",
|
||||
"@leeoniya/ufuzzy": "1.0.14",
|
||||
"@leeoniya/ufuzzy": "1.0.16",
|
||||
"d3": "^7.8.5",
|
||||
"lodash": "4.17.21",
|
||||
"react": "18.2.0",
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@grafana/data": "11.4.0-pre",
|
||||
"@grafana/experimental": "2.1.2",
|
||||
"@grafana/faro-web-sdk": "1.12.0",
|
||||
"@grafana/faro-web-sdk": "1.12.1",
|
||||
"@grafana/runtime": "11.4.0-pre",
|
||||
"@grafana/schema": "11.4.0-pre",
|
||||
"@grafana/ui": "11.4.0-pre",
|
||||
"@hello-pangea/dnd": "17.0.0",
|
||||
"@leeoniya/ufuzzy": "1.0.14",
|
||||
"@leeoniya/ufuzzy": "1.0.16",
|
||||
"@lezer/common": "1.2.3",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@lezer/lr": "1.4.2",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AdHocVariableFilter,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { reportInteraction } from '../analytics/utils';
|
||||
import { config } from '../config';
|
||||
import {
|
||||
BackendSrvRequest,
|
||||
@@ -355,8 +356,17 @@ class DataSourceWithBackend<
|
||||
headers: this.getRequestHeaders(),
|
||||
})
|
||||
)
|
||||
.then((v: FetchResponse) => v.data)
|
||||
.catch((err) => err.data);
|
||||
.then((v: FetchResponse<HealthCheckResult>) => v.data)
|
||||
.catch((err) => {
|
||||
let properties: Record<string, string> = {
|
||||
plugin_id: this.meta?.id || '',
|
||||
plugin_version: this.meta?.info?.version || '',
|
||||
datasource_healthcheck_status: err?.data?.status || 'error',
|
||||
datasource_healthcheck_message: err?.data?.message || '',
|
||||
};
|
||||
reportInteraction('datasource_health_check_completed', properties);
|
||||
return err.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,789 @@
|
||||
package dashboard
|
||||
|
||||
DashboardV2Spec: {
|
||||
// Unique numeric identifier for the dashboard.
|
||||
// `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances.
|
||||
id?: int64
|
||||
|
||||
// Title of dashboard.
|
||||
title: string
|
||||
|
||||
// Description of dashboard.
|
||||
description?: string
|
||||
|
||||
// Configuration of dashboard cursor sync behavior.
|
||||
// "Off" for no shared crosshair or tooltip (default).
|
||||
// "Crosshair" for shared crosshair.
|
||||
// "Tooltip" for shared crosshair AND shared tooltip.
|
||||
cursorSync: DashboardCursorSync
|
||||
|
||||
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
|
||||
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
|
||||
// avoid dashboards presenting stale live data.
|
||||
liveNow?: bool
|
||||
|
||||
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
|
||||
preload: bool
|
||||
|
||||
// Whether a dashboard is editable or not.
|
||||
editable?: bool | *true
|
||||
|
||||
// Links with references to other dashboards or external websites.
|
||||
links: [...DashboardLink]
|
||||
|
||||
// Tags associated with dashboard.
|
||||
tags?: [...string]
|
||||
|
||||
timeSettings: TimeSettingsSpec
|
||||
|
||||
// Configured template variables.
|
||||
variables: [...QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind]
|
||||
|
||||
elements: [ElementReference.name]: PanelKind // |* more element types in the future
|
||||
|
||||
annotations: [...AnnotationQueryKind]
|
||||
|
||||
layout: GridLayoutKind
|
||||
|
||||
// Version of the JSON schema, incremented each time a Grafana update brings
|
||||
// changes to said schema.
|
||||
schemaVersion: uint16 | *39
|
||||
|
||||
|
||||
// version: will rely on k8s resource versioning, via metadata.resorceVersion
|
||||
// revision?: int // for plugins only
|
||||
// gnetId?: string // ??? Wat is this used for?
|
||||
}
|
||||
|
||||
|
||||
AnnotationPanelFilter: {
|
||||
// Should the specified panels be included or excluded
|
||||
exclude?: bool | *false
|
||||
|
||||
// Panel IDs that should be included or excluded
|
||||
ids: [...uint8]
|
||||
}
|
||||
|
||||
// "Off" for no shared crosshair or tooltip (default).
|
||||
// "Crosshair" for shared crosshair.
|
||||
// "Tooltip" for shared crosshair AND shared tooltip.
|
||||
DashboardCursorSync: "Off" | "Crosshair" | "Tooltip"
|
||||
|
||||
// Links with references to other dashboards or external resources
|
||||
DashboardLink: {
|
||||
// Title to display with the link
|
||||
title: string
|
||||
// Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
|
||||
// FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType`
|
||||
type: DashboardLinkType
|
||||
// Icon name to be displayed with the link
|
||||
icon: string
|
||||
// Tooltip to display when the user hovers their mouse over it
|
||||
tooltip: string
|
||||
// Link URL. Only required/valid if the type is link
|
||||
url?: string
|
||||
// List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
|
||||
tags: [...string]
|
||||
// If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards
|
||||
asDropdown: bool | *false
|
||||
// If true, the link will be opened in a new tab
|
||||
targetBlank: bool | *false
|
||||
// If true, includes current template variables values in the link as query params
|
||||
includeVars: bool | *false
|
||||
// If true, includes current time range in the link as query params
|
||||
keepTime: bool | *false
|
||||
}
|
||||
|
||||
DataSourceRef: {
|
||||
// The plugin type-id
|
||||
type?: string
|
||||
|
||||
// Specific datasource instance
|
||||
uid?: string
|
||||
}
|
||||
|
||||
// Transformations allow to manipulate data returned by a query before the system applies a visualization.
|
||||
// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries,
|
||||
// use the output of one transformation as the input to another transformation, etc.
|
||||
DataTransformerConfig: {
|
||||
// Unique identifier of transformer
|
||||
id: string
|
||||
// Disabled transformations are skipped
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
filter?: MatcherConfig
|
||||
// Where to pull DataFrames from as input to transformation
|
||||
topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic
|
||||
// Options to be passed to the transformer
|
||||
// Valid options depend on the transformer id
|
||||
options: _
|
||||
}
|
||||
|
||||
// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.
|
||||
// Each column within this structure is called a field. A field can represent a single time series or table column.
|
||||
// Field options allow you to change how the data is displayed in your visualizations.
|
||||
FieldConfigSource: {
|
||||
// Defaults are the options applied to all fields.
|
||||
defaults: FieldConfig
|
||||
// Overrides are the options applied to specific fields overriding the defaults.
|
||||
overrides: [...{
|
||||
matcher: MatcherConfig
|
||||
properties: [...DynamicConfigValue]
|
||||
}]
|
||||
}
|
||||
|
||||
// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.
|
||||
// Each column within this structure is called a field. A field can represent a single time series or table column.
|
||||
// Field options allow you to change how the data is displayed in your visualizations.
|
||||
FieldConfig: {
|
||||
// The display value for this field. This supports template variables blank is auto
|
||||
displayName?: string
|
||||
|
||||
// This can be used by data sources that return and explicit naming structure for values and labels
|
||||
// When this property is configured, this value is used rather than the default naming strategy.
|
||||
displayNameFromDS?: string
|
||||
|
||||
// Human readable field metadata
|
||||
description?: string
|
||||
|
||||
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
||||
// This will default to `${frame.meta.path}/${field.name}
|
||||
//
|
||||
// When defined, this value can be used as an identifier within the datasource scope, and
|
||||
// may be used to update the results
|
||||
path?: string
|
||||
|
||||
// True if data source can write a value to the path. Auth/authz are supported separately
|
||||
writeable?: bool
|
||||
|
||||
// True if data source field supports ad-hoc filters
|
||||
filterable?: bool
|
||||
|
||||
// Unit a field should use. The unit you select is applied to all fields except time.
|
||||
// You can use the units ID availables in Grafana or a custom unit.
|
||||
// Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts
|
||||
// As custom unit, you can use the following formats:
|
||||
// `suffix:<suffix>` for custom unit that should go after value.
|
||||
// `prefix:<prefix>` for custom unit that should go before value.
|
||||
// `time:<format>` For custom date time formats type for example `time:YYYY-MM-DD`.
|
||||
// `si:<base scale><unit characters>` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.
|
||||
// `count:<unit>` for a custom count unit.
|
||||
// `currency:<unit>` for custom a currency unit.
|
||||
unit?: string
|
||||
|
||||
// Specify the number of decimals Grafana includes in the rendered value.
|
||||
// If you leave this field blank, Grafana automatically truncates the number of decimals based on the value.
|
||||
// For example 1.1234 will display as 1.12 and 100.456 will display as 100.
|
||||
// To display all decimals, set the unit to `String`.
|
||||
decimals?: number
|
||||
|
||||
// The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.
|
||||
min?: number
|
||||
// The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.
|
||||
max?: number
|
||||
|
||||
// Convert input values into a display string
|
||||
mappings?: [...ValueMapping]
|
||||
|
||||
// Map numeric values to states
|
||||
thresholds?: ThresholdsConfig
|
||||
|
||||
// Panel color configuration
|
||||
color?: FieldColor
|
||||
|
||||
// The behavior when clicking on a result
|
||||
links?: [...]
|
||||
|
||||
// Alternative to empty string
|
||||
noValue?: string
|
||||
|
||||
// custom is specified by the FieldConfig field
|
||||
// in panel plugin schemas.
|
||||
custom?: {...}
|
||||
}
|
||||
|
||||
DynamicConfigValue: {
|
||||
id: string | *""
|
||||
value?: _
|
||||
}
|
||||
|
||||
// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.
|
||||
// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.
|
||||
MatcherConfig: {
|
||||
// The matcher id. This is used to find the matcher implementation from registry.
|
||||
id: string | *""
|
||||
// The matcher options. This is specific to the matcher implementation.
|
||||
options?: _
|
||||
}
|
||||
|
||||
Threshold: {
|
||||
value: number | null
|
||||
color: string
|
||||
}
|
||||
|
||||
ThresholdsMode: "absolute" | "percentage"
|
||||
|
||||
ThresholdsConfig: {
|
||||
mode: ThresholdsMode
|
||||
steps: [...Threshold]
|
||||
}
|
||||
|
||||
ValueMapping: ValueMap | RangeMap | RegexMap | SpecialValueMap
|
||||
|
||||
// Supported value mapping types
|
||||
// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
|
||||
// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
|
||||
// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
|
||||
// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
|
||||
MappingType: "value" | "range" | "regex" | "special"
|
||||
|
||||
// Maps text values to a color or different display text and color.
|
||||
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
|
||||
ValueMap: {
|
||||
type: MappingType & "value"
|
||||
// Map with <value_to_match>: ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } }
|
||||
options: [string]: ValueMappingResult
|
||||
}
|
||||
|
||||
// Maps numerical ranges to a display text and color.
|
||||
// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
|
||||
RangeMap: {
|
||||
type: MappingType & "range"
|
||||
// Range to match against and the result to apply when the value is within the range
|
||||
options: {
|
||||
// Min value of the range. It can be null which means -Infinity
|
||||
from: float64 | null
|
||||
// Max value of the range. It can be null which means +Infinity
|
||||
to: float64 | null
|
||||
// Config to apply when the value is within the range
|
||||
result: ValueMappingResult
|
||||
}
|
||||
}
|
||||
|
||||
// Maps regular expressions to replacement text and a color.
|
||||
// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
|
||||
RegexMap: {
|
||||
type: MappingType & "regex"
|
||||
// Regular expression to match against and the result to apply when the value matches the regex
|
||||
options: {
|
||||
// Regular expression to match against
|
||||
pattern: string
|
||||
// Config to apply when the value matches the regex
|
||||
result: ValueMappingResult
|
||||
}
|
||||
}
|
||||
|
||||
// Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.
|
||||
// See SpecialValueMatch to see the list of special values.
|
||||
// For example, you can configure a special value mapping so that null values appear as N/A.
|
||||
SpecialValueMap: {
|
||||
type: MappingType & "special"
|
||||
options: {
|
||||
// Special value to match against
|
||||
match: SpecialValueMatch
|
||||
// Config to apply when the value matches the special value
|
||||
result: ValueMappingResult
|
||||
}
|
||||
}
|
||||
|
||||
// Special value types supported by the `SpecialValueMap`
|
||||
SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty"
|
||||
|
||||
// Result used as replacement with text and color when the value matches
|
||||
ValueMappingResult: {
|
||||
// Text to display when the value matches
|
||||
text?: string
|
||||
// Text to use when the value matches
|
||||
color?: string
|
||||
// Icon to display when the value matches. Only specific visualizations.
|
||||
icon?: string
|
||||
// Position in the mapping array. Only used internally.
|
||||
index?: int32
|
||||
}
|
||||
|
||||
// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.
|
||||
// Continuous color interpolates a color using the percentage of a value relative to min and max.
|
||||
// Accepted values are:
|
||||
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
|
||||
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
|
||||
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
|
||||
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
|
||||
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
|
||||
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
|
||||
// `continuous-YlRd`: Continuous Yellow-Red palette mode
|
||||
// `continuous-BlPu`: Continuous Blue-Purple palette mode
|
||||
// `continuous-YlBl`: Continuous Yellow-Blue palette mode
|
||||
// `continuous-blues`: Continuous Blue palette mode
|
||||
// `continuous-reds`: Continuous Red palette mode
|
||||
// `continuous-greens`: Continuous Green palette mode
|
||||
// `continuous-purples`: Continuous Purple palette mode
|
||||
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
|
||||
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
|
||||
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
|
||||
|
||||
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
|
||||
FieldColorSeriesByMode: "min" | "max" | "last"
|
||||
|
||||
// Map a field to a color.
|
||||
FieldColor: {
|
||||
// The main color scheme mode.
|
||||
mode: FieldColorModeId
|
||||
// The fixed color value for fixed or shades color modes.
|
||||
fixedColor?: string
|
||||
// Some visualizations need to know how to assign a series color from by value color schemes.
|
||||
seriesBy?: FieldColorSeriesByMode
|
||||
}
|
||||
|
||||
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
|
||||
DashboardLinkType: "link" | "dashboards"
|
||||
|
||||
// --- Common types ---
|
||||
Kind: {
|
||||
kind: string,
|
||||
spec: _
|
||||
metadata?: _
|
||||
}
|
||||
|
||||
// --- Kinds ---
|
||||
VizConfigSpec: {
|
||||
pluginVersion: string
|
||||
options: [string]: _
|
||||
fieldConfig: FieldConfigSource
|
||||
}
|
||||
|
||||
VizConfigKind: {
|
||||
// The kind of a VizConfigKind is the plugin ID
|
||||
kind: string
|
||||
spec: VizConfigSpec
|
||||
}
|
||||
|
||||
AnnotationQuerySpec: {
|
||||
datasource: DataSourceRef
|
||||
query: DataQueryKind
|
||||
|
||||
// TODO: Should be figured out based on datasource (Grafana ds)
|
||||
// builtIn?: int
|
||||
// Below are currently existing options for annotation queries
|
||||
enable: bool
|
||||
filter: AnnotationPanelFilter
|
||||
hide: bool
|
||||
iconColor: string
|
||||
name: string
|
||||
}
|
||||
|
||||
AnnotationQueryKind: {
|
||||
kind: "AnnotationQuery"
|
||||
spec: AnnotationQuerySpec
|
||||
}
|
||||
|
||||
QueryOptionsSpec: {
|
||||
timeFrom?: string
|
||||
maxDataPoints?: int
|
||||
timeShift?: string
|
||||
queryCachingTTL?: int
|
||||
interval?: string
|
||||
cacheTimeout?: string
|
||||
}
|
||||
|
||||
DataQueryKind: {
|
||||
// The kind of a DataQueryKind is the datasource type
|
||||
kind: string
|
||||
spec: [string]: _
|
||||
}
|
||||
|
||||
PanelQuerySpec: {
|
||||
query: DataQueryKind
|
||||
datasource: DataSourceRef
|
||||
|
||||
refId: string
|
||||
hidden: bool
|
||||
}
|
||||
|
||||
PanelQueryKind: {
|
||||
kind: "PanelQuery"
|
||||
spec: PanelQuerySpec
|
||||
}
|
||||
|
||||
TransformationKind: {
|
||||
// The kind of a TransformationKind is the transformation ID
|
||||
kind: string
|
||||
spec: DataTransformerConfig
|
||||
}
|
||||
|
||||
QueryGroupSpec: {
|
||||
queries: [...PanelQueryKind]
|
||||
transformations: [...TransformationKind]
|
||||
queryOptions: QueryOptionsSpec
|
||||
}
|
||||
|
||||
QueryGroupKind: {
|
||||
kind: "QueryGroup"
|
||||
spec: QueryGroupSpec
|
||||
}
|
||||
|
||||
// Time configuration
|
||||
// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
|
||||
TimeSettingsSpec: {
|
||||
// Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc".
|
||||
timezone?: string | *"browser"
|
||||
// Start time range for dashboard.
|
||||
// Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
|
||||
from: string | *"now-6h"
|
||||
// End time range for dashboard.
|
||||
// Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
|
||||
to: string | *"now"
|
||||
// Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d".
|
||||
autoRefresh: string // v1: refresh
|
||||
// Interval options available in the refresh picker dropdown.
|
||||
autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals
|
||||
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
|
||||
quickRanges: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] // v1: timepicker.time_options , not exposed in the UI
|
||||
// Whether timepicker is visible or not.
|
||||
hideTimepicker: bool // v1: timepicker.hidden
|
||||
// Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
|
||||
weekStart: string
|
||||
// The month that the fiscal year starts on. 0 = January, 11 = December
|
||||
fiscalYearStartMonth: int
|
||||
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
|
||||
nowDelay?: string // v1: timepicker.nowDelay
|
||||
}
|
||||
|
||||
GridLayoutItemSpec: {
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
element: ElementReference // reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference
|
||||
}
|
||||
|
||||
GridLayoutItemKind: {
|
||||
kind: "GridLayoutItem"
|
||||
spec: GridLayoutItemSpec
|
||||
}
|
||||
|
||||
GridLayoutSpec: {
|
||||
items: [...GridLayoutItemKind]
|
||||
}
|
||||
|
||||
GridLayoutKind: {
|
||||
kind: "GridLayout"
|
||||
spec: GridLayoutSpec
|
||||
}
|
||||
|
||||
PanelSpec: {
|
||||
uid: string
|
||||
title: string
|
||||
description: string
|
||||
links: [...DashboardLink]
|
||||
data: QueryGroupKind
|
||||
vizConfig: VizConfigKind
|
||||
}
|
||||
|
||||
PanelKind: {
|
||||
kind: "Panel"
|
||||
spec: PanelSpec
|
||||
}
|
||||
|
||||
ElementReference: {
|
||||
kind: "ElementReference"
|
||||
name: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Start FIXME: variables - in CUE PR - this are things that should be added into the cue schema
|
||||
// TODO: properties such as `hide`, `skipUrlSync`, `multi` are type boolean, and in the old schema they are conditional,
|
||||
// should we make them conditional in the new schema as well? or should we make them required but default to false?
|
||||
|
||||
// Variable types
|
||||
VariableValue: VariableValueSingle | [...VariableValueSingle]
|
||||
|
||||
VariableValueSingle: string | bool | number | CustomVariableValue
|
||||
|
||||
// Custom formatter variable
|
||||
CustomFormatterVariable: {
|
||||
name: string
|
||||
type: VariableType
|
||||
multi: bool
|
||||
includeAll: bool
|
||||
}
|
||||
|
||||
// Custom variable value
|
||||
CustomVariableValue: {
|
||||
// The format name or function used in the expression
|
||||
formatter: *null | string | VariableCustomFormatterFn
|
||||
}
|
||||
|
||||
// Custom formatter function
|
||||
VariableCustomFormatterFn: {
|
||||
value: _
|
||||
legacyVariableModel: {
|
||||
name: string
|
||||
type: VariableType
|
||||
multi: bool
|
||||
includeAll: bool
|
||||
}
|
||||
legacyDefaultFormatter?: VariableCustomFormatterFn
|
||||
}
|
||||
|
||||
// Dashboard variable type
|
||||
// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
|
||||
// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
|
||||
// `constant`: Define a hidden constant.
|
||||
// `datasource`: Quickly change the data source for an entire dashboard.
|
||||
// `interval`: Interval variables represent time spans.
|
||||
// `textbox`: Display a free text input field with an optional default value.
|
||||
// `custom`: Define the variable options manually using a comma-separated list.
|
||||
// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables
|
||||
VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" |
|
||||
"system" | "snapshot"
|
||||
|
||||
// Sort variable options
|
||||
// Accepted values are:
|
||||
// `disabled`: No sorting
|
||||
// `alphabeticalAsc`: Alphabetical ASC
|
||||
// `alphabeticalDesc`: Alphabetical DESC
|
||||
// `numericalAsc`: Numerical ASC
|
||||
// `numericalDesc`: Numerical DESC
|
||||
// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC
|
||||
// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC
|
||||
// `naturalAsc`: Natural ASC
|
||||
// `naturalDesc`: Natural DESC
|
||||
// VariableSort enum with default value
|
||||
VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc"
|
||||
|
||||
// Options to config when to refresh a variable
|
||||
// `never`: Never refresh the variable
|
||||
// `onDashboardLoad`: Queries the data source every time the dashboard loads.
|
||||
// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes.
|
||||
VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged"
|
||||
|
||||
// Determine if the variable shows on dashboard
|
||||
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
|
||||
VariableHide: *"dontHide" | "hideLabel" | "hideVariable"
|
||||
|
||||
|
||||
// FIXME: should we introduce this? --- Variable value option
|
||||
VariableValueOption: {
|
||||
label: string
|
||||
value: VariableValueSingle
|
||||
group?: string
|
||||
}
|
||||
|
||||
// Variable option specification
|
||||
VariableOption: {
|
||||
// Whether the option is selected or not
|
||||
selected?: bool
|
||||
// Text to be displayed for the option
|
||||
text: string | [...string]
|
||||
// Value of the option
|
||||
value: string | [...string]
|
||||
}
|
||||
|
||||
// Query variable specification
|
||||
QueryVariableSpec: {
|
||||
name: string | *""
|
||||
current: VariableOption | *{
|
||||
text: ""
|
||||
value: ""
|
||||
}
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
refresh: VariableRefresh
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
datasource: DataSourceRef | *{}
|
||||
query: string | DataQueryKind | *""
|
||||
regex: string | *""
|
||||
sort: VariableSort
|
||||
definition?: string
|
||||
options: [...VariableOption] | *[]
|
||||
multi: bool | *false
|
||||
includeAll: bool | *false
|
||||
allValue?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// Query variable kind
|
||||
QueryVariableKind: {
|
||||
kind: "QueryVariable"
|
||||
spec: QueryVariableSpec
|
||||
}
|
||||
|
||||
// Text variable specification
|
||||
TextVariableSpec: {
|
||||
name: string | *""
|
||||
current: VariableOption | *{
|
||||
text: ""
|
||||
value: ""
|
||||
}
|
||||
query: string | *""
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Text variable kind
|
||||
TextVariableKind: {
|
||||
kind: "TextVariable"
|
||||
spec: TextVariableSpec
|
||||
}
|
||||
|
||||
// Constant variable specification
|
||||
ConstantVariableSpec: {
|
||||
name: string | *""
|
||||
query: string | *""
|
||||
current: VariableOption | *{
|
||||
text: ""
|
||||
value: ""
|
||||
}
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Constant variable kind
|
||||
ConstantVariableKind: {
|
||||
kind: "ConstantVariable"
|
||||
spec: ConstantVariableSpec
|
||||
}
|
||||
|
||||
// Datasource variable specification
|
||||
DatasourceVariableSpec: {
|
||||
name: string | *""
|
||||
pluginId: string | *""
|
||||
refresh: VariableRefresh
|
||||
regex: string | *""
|
||||
current: VariableOption | *{
|
||||
text: ""
|
||||
value: ""
|
||||
}
|
||||
defaultOptionEnabled: bool | *false
|
||||
options: [...VariableOption] | *[]
|
||||
multi: bool | *false
|
||||
includeAll: bool | *false
|
||||
allValue?: string
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Datasource variable kind
|
||||
DatasourceVariableKind: {
|
||||
kind: "DatasourceVariable"
|
||||
spec: DatasourceVariableSpec
|
||||
}
|
||||
|
||||
// Interval variable specification
|
||||
IntervalVariableSpec: {
|
||||
name: string | *""
|
||||
query: string | *""
|
||||
current: VariableOption | *{
|
||||
text: ""
|
||||
value: ""
|
||||
}
|
||||
options: [...VariableOption] | *[]
|
||||
auto: bool | *false
|
||||
auto_min: string | *""
|
||||
auto_count: int | *0
|
||||
refresh: VariableRefresh
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Interval variable kind
|
||||
IntervalVariableKind: {
|
||||
kind: "IntervalVariable"
|
||||
spec: IntervalVariableSpec
|
||||
}
|
||||
|
||||
// Custom variable specification
|
||||
CustomVariableSpec: {
|
||||
name: string | *""
|
||||
query: string | *""
|
||||
current: VariableOption
|
||||
options: [...VariableOption] | *[]
|
||||
multi: bool | *false
|
||||
includeAll: bool | *false
|
||||
allValue?: string
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
CustomVariableKind: {
|
||||
kind: "CustomVariable"
|
||||
spec: CustomVariableSpec
|
||||
}
|
||||
|
||||
// GroupBy variable specification
|
||||
GroupByVariableSpec: {
|
||||
name: string | *""
|
||||
datasource: DataSourceRef | *{}
|
||||
current: VariableOption | *{
|
||||
text: ""
|
||||
value: ""
|
||||
}
|
||||
options: [...VariableOption] | *[]
|
||||
multi: bool | *false
|
||||
includeAll: bool | *false
|
||||
allValue?: string
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Group variable kind
|
||||
GroupByVariableKind: {
|
||||
kind: "GroupByVariable"
|
||||
spec: GroupByVariableSpec
|
||||
}
|
||||
|
||||
// Adhoc variable specification
|
||||
AdhocVariableSpec: {
|
||||
name: string | *""
|
||||
datasource: DataSourceRef | *{}
|
||||
baseFilters: [...AdHocFilterWithLabels] | *[]
|
||||
filters: [...AdHocFilterWithLabels] | *[]
|
||||
defaultKeys: [...MetricFindValue] | *[]
|
||||
label?: string
|
||||
hide: VariableHide
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
}
|
||||
|
||||
// Define the MetricFindValue type
|
||||
MetricFindValue: {
|
||||
text: string
|
||||
value?: string | number
|
||||
group?: string
|
||||
expandable?: bool
|
||||
}
|
||||
|
||||
// Define the AdHocFilterWithLabels type
|
||||
AdHocFilterWithLabels: {
|
||||
key: string,
|
||||
operator: string,
|
||||
value: string,
|
||||
values?: [...string],
|
||||
keyLabel?: string,
|
||||
valueLabels?: [...string],
|
||||
forceEdit?: bool,
|
||||
// @deprecated
|
||||
condition?: string,
|
||||
}
|
||||
|
||||
// Adhoc variable kind
|
||||
AdhocVariableKind: {
|
||||
kind: "AdhocVariable"
|
||||
spec: AdhocVariableSpec
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { DashboardCursorSync, DashboardV2Spec } from './dashboard.gen';
|
||||
|
||||
export const handyTestingSchema: DashboardV2Spec = {
|
||||
id: 1,
|
||||
title: 'Default Dashboard',
|
||||
description: 'This is a default dashboard',
|
||||
cursorSync: DashboardCursorSync.Off,
|
||||
liveNow: false,
|
||||
preload: false,
|
||||
editable: true,
|
||||
links: [],
|
||||
tags: [],
|
||||
schemaVersion: 39,
|
||||
timeSettings: {
|
||||
timezone: 'browser',
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
autoRefresh: '10s',
|
||||
autoRefreshIntervals: ['10s', '1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'],
|
||||
quickRanges: ['now/d', 'now/w', 'now/M', 'now/y'],
|
||||
hideTimepicker: false,
|
||||
weekStart: 'sunday',
|
||||
fiscalYearStartMonth: 1,
|
||||
},
|
||||
|
||||
elements: {
|
||||
timeSeriesTest: {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
title: 'Time Series Test',
|
||||
description: 'This is a test panel',
|
||||
uid: 'timeSeriesTest',
|
||||
links: [],
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
query: {
|
||||
kind: 'prometheus',
|
||||
spec: {
|
||||
query: 'up',
|
||||
},
|
||||
},
|
||||
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
|
||||
hidden: false,
|
||||
refId: 'A',
|
||||
},
|
||||
},
|
||||
],
|
||||
transformations: [
|
||||
{
|
||||
kind: 'limit',
|
||||
spec: {
|
||||
id: 'limit', // id is competing w/ kind
|
||||
options: {
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
queryOptions: {
|
||||
maxDataPoints: 100,
|
||||
cacheTimeout: '1m',
|
||||
},
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: 'timeseries',
|
||||
spec: {
|
||||
pluginVersion: '11.0.0',
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
kind: 'GridLayout',
|
||||
spec: {
|
||||
items: [
|
||||
{
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
element: { kind: 'ElementReference', name: 'timeSeriesTest' },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
variables: [],
|
||||
annotations: [
|
||||
{
|
||||
kind: 'AnnotationQuery',
|
||||
spec: {
|
||||
datasource: { type: 'datasource', uid: 'grafana' },
|
||||
query: {
|
||||
kind: 'grafana',
|
||||
spec: {
|
||||
queryType: 'timeRegions',
|
||||
matchAny: false,
|
||||
timeRegion: {
|
||||
from: '12:27',
|
||||
fromDayOfWeek: 2,
|
||||
timezone: 'browser',
|
||||
to: '11:30',
|
||||
toDayOfWeek: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
enable: true,
|
||||
filter: {
|
||||
ids: [],
|
||||
},
|
||||
hide: false,
|
||||
iconColor: 'blue',
|
||||
name: 'Grafana annotations',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'AnnotationQuery',
|
||||
spec: {
|
||||
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
|
||||
query: {
|
||||
kind: 'prometheus',
|
||||
spec: {
|
||||
query: 'up',
|
||||
},
|
||||
},
|
||||
enable: true,
|
||||
filter: {
|
||||
ids: [],
|
||||
},
|
||||
hide: false,
|
||||
iconColor: 'red',
|
||||
name: 'Prometheus annotations',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -56,7 +56,7 @@
|
||||
"@grafana/faro-web-sdk": "^1.3.6",
|
||||
"@grafana/schema": "11.4.0-pre",
|
||||
"@hello-pangea/dnd": "17.0.0",
|
||||
"@leeoniya/ufuzzy": "1.0.14",
|
||||
"@leeoniya/ufuzzy": "1.0.16",
|
||||
"@monaco-editor/react": "4.6.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@react-aria/dialog": "3.5.19",
|
||||
@@ -92,7 +92,7 @@
|
||||
"react-calendar": "5.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-custom-scrollbars-2": "4.5.0",
|
||||
"react-dropzone": "14.2.10",
|
||||
"react-dropzone": "14.3.5",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^14.0.0",
|
||||
|
||||
@@ -4,16 +4,16 @@ import React, { ComponentProps, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import { useTheme2 } from '../../themes/ThemeContext';
|
||||
import { Alert } from '../Alert/Alert';
|
||||
import { Divider } from '../Divider/Divider';
|
||||
import { Field } from '../Forms/Field';
|
||||
import { AsyncSelect, Select } from '../Select/Select';
|
||||
import { AsyncSelect } from '../Select/Select';
|
||||
|
||||
import { Combobox, ComboboxOption } from './Combobox';
|
||||
import mdx from './Combobox.mdx';
|
||||
|
||||
type PropsAndCustomArgs = ComponentProps<typeof Combobox> & { numberOfOptions: number };
|
||||
type PropsAndCustomArgs<T extends string | number = string> = ComponentProps<typeof Combobox<T>> & {
|
||||
numberOfOptions: number;
|
||||
};
|
||||
|
||||
const meta: Meta<PropsAndCustomArgs> = {
|
||||
title: 'Forms/Combobox',
|
||||
@@ -27,6 +27,7 @@ const meta: Meta<PropsAndCustomArgs> = {
|
||||
loading: undefined,
|
||||
invalid: undefined,
|
||||
width: undefined,
|
||||
isClearable: false,
|
||||
placeholder: 'Select an option...',
|
||||
options: [
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
@@ -45,12 +46,6 @@ const meta: Meta<PropsAndCustomArgs> = {
|
||||
{ label: 'Honeydew', value: 'honeydew' },
|
||||
{ label: 'Iceberg Lettuce', value: 'iceberg-lettuce' },
|
||||
{ label: 'Jackfruit', value: 'jackfruit' },
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '3', value: 3 },
|
||||
{ label: '4', value: 4 },
|
||||
{ label: '5', value: 5 },
|
||||
{ label: '6', value: 6 },
|
||||
],
|
||||
value: 'banana',
|
||||
},
|
||||
@@ -59,16 +54,16 @@ const meta: Meta<PropsAndCustomArgs> = {
|
||||
decorators: [InDevDecorator],
|
||||
};
|
||||
|
||||
const BasicWithState: StoryFn<typeof Combobox> = (args) => {
|
||||
const [value, setValue] = useState(args.value);
|
||||
|
||||
const BasicWithState: StoryFn<PropsAndCustomArgs> = (args) => {
|
||||
const [value, setValue] = useState<string | null>();
|
||||
return (
|
||||
<Field label="Test input" description="Input with a few options">
|
||||
<Combobox
|
||||
id="test-combobox"
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange={(val: ComboboxOption | null) => {
|
||||
// TODO: Figure out how to update value on args
|
||||
setValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
@@ -88,7 +83,7 @@ async function generateOptions(amount: number): Promise<ComboboxOption[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...args }) => {
|
||||
const ManyOptionsStory: StoryFn<PropsAndCustomArgs<string>> = ({ numberOfOptions, ...args }) => {
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -103,13 +98,14 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
|
||||
}, 1000);
|
||||
}, [numberOfOptions]);
|
||||
|
||||
const { onChange, ...rest } = args;
|
||||
return (
|
||||
<Combobox
|
||||
{...args}
|
||||
{...rest}
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(opt) => {
|
||||
onChange={(opt: ComboboxOption | null) => {
|
||||
setValue(opt?.value || null);
|
||||
action('onChange')(opt);
|
||||
}}
|
||||
@@ -117,132 +113,6 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
|
||||
);
|
||||
};
|
||||
|
||||
const SelectComparisonStory: StoryFn<typeof Combobox> = (args) => {
|
||||
const [comboboxValue, setComboboxValue] = useState(args.value);
|
||||
const theme = useTheme2();
|
||||
|
||||
if (typeof args.options === 'function') {
|
||||
throw new Error('This story does not support async options');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid ' + theme.colors.border.weak, padding: 16 }}>
|
||||
<Field label="Combobox with default size">
|
||||
<Combobox
|
||||
{...args}
|
||||
id="combobox-default-size"
|
||||
value={comboboxValue}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Select with default size">
|
||||
<Select
|
||||
id="select-default-size"
|
||||
value={comboboxValue}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Field label="Combobox with explicit size (25)">
|
||||
{/*@ts-ignore minWidth and maxWidth has never, which is incompatible with args. It lacks the context that width=25 on the component*/}
|
||||
<Combobox
|
||||
{...args}
|
||||
id="combobox-explicit-size"
|
||||
width={25}
|
||||
value={comboboxValue}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Select with explicit size (25)">
|
||||
<Select
|
||||
id="select-explicit-size"
|
||||
width={25}
|
||||
value={comboboxValue}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Field label="Combobox with auto width, minWidth 15">
|
||||
<Combobox
|
||||
{...args}
|
||||
id="combobox-auto-size"
|
||||
width="auto"
|
||||
minWidth={15}
|
||||
value={comboboxValue}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Select with auto width">
|
||||
<Select
|
||||
id="select-auto-size"
|
||||
width="auto"
|
||||
value={comboboxValue}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Combobox with auto width, minWidth 15, empty value">
|
||||
<Combobox
|
||||
{...args}
|
||||
id="combobox-auto-size-empty"
|
||||
width="auto"
|
||||
minWidth={15}
|
||||
value={null}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Select with auto width, empty value">
|
||||
<Select
|
||||
id="select-auto-size-empty"
|
||||
width="auto"
|
||||
value={null}
|
||||
options={args.options}
|
||||
onChange={(val) => {
|
||||
setComboboxValue(val?.value || null);
|
||||
action('onChange')(val);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AutoSize: StoryObj<PropsAndCustomArgs> = {
|
||||
args: {
|
||||
width: 'auto',
|
||||
@@ -294,6 +164,8 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { onChange, ...rest } = args;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
@@ -301,12 +173,12 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
||||
description="This tests when options have both a label and a value. Consumers are required to pass in a full ComboboxOption as a value with a label"
|
||||
>
|
||||
<Combobox
|
||||
{...args}
|
||||
{...rest}
|
||||
id="test-combobox-one"
|
||||
placeholder="Select an option"
|
||||
options={loadOptionsWithLabels}
|
||||
value={selectedOption}
|
||||
onChange={(val) => {
|
||||
onChange={(val: ComboboxOption | null) => {
|
||||
action('onChange')(val);
|
||||
setSelectedOption(val);
|
||||
}}
|
||||
@@ -324,7 +196,7 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
||||
placeholder="Select an option"
|
||||
options={loadOptionsOnlyValues}
|
||||
value={selectedOption?.value ?? null}
|
||||
onChange={(val) => {
|
||||
onChange={(val: ComboboxOption | null) => {
|
||||
action('onChange')(val);
|
||||
setSelectedOption(val);
|
||||
}}
|
||||
@@ -366,7 +238,7 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
||||
placeholder="Select an option"
|
||||
options={loadOptionsWithErrors}
|
||||
value={selectedOption}
|
||||
onChange={(val) => {
|
||||
onChange={(val: ComboboxOption | null) => {
|
||||
action('onChange')(val);
|
||||
setSelectedOption(val);
|
||||
}}
|
||||
@@ -426,13 +298,6 @@ export const PositioningTest: StoryObj<PropsAndCustomArgs> = {
|
||||
render: PositioningTestStory,
|
||||
};
|
||||
|
||||
export const ComparisonToSelect: StoryObj<PropsAndCustomArgs> = {
|
||||
args: {
|
||||
numberOfOptions: 100,
|
||||
},
|
||||
render: SelectComparisonStory,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
function InDevDecorator(Story: React.ElementType) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { Combobox, ComboboxOption } from './Combobox';
|
||||
|
||||
@@ -49,7 +50,7 @@ describe('Combobox', () => {
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(options[0]);
|
||||
});
|
||||
|
||||
it("shows the placeholder with the menu open when there's no value", async () => {
|
||||
it('shows the placeholder with the menu open when value is null', async () => {
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} placeholder="Select an option" />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
@@ -58,6 +59,15 @@ describe('Combobox', () => {
|
||||
expect(input).toHaveAttribute('placeholder', 'Select an option');
|
||||
});
|
||||
|
||||
it('shows the placeholder with the menu open when value is undefined', async () => {
|
||||
render(<Combobox options={options} value={undefined} onChange={onChangeHandler} placeholder="Select an option" />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(input).toHaveAttribute('placeholder', 'Select an option');
|
||||
});
|
||||
|
||||
it('selects value by clicking that needs scrolling', async () => {
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
||||
|
||||
@@ -106,6 +116,73 @@ describe('Combobox', () => {
|
||||
expect(screen.queryByDisplayValue('Option 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['very valid value', '', 0])('should handle an option with %p as a value', async (val) => {
|
||||
const options = [
|
||||
{ label: 'Second option', value: '2' },
|
||||
{ label: 'Default', value: val },
|
||||
];
|
||||
|
||||
const ControlledCombobox = () => {
|
||||
const [value, setValue] = React.useState<string | number | null>(null);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(opt) => {
|
||||
setValue(opt.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ControlledCombobox />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
await userEvent.click(screen.getByRole('option', { name: 'Default' }));
|
||||
expect(screen.queryByDisplayValue('Default')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Default' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
describe('size support', () => {
|
||||
it('should require minWidth to be set with auto width', () => {
|
||||
// @ts-expect-error
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} width="auto" />);
|
||||
});
|
||||
|
||||
it('should change width when typing things with auto width', async () => {
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} width="auto" minWidth={2} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
const inputWrapper = screen.getByTestId('input-wrapper');
|
||||
const initialWidth = getComputedStyle(inputWrapper).width;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'very very long value' } });
|
||||
|
||||
const newWidth = getComputedStyle(inputWrapper).width;
|
||||
|
||||
expect(initialWidth).not.toBe(newWidth);
|
||||
});
|
||||
|
||||
it('should not change width when typing things with fixed width', async () => {
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} width={2} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
|
||||
const inputWrapper = screen.getByTestId('input-wrapper');
|
||||
const initialWidth = getComputedStyle(inputWrapper).width;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'very very long value' } });
|
||||
|
||||
const newWidth = getComputedStyle(inputWrapper).width;
|
||||
|
||||
expect(initialWidth).toBe(newWidth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a value already selected', () => {
|
||||
it('shows an empty text input when opening the menu', async () => {
|
||||
const selectedValue = options[0].value;
|
||||
|
||||
@@ -28,7 +28,10 @@ export type ComboboxOption<T extends string | number = string> = {
|
||||
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
|
||||
// then the onChange handler emits ComboboxOption with the label as non-undefined.
|
||||
interface ComboboxBaseProps<T extends string | number>
|
||||
extends Omit<InputProps, 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange' | 'width'> {
|
||||
extends Pick<
|
||||
InputProps,
|
||||
'placeholder' | 'autoFocus' | 'id' | 'aria-labelledby' | 'disabled' | 'loading' | 'invalid'
|
||||
> {
|
||||
/**
|
||||
* An `X` appears in the UI, which clears the input and sets the value to `null`. Do not use if you have no `null` case.
|
||||
*/
|
||||
@@ -38,20 +41,31 @@ interface ComboboxBaseProps<T extends string | number>
|
||||
*/
|
||||
createCustomValue?: boolean;
|
||||
options: Array<ComboboxOption<T>> | ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
|
||||
onChange: (option: ComboboxOption<T> | null) => void;
|
||||
onChange: (option: ComboboxOption<T>) => void;
|
||||
/**
|
||||
* Most consumers should pass value in as a scalar string | number. However, sometimes with Async because we don't
|
||||
* have the full options loaded to match the value to, consumers may also pass in an Option with a label to display.
|
||||
*/
|
||||
value: T | ComboboxOption<T> | null;
|
||||
value?: T | ComboboxOption<T> | null;
|
||||
/**
|
||||
* Defaults to 100%. Number is a multiple of 8px. 'auto' will size the input to the content.
|
||||
* */
|
||||
width?: number | 'auto';
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
const RECOMMENDED_ITEMS_AMOUNT = 100_000;
|
||||
|
||||
type ClearableConditionals<T extends number | string> =
|
||||
| {
|
||||
isClearable: true;
|
||||
/**
|
||||
* The onChange handler is called with `null` when clearing the Combobox.
|
||||
*/
|
||||
onChange: (option: ComboboxOption<T> | null) => void;
|
||||
}
|
||||
| { isClearable?: false; onChange: (option: ComboboxOption<T>) => void };
|
||||
|
||||
type AutoSizeConditionals =
|
||||
| {
|
||||
width: 'auto';
|
||||
@@ -70,13 +84,16 @@ type AutoSizeConditionals =
|
||||
maxWidth?: never;
|
||||
};
|
||||
|
||||
type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals & ClearableConditionals<T>;
|
||||
|
||||
function itemToString<T extends string | number>(item: ComboboxOption<T> | null) {
|
||||
if (item?.label?.includes('Custom value: ')) {
|
||||
return item?.value.toString();
|
||||
function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
|
||||
if (!item) {
|
||||
return '';
|
||||
}
|
||||
return item?.label ?? item?.value.toString() ?? '';
|
||||
if (item.label?.includes('Custom value: ')) {
|
||||
return item.value.toString();
|
||||
}
|
||||
return item.label ?? item.value.toString();
|
||||
}
|
||||
|
||||
function itemFilter<T extends string | number>(inputValue: string) {
|
||||
@@ -85,8 +102,8 @@ function itemFilter<T extends string | number>(inputValue: string) {
|
||||
return (item: ComboboxOption<T>) => {
|
||||
return (
|
||||
!inputValue ||
|
||||
item?.label?.toLowerCase().includes(lowerCasedInputValue) ||
|
||||
item?.value?.toString().toLowerCase().includes(lowerCasedInputValue)
|
||||
item.label?.toLowerCase().includes(lowerCasedInputValue) ||
|
||||
item.value?.toString().toLowerCase().includes(lowerCasedInputValue)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -109,8 +126,14 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
createCustomValue = false,
|
||||
id,
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
...restProps
|
||||
autoFocus,
|
||||
onBlur,
|
||||
disabled,
|
||||
loading,
|
||||
invalid,
|
||||
} = props;
|
||||
|
||||
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
|
||||
@@ -158,7 +181,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
if (valueProp === undefined || valueProp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,9 +191,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
}
|
||||
|
||||
return index;
|
||||
}, [options, value, isAsync]);
|
||||
}, [valueProp, options, value, isAsync]);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
if (valueProp === undefined || valueProp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedItemIndex !== null && !isAsync) {
|
||||
return options[selectedItemIndex];
|
||||
}
|
||||
@@ -329,7 +356,9 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, rowVirtualizer.range, isOpen);
|
||||
|
||||
const InputComponent = width === 'auto' ? AutoSizeInput : Input;
|
||||
const isAutoSize = width === 'auto';
|
||||
|
||||
const InputComponent = isAutoSize ? AutoSizeInput : Input;
|
||||
|
||||
const suffixIcon = asyncLoading
|
||||
? 'spinner'
|
||||
@@ -343,7 +372,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
return (
|
||||
<div>
|
||||
<InputComponent
|
||||
width={width === 'auto' ? undefined : width}
|
||||
width={isAutoSize ? undefined : width}
|
||||
{...(isAutoSize ? { minWidth, maxWidth } : {})}
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
invalid={invalid}
|
||||
className={styles.input}
|
||||
suffix={
|
||||
<>
|
||||
@@ -368,7 +403,6 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
<Icon name={suffixIcon} />
|
||||
</>
|
||||
}
|
||||
{...restProps}
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
/* Empty onCall to avoid TS error
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from 'react';
|
||||
|
||||
import { measureText } from '../../utils/measureText';
|
||||
|
||||
import { AutoSizeInputContext } from './AutoSizeInputContext';
|
||||
import { Input, Props as InputProps } from './Input';
|
||||
|
||||
export interface Props extends InputProps {
|
||||
@@ -44,34 +45,38 @@ export const AutoSizeInput = React.forwardRef<HTMLInputElement, Props>((props, r
|
||||
}, [placeholder, value, minWidth, maxWidth]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...restProps}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
value={value.toString()}
|
||||
onChange={(event) => {
|
||||
if (onChange) {
|
||||
onChange(event);
|
||||
}
|
||||
setValue(event.currentTarget.value);
|
||||
}}
|
||||
width={inputWidth}
|
||||
onBlur={(event) => {
|
||||
if (onBlur) {
|
||||
onBlur(event);
|
||||
} else if (onCommitChange) {
|
||||
onCommitChange(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(event);
|
||||
} else if (event.key === 'Enter' && onCommitChange) {
|
||||
onCommitChange(event);
|
||||
}
|
||||
}}
|
||||
data-testid={'autosize-input'}
|
||||
/>
|
||||
// Used to tell Input to increase the width properly of the input to fit the text.
|
||||
// See comment in Input.tsx for more details
|
||||
<AutoSizeInputContext.Provider value={true}>
|
||||
<Input
|
||||
{...restProps}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
value={value.toString()}
|
||||
onChange={(event) => {
|
||||
if (onChange) {
|
||||
onChange(event);
|
||||
}
|
||||
setValue(event.currentTarget.value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (onBlur) {
|
||||
onBlur(event);
|
||||
} else if (onCommitChange) {
|
||||
onCommitChange(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(event);
|
||||
} else if (event.key === 'Enter' && onCommitChange) {
|
||||
onCommitChange(event);
|
||||
}
|
||||
}}
|
||||
width={inputWidth}
|
||||
data-testid="autosize-input"
|
||||
/>
|
||||
</AutoSizeInputContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
// Used to tell Input to increase the width properly of the input to fit the text.
|
||||
// See comment in Input.tsx for more details
|
||||
export const AutoSizeInputContext = React.createContext(false);
|
||||
AutoSizeInputContext.displayName = 'AutoSizeInputContext';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { forwardRef, HTMLProps, ReactNode } from 'react';
|
||||
import { forwardRef, HTMLProps, ReactNode, useContext } from 'react';
|
||||
import useMeasure from 'react-use/lib/useMeasure';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -8,6 +8,8 @@ import { stylesFactory, useTheme2 } from '../../themes';
|
||||
import { getFocusStyle, sharedInputStyle } from '../Forms/commonStyles';
|
||||
import { Spinner } from '../Spinner/Spinner';
|
||||
|
||||
import { AutoSizeInputContext } from './AutoSizeInputContext';
|
||||
|
||||
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'size'> {
|
||||
/** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/
|
||||
width?: number;
|
||||
@@ -32,7 +34,17 @@ interface StyleDeps {
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const { className, addonAfter, addonBefore, prefix, suffix, invalid, loading, width = 0, ...restProps } = props;
|
||||
const {
|
||||
className,
|
||||
addonAfter,
|
||||
addonBefore,
|
||||
prefix,
|
||||
suffix: suffixProp,
|
||||
invalid,
|
||||
loading,
|
||||
width = 0,
|
||||
...restProps
|
||||
} = props;
|
||||
/**
|
||||
* Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input
|
||||
* when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)).
|
||||
@@ -41,13 +53,24 @@ export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const [prefixRef, prefixRect] = useMeasure<HTMLDivElement>();
|
||||
const [suffixRef, suffixRect] = useMeasure<HTMLDivElement>();
|
||||
|
||||
// Yes, this is gross - When Input is being wrapped by AutoSizeInput, add the suffix/prefix width to the overall width
|
||||
// so the text content is not clipped. The intention is to make all the input's text appear without overflow/clipping,
|
||||
// which isn't normally how width is used in this component.
|
||||
// This behaviour is not controlled via a prop so we can limit API surface, and remove this as a 'breaking change' later
|
||||
// if a better solution is found.
|
||||
const isInAutoSizeInput = useContext(AutoSizeInputContext);
|
||||
const accessoriesWidth = (prefixRect.width || 0) + (suffixRect.width || 0);
|
||||
const finalWidth = isInAutoSizeInput && width ? width + accessoriesWidth / 8 : width;
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getInputStyles({ theme, invalid: !!invalid, width });
|
||||
|
||||
const styles = getInputStyles({ theme, invalid: !!invalid, width: finalWidth });
|
||||
|
||||
const suffix = suffixProp || (loading && <Spinner inline={true} />);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.wrapper, className)} data-testid={'input-wrapper'}>
|
||||
<div className={cx(styles.wrapper, className)} data-testid="input-wrapper">
|
||||
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
|
||||
|
||||
<div className={styles.inputWrapper}>
|
||||
{prefix && (
|
||||
<div className={styles.prefix} ref={prefixRef}>
|
||||
@@ -65,14 +88,12 @@ export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{(suffix || loading) && (
|
||||
{suffix && (
|
||||
<div className={styles.suffix} ref={suffixRef}>
|
||||
{loading && <Spinner className={styles.loadingIndicator} inline={true} />}
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -381,6 +381,15 @@ func TraceIDFromContext(ctx context.Context, requireSampled bool) string {
|
||||
return spanCtx.TraceID().String()
|
||||
}
|
||||
|
||||
func ServerTimingForSpan(span trace.Span) string {
|
||||
spanCtx := span.SpanContext()
|
||||
if !spanCtx.HasTraceID() || !spanCtx.IsValid() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("00-%s-%s-01", spanCtx.TraceID().String(), spanCtx.SpanID().String())
|
||||
}
|
||||
|
||||
// Error sets the status to error and record the error as an exception in the provided span.
|
||||
func Error(span trace.Span, err error) error {
|
||||
attr := []attribute.KeyValue{}
|
||||
|
||||
@@ -94,6 +94,14 @@ func RequestTracing(tracer tracing.Tracer) web.Middleware {
|
||||
), trace.WithSpanKind(trace.SpanKindServer))
|
||||
defer span.End()
|
||||
|
||||
// inject local root span context into the response via server-timing header
|
||||
// we're doing it this early so that we can capture the root span context
|
||||
// which is not available later-on.
|
||||
serverTimingValue := tracing.ServerTimingForSpan(span)
|
||||
if serverTimingValue != "" {
|
||||
w.Header().Set("server-timing", fmt.Sprintf("traceparent;desc=\"%s\"", serverTimingValue))
|
||||
}
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
// Ensure the response writer's status can be captured.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
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"
|
||||
@@ -114,14 +115,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
return err
|
||||
}
|
||||
|
||||
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storageOpts := apistore.StorageOptions{
|
||||
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
|
||||
internal := &dashboardinternal.Dashboard{}
|
||||
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = scheme.Convert(obj, desiredObj, nil)
|
||||
return desiredObj, err
|
||||
}),
|
||||
}
|
||||
|
||||
// Split dashboards when they are large
|
||||
var largeObjects apistore.LargeObjectSupport
|
||||
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
|
||||
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
|
||||
opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{
|
||||
LargeObjectSupport: largeObjects,
|
||||
})
|
||||
storageOpts.LargeObjectSupport = largeObjects
|
||||
}
|
||||
opts.StorageOptions(dash.GroupResource(), storageOpts)
|
||||
|
||||
storage := map[string]rest.Storage{}
|
||||
storage[dash.StoragePath()] = legacyStore
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
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"
|
||||
@@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
return err
|
||||
}
|
||||
|
||||
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storageOpts := apistore.StorageOptions{
|
||||
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
|
||||
internal := &dashboardinternal.Dashboard{}
|
||||
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = scheme.Convert(obj, desiredObj, nil)
|
||||
return desiredObj, err
|
||||
}),
|
||||
}
|
||||
|
||||
// Split dashboards when they are large
|
||||
var largeObjects apistore.LargeObjectSupport
|
||||
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
|
||||
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
|
||||
opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{
|
||||
LargeObjectSupport: largeObjects,
|
||||
})
|
||||
storageOpts.LargeObjectSupport = largeObjects
|
||||
}
|
||||
opts.StorageOptions(dash.GroupResource(), storageOpts)
|
||||
|
||||
storage := map[string]rest.Storage{}
|
||||
storage[dash.StoragePath()] = legacyStore
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
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"
|
||||
@@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
return err
|
||||
}
|
||||
|
||||
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storageOpts := apistore.StorageOptions{
|
||||
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
|
||||
internal := &dashboardinternal.Dashboard{}
|
||||
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = scheme.Convert(obj, desiredObj, nil)
|
||||
return desiredObj, err
|
||||
}),
|
||||
}
|
||||
|
||||
// Split dashboards when they are large
|
||||
var largeObjects apistore.LargeObjectSupport
|
||||
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
|
||||
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
|
||||
opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{
|
||||
LargeObjectSupport: largeObjects,
|
||||
})
|
||||
storageOpts.LargeObjectSupport = largeObjects
|
||||
}
|
||||
opts.StorageOptions(dash.GroupResource(), storageOpts)
|
||||
|
||||
storage := map[string]rest.Storage{}
|
||||
storage[dash.StoragePath()] = legacyStore
|
||||
|
||||
@@ -35,8 +35,8 @@ func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *tes
|
||||
|
||||
_, cfg := db.InitTestDBWithCfg(t)
|
||||
cfg.HTTPPort = "3001"
|
||||
cfg.GRPCServerNetwork = "tcp"
|
||||
cfg.GRPCServerAddress = "localhost:10000"
|
||||
cfg.GRPCServer.Network = "tcp"
|
||||
cfg.GRPCServer.Address = "localhost:10000"
|
||||
addStorageServerToConfig(t, cfg, dbType)
|
||||
cfg.Target = []string{modules.StorageServer}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func folderTreeCollector(store db.DB) legacyTupleCollector {
|
||||
func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector {
|
||||
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
query := `
|
||||
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id
|
||||
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id, br.role as basic_role_name
|
||||
FROM permission p
|
||||
INNER JOIN role r ON p.role_id = r.id
|
||||
LEFT JOIN user_role ur ON r.id = ur.role_id
|
||||
@@ -128,12 +128,12 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector
|
||||
AND p.kind = ?
|
||||
`
|
||||
type Permission struct {
|
||||
RoleName string `xorm:"role_name"`
|
||||
Action string `xorm:"action"`
|
||||
Kind string
|
||||
Identifier string
|
||||
UserUID string `xorm:"user_uid"`
|
||||
TeamUID string `xorm:"team_uid"`
|
||||
Action string `xorm:"action"`
|
||||
Kind string
|
||||
Identifier string
|
||||
UserUID string `xorm:"user_uid"`
|
||||
TeamUID string `xorm:"team_uid"`
|
||||
BasicRoleName string `xorm:"basic_role_name"`
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
@@ -152,10 +152,9 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector
|
||||
if len(p.UserUID) > 0 {
|
||||
subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "")
|
||||
} else if len(p.TeamUID) > 0 {
|
||||
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, "member")
|
||||
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, zanzana.RelationTeamMember)
|
||||
} else {
|
||||
// FIXME(kalleep): Unsuported role binding (org role). We need to have basic roles in place
|
||||
continue
|
||||
subject = zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(p.BasicRoleName), zanzana.RelationAssignee)
|
||||
}
|
||||
|
||||
tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier)
|
||||
@@ -195,53 +194,7 @@ func tupleStringWithoutCondition(tuple *openfgav1.TupleKey) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// basicRolePermissionsCollector collects permissions for basic roles
|
||||
func basicRolePermissionsCollector(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context, _ int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
const query = `
|
||||
SELECT r.uid as role_uid, p.action, p.kind, p.identifier
|
||||
FROM permission p
|
||||
INNER JOIN role r ON p.role_id = r.id
|
||||
LEFT JOIN builtin_role br ON r.id = br.role_id
|
||||
WHERE r.name LIKE 'basic:%'
|
||||
`
|
||||
type Permission struct {
|
||||
Action string `xorm:"action"`
|
||||
Kind string
|
||||
Identifier string
|
||||
RoleUID string `xorm:"role_uid"`
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.SQL(query).Find(&permissions)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, p := range permissions {
|
||||
subject := zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee")
|
||||
|
||||
tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if tuples[tuple.Object] == nil {
|
||||
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
tuples[tuple.Object][tuple.String()] = tuple
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
}
|
||||
|
||||
// basicRoleBindingsCollects collects role bindings for basic roles
|
||||
// basicRoleBindingsCollector collects role bindings for basic roles
|
||||
func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
query := `
|
||||
@@ -269,10 +222,8 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, b := range bindings {
|
||||
subject := zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, "")
|
||||
|
||||
tuple := &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(b.OrgRole), ""),
|
||||
}
|
||||
@@ -288,6 +239,144 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
|
||||
}
|
||||
}
|
||||
|
||||
func teamRoleBindingsCollector(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
query := `
|
||||
SELECT t.uid AS team_uid, r.uid AS role_uid
|
||||
FROM team_role tr
|
||||
INNER JOIN team t ON tr.team_id = t.id
|
||||
INNER JOIN role r ON tr.role_id = r.id
|
||||
WHERE t.org_id = ?
|
||||
AND r.name NOT LIKE 'managed:%'
|
||||
`
|
||||
type Binding struct {
|
||||
TeamUID string `xorm:"team_uid"`
|
||||
RoleUID string `xorm:"role_uid"`
|
||||
}
|
||||
|
||||
var bindings []Binding
|
||||
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.SQL(query, orgID).Find(&bindings)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, b := range bindings {
|
||||
tuple := &openfgav1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeTeam, b.TeamUID, zanzana.RelationTeamMember),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""),
|
||||
}
|
||||
|
||||
if tuples[tuple.Object] == nil {
|
||||
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
tuples[tuple.Object][tuple.String()] = tuple
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
}
|
||||
|
||||
func userRoleBindingsCollector(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
query := `
|
||||
SELECT u.uid AS user_uid, r.uid AS role_uid
|
||||
FROM user_role ur
|
||||
INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON ur.user_id = u.id
|
||||
INNER JOIN role r ON ur.role_id = r.id
|
||||
WHERE (ur.org_id = 0 OR ur.org_id = ?)
|
||||
AND r.name NOT LIKE 'managed:%'
|
||||
`
|
||||
type Binding struct {
|
||||
UserUID string `xorm:"user_uid"`
|
||||
RoleUID string `xorm:"role_uid"`
|
||||
}
|
||||
|
||||
var bindings []Binding
|
||||
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.SQL(query, orgID).Find(&bindings)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, b := range bindings {
|
||||
tuple := &openfgav1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""),
|
||||
}
|
||||
|
||||
if tuples[tuple.Object] == nil {
|
||||
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
tuples[tuple.Object][tuple.String()] = tuple
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
}
|
||||
|
||||
func rolePermissionsCollector(store db.DB) legacyTupleCollector {
|
||||
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
var query = `
|
||||
SELECT r.uid as role_uid, p.action, p.kind, p.identifier
|
||||
FROM permission p
|
||||
INNER JOIN role r ON p.role_id = r.id
|
||||
LEFT JOIN builtin_role br ON r.id = br.role_id
|
||||
WHERE (r.org_id = 0 OR r.org_id = ?)
|
||||
AND r.name NOT LIKE 'managed:%'
|
||||
`
|
||||
|
||||
type Permission struct {
|
||||
Action string `xorm:"action"`
|
||||
Kind string
|
||||
Identifier string
|
||||
RoleUID string `xorm:"role_uid"`
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.SQL(query, orgID).Find(&permissions)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
||||
|
||||
for _, p := range permissions {
|
||||
tuple, ok := zanzana.TranslateToResourceTuple(
|
||||
zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, zanzana.RelationAssignee),
|
||||
p.Action,
|
||||
p.Kind,
|
||||
p.Identifier,
|
||||
)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if tuples[tuple.Object] == nil {
|
||||
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
tuples[tuple.Object][tuple.String()] = tuple
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
}
|
||||
|
||||
func zanzanaCollector(relations []string) zanzanaTupleCollector {
|
||||
return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) {
|
||||
// list will use continuation token to collect all tuples for object and relation
|
||||
|
||||
@@ -65,8 +65,8 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB,
|
||||
client,
|
||||
),
|
||||
newResourceReconciler(
|
||||
"basic role permissions",
|
||||
basicRolePermissionsCollector(store),
|
||||
"role permissions",
|
||||
rolePermissionsCollector(store),
|
||||
zanzanaCollector(zanzana.FolderRelations),
|
||||
client,
|
||||
),
|
||||
@@ -76,6 +76,18 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB,
|
||||
zanzanaCollector([]string{zanzana.RelationAssignee}),
|
||||
client,
|
||||
),
|
||||
newResourceReconciler(
|
||||
"team role bindings",
|
||||
teamRoleBindingsCollector(store),
|
||||
zanzanaCollector([]string{zanzana.RelationAssignee}),
|
||||
client,
|
||||
),
|
||||
newResourceReconciler(
|
||||
"user role bindings",
|
||||
userRoleBindingsCollector(store),
|
||||
zanzanaCollector([]string{zanzana.RelationAssignee}),
|
||||
client,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,15 +37,13 @@ func (auth orgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib
|
||||
default:
|
||||
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
|
||||
}
|
||||
case org.RoleViewer:
|
||||
case org.RoleViewer, org.RoleNone:
|
||||
switch a.GetVerb() {
|
||||
case "get", "list", "watch":
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
default:
|
||||
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
|
||||
}
|
||||
case org.RoleNone:
|
||||
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "", nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func (a RuntimeConfig) String() string {
|
||||
|
||||
// Supported options are:
|
||||
//
|
||||
// <group>/<version>=true|false for a specific API group and version (e.g. dashboards.grafana.app/v0alpha1=true)
|
||||
// <group>/<version>=true|false for a specific API group and version (e.g. dashboard.grafana.app/v0alpha1=true)
|
||||
// api/all=true|false controls all API versions
|
||||
// api/ga=true|false controls all API versions of the form v[0-9]+
|
||||
// api/beta=true|false controls all API versions of the form v[0-9]+beta[0-9]+
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
)
|
||||
|
||||
func TestReadRuntimeCOnfig(t *testing.T) {
|
||||
out, err := ReadRuntimeConfig("all/all=true,dashboards.grafana.app/v0alpha1=false")
|
||||
out, err := ReadRuntimeConfig("all/all=true,dashboard.grafana.app/v0alpha1=false")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []RuntimeConfig{
|
||||
{Group: "all", Version: "all", Enabled: true},
|
||||
{Group: "dashboards.grafana.app", Version: "v0alpha1", Enabled: false},
|
||||
{Group: "dashboard.grafana.app", Version: "v0alpha1", Enabled: false},
|
||||
}, out)
|
||||
require.Equal(t, "all/all=true", fmt.Sprintf("%v", out[0]))
|
||||
|
||||
|
||||
@@ -88,7 +88,10 @@ func ProvideStandaloneAuthZClient(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newGrpcLegacyClient(authCfg)
|
||||
if cfg.StackID == "" {
|
||||
return newGrpcLegacyClient(authCfg)
|
||||
}
|
||||
return newCloudLegacyClient(authCfg)
|
||||
}
|
||||
|
||||
func newInProcLegacyClient(server *legacyServer) (authzlib.AccessChecker, error) {
|
||||
|
||||
@@ -2,10 +2,10 @@ package mappers
|
||||
|
||||
type VerbToAction map[string]string // e.g. "get" -> "read"
|
||||
type ResourceVerbToAction map[string]VerbToAction // e.g. "dashboards" -> VerbToAction
|
||||
type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboards.grafana.app" -> ResourceVerbToAction
|
||||
type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboard.grafana.app" -> ResourceVerbToAction
|
||||
|
||||
type ResourceToAttribute map[string]string // e.g. "dashboards" -> "uid"
|
||||
type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboards.grafana.app" -> ResourceToAttribute
|
||||
type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboard.grafana.app" -> ResourceToAttribute
|
||||
|
||||
type K8sRbacMapper struct {
|
||||
DefaultActions VerbToAction
|
||||
@@ -28,8 +28,8 @@ func NewK8sRbacMapper() *K8sRbacMapper {
|
||||
},
|
||||
DefaultAttribute: "uid",
|
||||
Actions: GroupResourceVerbToAction{
|
||||
"dashboards.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}},
|
||||
"folders.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}},
|
||||
"dashboard.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}},
|
||||
"folder.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
authzv1 "github.com/grafana/authlib/authz/proto/v1"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
authzv1.UnimplementedAuthzServiceServer
|
||||
authzextv1.UnimplementedAuthzExtentionServiceServer
|
||||
|
||||
store *store.Store
|
||||
logger log.Logger
|
||||
tracer tracing.Tracer
|
||||
}
|
||||
|
||||
func NewService(sql legacysql.LegacyDatabaseProvider, logger log.Logger, tracer tracing.Tracer) *Service {
|
||||
return &Service{
|
||||
store: store.NewStore(sql),
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement Check
|
||||
// func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
|
||||
// This needs to be done for the database provider
|
||||
// ns := req.GetNamespace()
|
||||
// ctx = request.WithNamespace(ctx, ns)
|
||||
|
||||
// return nil, nil
|
||||
// }
|
||||
@@ -0,0 +1,15 @@
|
||||
package store
|
||||
|
||||
import "github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
|
||||
// TODO (gamab): Implement GetRoles, GetTeams, GetFolders, GetPermissions
|
||||
|
||||
type Store struct {
|
||||
sql legacysql.LegacyDatabaseProvider
|
||||
}
|
||||
|
||||
func NewStore(sql legacysql.LegacyDatabaseProvider) *Store {
|
||||
return &Store{
|
||||
sql: sql,
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,22 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/authz/mappers"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/rbac"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/grpcserver"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
)
|
||||
|
||||
func RegisterRBACAuthZService(handler grpcserver.Provider, db legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) {
|
||||
server := rbac.NewService(db, log.New("authz-grpc-server"), tracer)
|
||||
|
||||
srv := handler.GetServer()
|
||||
authzv1.RegisterAuthzServiceServer(srv, server)
|
||||
authzextv1.RegisterAuthzExtentionServiceServer(srv, server)
|
||||
}
|
||||
|
||||
var _ authzv1.AuthzServiceServer = (*legacyServer)(nil)
|
||||
var _ grpc_auth.ServiceAuthFuncOverride = (*legacyServer)(nil)
|
||||
var _ authzlib.ServiceAuthorizeFuncOverride = (*legacyServer)(nil)
|
||||
|
||||
@@ -60,7 +60,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "dash1",
|
||||
Namespace: "org-2",
|
||||
@@ -74,7 +74,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "dash1",
|
||||
Namespace: "org-2",
|
||||
@@ -88,7 +88,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "dash1",
|
||||
Namespace: "org-2",
|
||||
@@ -106,7 +106,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
@@ -131,7 +131,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Name: "dash1",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
@@ -141,7 +141,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
name: "should return error when verb is not set",
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "dash1",
|
||||
Namespace: "org-2",
|
||||
@@ -152,7 +152,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
name: "should return error when subject is not set",
|
||||
req: &authzv1.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "dash1",
|
||||
Namespace: "org-2",
|
||||
@@ -164,7 +164,7 @@ func Test_legacyServer_Check(t *testing.T) {
|
||||
req: &authzv1.CheckRequest{
|
||||
Subject: "user:1",
|
||||
Verb: "get",
|
||||
Group: "dashboards.grafana.app",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "dash1",
|
||||
Namespace: "stacks-2",
|
||||
|
||||
@@ -11,14 +11,14 @@ type TypeInfo struct {
|
||||
}
|
||||
|
||||
var typedResources = map[string]TypeInfo{
|
||||
NewNamespaceResourceIdent(
|
||||
FormatGroupResource(
|
||||
folderalpha1.FolderResourceInfo.GroupResource().Group,
|
||||
folderalpha1.FolderResourceInfo.GroupResource().Resource,
|
||||
): {Type: "folder"},
|
||||
}
|
||||
|
||||
func GetTypeInfo(group, resource string) (TypeInfo, bool) {
|
||||
info, ok := typedResources[NewNamespaceResourceIdent(group, resource)]
|
||||
info, ok := typedResources[FormatGroupResource(group, resource)]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ type Server struct {
|
||||
}
|
||||
|
||||
type storeInfo struct {
|
||||
Id string
|
||||
AuthorizationModelId string
|
||||
ID string
|
||||
ModelID string
|
||||
}
|
||||
|
||||
type ServerOption func(s *Server)
|
||||
|
||||
@@ -17,48 +17,58 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest
|
||||
Groups: make(map[string]*authzextv1.BatchCheckGroupResource),
|
||||
}
|
||||
|
||||
subject := r.GetSubject()
|
||||
store, err := s.getStoreInfo(ctx, r.GetNamespace())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range r.Items {
|
||||
groupPrefix := common.FormatGroupResource(item.GetGroup(), item.GetResource())
|
||||
allowed, err := s.batchCheckItem(ctx, subject, r.Namespace, item)
|
||||
groupResourceAccess := make(map[string]bool)
|
||||
|
||||
for _, item := range r.GetItems() {
|
||||
res, err := s.batchCheckItem(ctx, r, item, store, groupResourceAccess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, ok := batchRes.Groups[groupPrefix]; !ok {
|
||||
batchRes.Groups[groupPrefix] = &authzextv1.BatchCheckGroupResource{
|
||||
groupResource := common.FormatGroupResource(item.GetGroup(), item.GetResource())
|
||||
if _, ok := batchRes.Groups[groupResource]; !ok {
|
||||
batchRes.Groups[groupResource] = &authzextv1.BatchCheckGroupResource{
|
||||
Items: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
batchRes.Groups[groupPrefix].Items[item.GetName()] = allowed
|
||||
batchRes.Groups[groupResource].Items[item.GetName()] = res.GetAllowed()
|
||||
}
|
||||
|
||||
return batchRes, nil
|
||||
}
|
||||
|
||||
func (s *Server) batchCheckItem(ctx context.Context, subject string, namespace string, item *authzextv1.BatchCheckItem) (bool, error) {
|
||||
req := &authzv1.CheckRequest{
|
||||
Namespace: namespace,
|
||||
Subject: subject,
|
||||
Verb: item.GetVerb(),
|
||||
Group: item.GetGroup(),
|
||||
Resource: item.GetResource(),
|
||||
Name: item.GetName(),
|
||||
Folder: item.GetFolder(),
|
||||
Subresource: item.GetSubresource(),
|
||||
func (s *Server) batchCheckItem(
|
||||
ctx context.Context,
|
||||
r *authzextv1.BatchCheckRequest,
|
||||
item *authzextv1.BatchCheckItem,
|
||||
store *storeInfo,
|
||||
groupResourceAccess map[string]bool,
|
||||
) (*authzv1.CheckResponse, error) {
|
||||
var (
|
||||
relation = common.VerbMapping[item.GetVerb()]
|
||||
groupResource = common.FormatGroupResource(item.GetGroup(), item.GetResource())
|
||||
)
|
||||
|
||||
allowed, ok := groupResourceAccess[groupResource]
|
||||
if !ok {
|
||||
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupResourceAccess[groupResource] = res.GetAllowed()
|
||||
}
|
||||
|
||||
if allowed {
|
||||
return &authzv1.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
var res *authzv1.CheckResponse
|
||||
var err error
|
||||
if info, ok := common.GetTypeInfo(item.GetGroup(), item.GetResource()); ok {
|
||||
res, err = s.checkTyped(ctx, req, info)
|
||||
} else {
|
||||
res, err = s.checkGeneric(ctx, req)
|
||||
return s.checkTyped(ctx, r.GetSubject(), relation, item.GetName(), info, store)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return res.Allowed, nil
|
||||
return s.checkGeneric(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), item.GetName(), item.GetFolder(), store)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func newBatch(subject, group, resource string, items []*authzextv1.BatchCheckIte
|
||||
}
|
||||
|
||||
func testBatchCheck(t *testing.T, server *Server) {
|
||||
t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
@@ -44,7 +44,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
assert.False(t, res.Groups[groupPrefix].Items["2"])
|
||||
})
|
||||
|
||||
t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) {
|
||||
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
@@ -54,7 +54,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
assert.Len(t, res.Groups[groupPrefix].Items, 2)
|
||||
})
|
||||
|
||||
t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
@@ -67,7 +67,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
assert.False(t, res.Groups[groupPrefix].Items["2"])
|
||||
})
|
||||
|
||||
t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
@@ -82,7 +82,7 @@ func testBatchCheck(t *testing.T, server *Server) {
|
||||
assert.False(t, res.Groups[groupPrefix].Items["3"])
|
||||
})
|
||||
|
||||
t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
|
||||
t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
|
||||
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
|
||||
res, err := server.BatchCheck(context.Background(), newBatch("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
|
||||
{Name: "1", Folder: "1"},
|
||||
|
||||
@@ -14,28 +14,39 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C
|
||||
ctx, span := tracer.Start(ctx, "authzServer.Check")
|
||||
defer span.End()
|
||||
|
||||
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
return s.checkTyped(ctx, r, info)
|
||||
}
|
||||
return s.checkGeneric(ctx, r)
|
||||
}
|
||||
|
||||
// checkNamespace checks if subject has access through namespace
|
||||
func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
|
||||
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
|
||||
store, err := s.getStoreInfo(ctx, r.GetNamespace())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// Check if subject has access through namespace
|
||||
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.GetAllowed() {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
return s.checkTyped(ctx, r.GetSubject(), relation, r.GetName(), info, store)
|
||||
}
|
||||
return s.checkGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), r.GetName(), r.GetFolder(), store)
|
||||
}
|
||||
|
||||
// checkTyped performes check on the root "namespace". If subject has access through the namespace they have access to
|
||||
// every resource for that "GroupResource".
|
||||
func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) {
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
Object: common.NewNamespaceResourceIdent(group, resource),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -45,22 +56,16 @@ func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*
|
||||
return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil
|
||||
}
|
||||
|
||||
func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info common.TypeInfo) (*authzv1.CheckResponse, error) {
|
||||
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// 1. check if subject has direct access to resource
|
||||
// checkTyped performes checks on our typed resources e.g. folder.
|
||||
func (s *Server) checkTyped(ctx context.Context, subject, relation, name string, info common.TypeInfo, store *storeInfo) (*authzv1.CheckResponse, error) {
|
||||
// Check if subject has direct access to resource
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: common.NewTypedIdent(info.Type, r.GetName()),
|
||||
Object: common.NewTypedIdent(info.Type, name),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -71,40 +76,30 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info c
|
||||
return &authzv1.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
// 2. check if subject has access through namespace
|
||||
nsRes, err := s.checkNamespace(ctx, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &authzv1.CheckResponse{Allowed: nsRes.GetAllowed()}, nil
|
||||
return &authzv1.CheckResponse{Allowed: false}, nil
|
||||
}
|
||||
|
||||
func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
|
||||
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// checkGeneric check our generic "resource" type.
|
||||
func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, resource, name, folder string, store *storeInfo) (*authzv1.CheckResponse, error) {
|
||||
groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource))
|
||||
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
// 1. check if subject has direct access to resource
|
||||
// Check if subject has direct access to resource
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: common.NewResourceIdent(r.GetGroup(), r.GetResource(), r.GetName()),
|
||||
Object: common.NewResourceIdent(group, resource, name),
|
||||
},
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": groupResource,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// FIXME: wrap error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -112,32 +107,22 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au
|
||||
return &authzv1.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
// 2. check if subject has access through namespace
|
||||
nsRes, err := s.checkNamespace(ctx, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if nsRes.GetAllowed() {
|
||||
return &authzv1.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
if r.Folder == "" {
|
||||
if folder == "" {
|
||||
return &authzv1.CheckResponse{Allowed: false}, nil
|
||||
}
|
||||
|
||||
// 3. check if subject has access as a sub resource for the folder
|
||||
// Check if subject has access as a sub resource for the folder
|
||||
res, err = s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
Relation: common.FolderResourceRelation(relation),
|
||||
Object: common.NewFolderIdent(r.GetFolder()),
|
||||
Object: common.NewFolderIdent(folder),
|
||||
},
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": groupResource,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ func testCheck(t *testing.T, server *Server) {
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
res, err := server.Check(context.Background(), newRead("user:1", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
@@ -35,13 +35,13 @@ func testCheck(t *testing.T, server *Server) {
|
||||
assert.False(t, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
|
||||
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
|
||||
res, err := server.Check(context.Background(), newRead("user:2", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
res, err := server.Check(context.Background(), newRead("user:3", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
@@ -52,7 +52,7 @@ func testCheck(t *testing.T, server *Server) {
|
||||
assert.False(t, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
res, err := server.Check(context.Background(), newRead("user:4", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
@@ -71,7 +71,7 @@ func testCheck(t *testing.T, server *Server) {
|
||||
assert.False(t, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
|
||||
t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
|
||||
res, err := server.Check(context.Background(), newRead("user:5", dashboardGroup, dashboardResource, "1", "1"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
@@ -16,31 +16,22 @@ func (s *Server) List(ctx context.Context, r *authzextv1.ListRequest) (*authzext
|
||||
ctx, span := tracer.Start(ctx, "authzServer.List")
|
||||
defer span.End()
|
||||
|
||||
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
return s.listTyped(ctx, r, info)
|
||||
}
|
||||
|
||||
return s.listGeneric(ctx, r)
|
||||
}
|
||||
|
||||
func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info common.TypeInfo) (*authzextv1.ListResponse, error) {
|
||||
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
|
||||
store, err := s.getStoreInfo(ctx, r.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// 1. check if subject has access through namespace because then they can read all of them
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
},
|
||||
})
|
||||
res, err := s.checkNamespace(
|
||||
ctx,
|
||||
r.GetSubject(),
|
||||
relation,
|
||||
r.GetGroup(),
|
||||
r.GetResource(),
|
||||
store,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -49,13 +40,21 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
|
||||
return &authzextv1.ListResponse{All: true}, nil
|
||||
}
|
||||
|
||||
// 2. List all resources user has access too
|
||||
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
|
||||
return s.listTyped(ctx, r.GetSubject(), relation, info, store)
|
||||
}
|
||||
|
||||
return s.listGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
|
||||
}
|
||||
|
||||
func (s *Server) listTyped(ctx context.Context, subject, relation string, info common.TypeInfo, store *storeInfo) (*authzextv1.ListResponse, error) {
|
||||
// List all resources user has access too
|
||||
listRes, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
Type: info.Type,
|
||||
Relation: relation,
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,42 +65,19 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*authzextv1.ListResponse, error) {
|
||||
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func (s *Server) listGeneric(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzextv1.ListResponse, error) {
|
||||
groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource))
|
||||
|
||||
relation := common.VerbMapping[r.GetVerb()]
|
||||
|
||||
// 1. check if subject has access through namespace because then they can read all of them
|
||||
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: r.GetSubject(),
|
||||
Relation: relation,
|
||||
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.Allowed {
|
||||
return &authzextv1.ListResponse{All: true}, nil
|
||||
}
|
||||
|
||||
// 2. List all folders subject has access to resource type in
|
||||
// 1. List all folders subject has access to resource type in
|
||||
folders, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
Type: common.TypeFolder,
|
||||
Relation: common.FolderResourceRelation(relation),
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": groupResource,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -109,16 +85,16 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. List all resource directly assigned to subject
|
||||
// 2. List all resource directly assigned to subject
|
||||
direct, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
Type: common.TypeResource,
|
||||
Relation: relation,
|
||||
User: r.GetSubject(),
|
||||
User: subject,
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
|
||||
"requested_group": groupResource,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -128,7 +104,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
|
||||
|
||||
return &authzextv1.ListResponse{
|
||||
Folders: folderObject(folders.GetObjects()),
|
||||
Items: directObjects(r.GetGroup(), r.GetResource(), direct.GetObjects()),
|
||||
Items: directObjects(group, resource, direct.GetObjects()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ func testList(t *testing.T, server *Server) {
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("user:1 should list resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
t.Run("user:1 should list resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
|
||||
res, err := server.List(context.Background(), newList("user:1", dashboardGroup, dashboardResource))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res.GetItems(), 1)
|
||||
@@ -38,7 +38,7 @@ func testList(t *testing.T, server *Server) {
|
||||
assert.Len(t, res.GetFolders(), 0)
|
||||
})
|
||||
|
||||
t.Run("user:3 should be able to list resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
t.Run("user:3 should be able to list resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
|
||||
res, err := server.List(context.Background(), newList("user:3", dashboardGroup, dashboardResource))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -47,7 +47,7 @@ func testList(t *testing.T, server *Server) {
|
||||
assert.Equal(t, res.GetItems()[0], "1")
|
||||
})
|
||||
|
||||
t.Run("user:4 should be able to list all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
t.Run("user:4 should be able to list all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
|
||||
res, err := server.List(context.Background(), newList("user:4", dashboardGroup, dashboardResource))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res.GetItems(), 0)
|
||||
@@ -64,7 +64,7 @@ func testList(t *testing.T, server *Server) {
|
||||
assert.Equal(t, second, "3")
|
||||
})
|
||||
|
||||
t.Run("user:5 should be get list all dashboards.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) {
|
||||
t.Run("user:5 should be get list all dashboard.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) {
|
||||
res, err := server.List(context.Background(), newList("user:5", dashboardGroup, dashboardResource))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res.GetItems(), 0)
|
||||
|
||||
@@ -19,7 +19,7 @@ func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authze
|
||||
}
|
||||
|
||||
res, err := s.openfga.Read(ctx, &openfgav1.ReadRequest{
|
||||
StoreId: storeInf.Id,
|
||||
StoreId: storeInf.ID,
|
||||
TupleKey: &openfgav1.ReadRequestTupleKey{
|
||||
User: req.GetTupleKey().GetUser(),
|
||||
Relation: req.GetTupleKey().GetRelation(),
|
||||
|
||||
@@ -30,8 +30,8 @@ func (s *Server) getStoreInfo(ctx context.Context, namespace string) (*storeInfo
|
||||
}
|
||||
|
||||
info = storeInfo{
|
||||
Id: store.GetId(),
|
||||
AuthorizationModelId: modelID,
|
||||
ID: store.GetId(),
|
||||
ModelID: modelID,
|
||||
}
|
||||
|
||||
s.stores[namespace] = info
|
||||
|
||||
@@ -71,8 +71,8 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
|
||||
// seed tuples
|
||||
_, err = openfga.Write(context.Background(), &openfgav1.WriteRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: storeInf.ID,
|
||||
AuthorizationModelId: storeInf.ModelID,
|
||||
Writes: &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: []*openfgav1.TupleKey{
|
||||
common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"),
|
||||
|
||||
@@ -29,8 +29,8 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: storeInf.Id,
|
||||
AuthorizationModelId: storeInf.AuthorizationModelId,
|
||||
StoreId: storeInf.ID,
|
||||
AuthorizationModelId: storeInf.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
|
||||
@@ -1448,9 +1448,10 @@ var (
|
||||
{
|
||||
Name: "singleTopNav",
|
||||
Description: "Unifies the top search bar and breadcrumb bar into one",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "exploreLogsShardSplitting",
|
||||
|
||||
@@ -190,7 +190,7 @@ dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,
|
||||
newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false
|
||||
lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false
|
||||
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
|
||||
singleTopNav,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||
singleTopNav,GA,@grafana/grafana-frontend-platform,false,false,true
|
||||
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
|
||||
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
|
||||
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
|
||||
|
||||
|
@@ -3117,17 +3117,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "singleTopNav",
|
||||
"resourceVersion": "1731664029942",
|
||||
"resourceVersion": "1732104041490",
|
||||
"creationTimestamp": "2024-08-29T08:48:32Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-15 09:47:09.942158 +0000 UTC"
|
||||
"grafana.app/updatedTimestamp": "2024-11-20 12:00:41.490792 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Unifies the top search bar and breadcrumb bar into one",
|
||||
"stage": "preview",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/grafana-frontend-platform",
|
||||
"frontend": true
|
||||
"frontend": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,11 +4,10 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServerInterceptor {
|
||||
func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
req any,
|
||||
@@ -16,7 +15,7 @@ func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServ
|
||||
handler grpc.UnaryHandler,
|
||||
) (resp any, err error) {
|
||||
resp, err = handler(ctx, req)
|
||||
if cfg.GRPCServerEnableLogging {
|
||||
if enabled {
|
||||
ctxLogger := logger.FromContext(ctx)
|
||||
if err != nil {
|
||||
ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err)
|
||||
|
||||
@@ -34,7 +34,7 @@ type Provider interface {
|
||||
}
|
||||
|
||||
type gPRCServerService struct {
|
||||
cfg *setting.Cfg
|
||||
cfg setting.GRPCServerSettings
|
||||
logger log.Logger
|
||||
server *grpc.Server
|
||||
address string
|
||||
@@ -44,9 +44,9 @@ type gPRCServerService struct {
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authenticator interceptors.Authenticator, tracer tracing.Tracer, registerer prometheus.Registerer) (Provider, error) {
|
||||
s := &gPRCServerService{
|
||||
cfg: cfg,
|
||||
cfg: cfg.GRPCServer,
|
||||
logger: log.New("grpc-server"),
|
||||
enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer),
|
||||
enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer), // TODO: replace with cfg.GRPCServer.Enabled when we remove feature toggle.
|
||||
startedChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe
|
||||
grpc.StatsHandler(otelgrpc.NewServerHandler()),
|
||||
grpc.ChainUnaryInterceptor(
|
||||
grpcAuth.UnaryServerInterceptor(authenticator.Authenticate),
|
||||
interceptors.LoggingUnaryInterceptor(s.cfg, s.logger), // needs to be registered after tracing interceptor to get trace id
|
||||
interceptors.LoggingUnaryInterceptor(s.logger, s.cfg.EnableLogging), // needs to be registered after tracing interceptor to get trace id
|
||||
middleware.UnaryServerInstrumentInterceptor(grpcRequestDuration),
|
||||
),
|
||||
grpc.ChainStreamInterceptor(
|
||||
@@ -85,16 +85,16 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe
|
||||
),
|
||||
}
|
||||
|
||||
if s.cfg.GRPCServerTLSConfig != nil {
|
||||
opts = append(opts, grpc.Creds(credentials.NewTLS(cfg.GRPCServerTLSConfig)))
|
||||
if s.cfg.TLSConfig != nil {
|
||||
opts = append(opts, grpc.Creds(credentials.NewTLS(s.cfg.TLSConfig)))
|
||||
}
|
||||
|
||||
if s.cfg.GRPCServerMaxRecvMsgSize > 0 {
|
||||
opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.GRPCServerMaxRecvMsgSize))
|
||||
if s.cfg.MaxRecvMsgSize > 0 {
|
||||
opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.MaxRecvMsgSize))
|
||||
}
|
||||
|
||||
if s.cfg.GRPCServerMaxSendMsgSize > 0 {
|
||||
opts = append(opts, grpc.MaxSendMsgSize(s.cfg.GRPCServerMaxSendMsgSize))
|
||||
if s.cfg.MaxSendMsgSize > 0 {
|
||||
opts = append(opts, grpc.MaxSendMsgSize(s.cfg.MaxSendMsgSize))
|
||||
}
|
||||
|
||||
s.server = grpc.NewServer(opts...)
|
||||
@@ -102,9 +102,9 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe
|
||||
}
|
||||
|
||||
func (s *gPRCServerService) Run(ctx context.Context) error {
|
||||
s.logger.Info("Running GRPC server", "address", s.cfg.GRPCServerAddress, "network", s.cfg.GRPCServerNetwork, "tls", s.cfg.GRPCServerTLSConfig != nil, "max_recv_msg_size", s.cfg.GRPCServerMaxRecvMsgSize, "max_send_msg_size", s.cfg.GRPCServerMaxSendMsgSize)
|
||||
s.logger.Info("Running GRPC server", "address", s.cfg.Address, "network", s.cfg.Network, "tls", s.cfg.TLSConfig != nil, "max_recv_msg_size", s.cfg.MaxRecvMsgSize, "max_send_msg_size", s.cfg.MaxSendMsgSize)
|
||||
|
||||
listener, err := net.Listen(s.cfg.GRPCServerNetwork, s.cfg.GRPCServerAddress)
|
||||
listener, err := net.Listen(s.cfg.Network, s.cfg.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GRPC server: failed to listen: %w", err)
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode
|
||||
Limit: perPage,
|
||||
Filter: filter,
|
||||
SignedInUser: c.SignedInUser,
|
||||
CountTokens: true,
|
||||
}
|
||||
serviceAccountSearch, err := api.service.SearchOrgServiceAccounts(ctx, &q)
|
||||
if err != nil {
|
||||
@@ -296,13 +297,6 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode
|
||||
saIDs[saIDString] = true
|
||||
metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true})
|
||||
sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)]
|
||||
tokens, err := api.service.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{
|
||||
OrgID: &sa.OrgId, ServiceAccountID: &sa.Id,
|
||||
})
|
||||
if err != nil {
|
||||
api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id)
|
||||
}
|
||||
sa.Tokens = int64(len(tokens))
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, serviceAccountSearch)
|
||||
|
||||
@@ -402,6 +402,43 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context,
|
||||
if err := sess.Find(&searchResult.ServiceAccounts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// stop here if we don't want to count the number of tokens per service account
|
||||
if !query.CountTokens {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch tokens count for each service account
|
||||
accountIDs := make([]int64, len(searchResult.ServiceAccounts))
|
||||
for i, serviceAccount := range searchResult.ServiceAccounts {
|
||||
accountIDs[i] = serviceAccount.Id
|
||||
}
|
||||
|
||||
tokensSess := dbSession.Table("api_key").
|
||||
Select("service_account_id, COUNT(id) AS token_count").
|
||||
Where("org_id = ?", query.OrgID).
|
||||
In("service_account_id", accountIDs).
|
||||
GroupBy("service_account_id")
|
||||
|
||||
type tokenCount struct {
|
||||
AccountId int64 `xorm:"service_account_id"`
|
||||
TokenCount int64 `xorm:"token_count"`
|
||||
}
|
||||
|
||||
var tokens []tokenCount
|
||||
if err = tokensSess.Find(&tokens); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokenMap := make(map[int64]int64)
|
||||
for _, token := range tokens {
|
||||
tokenMap[token.AccountId] = token.TokenCount
|
||||
}
|
||||
|
||||
for i, account := range searchResult.ServiceAccounts {
|
||||
searchResult.ServiceAccounts[i].Tokens = tokenMap[account.Id]
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -488,41 +488,86 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
t.Skip("skipping test in short mode")
|
||||
}
|
||||
|
||||
db, store := setupTestDatabase(t)
|
||||
|
||||
initUsers := []tests.TestUser{
|
||||
{Name: "satest-1", Role: string(org.RoleViewer), Login: "sa-1-satest-1", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-1", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-1", IsServiceAccount: true},
|
||||
{Name: "usertest-2", Role: string(org.RoleEditor), Login: "usertest-2", IsServiceAccount: false},
|
||||
{Name: "satest-3", Role: string(org.RoleEditor), Login: "sa-1-satest-3", IsServiceAccount: true},
|
||||
{Name: "satest-4", Role: string(org.RoleAdmin), Login: "sa-1-satest-4", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-3", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-3", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-4", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-4", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-5", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-5", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-6", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-6", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-7", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-7", IsServiceAccount: true},
|
||||
{Name: "extsvc-test-8", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-8", IsServiceAccount: true},
|
||||
{Name: "satest-6", Role: string(org.RoleViewer), Login: "sa-1-satest-6", IsServiceAccount: true},
|
||||
{Name: "satest-7", Role: string(org.RoleEditor), Login: "sa-1-satest-7", IsServiceAccount: true},
|
||||
{Name: "satest-8", Role: string(org.RoleAdmin), Login: "sa-1-satest-8", IsServiceAccount: true},
|
||||
}
|
||||
|
||||
db, store := setupTestDatabase(t)
|
||||
orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
|
||||
users, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
|
||||
|
||||
apiKeys := []tests.TestApiKey{
|
||||
{Name: "sa-01-apikey-01", OrgId: orgID, Key: "key01", IsExpired: false, ServiceAccountID: &users[0].ID},
|
||||
{Name: "sa-01-apikey-02", OrgId: orgID, Key: "key02", IsExpired: false, ServiceAccountID: &users[0].ID},
|
||||
{Name: "sa-01-apikey-03", OrgId: orgID, Key: "key03", IsExpired: false, ServiceAccountID: &users[0].ID},
|
||||
{Name: "sa-02-apikey-01", OrgId: orgID, Key: "key04", IsExpired: false, ServiceAccountID: &users[2].ID},
|
||||
{Name: "sa-02-apikey-02", OrgId: orgID, Key: "key05", IsExpired: false, ServiceAccountID: &users[2].ID},
|
||||
{Name: "sa-03-apikey-01", OrgId: orgID, Key: "key06", IsExpired: false, ServiceAccountID: &users[3].ID},
|
||||
}
|
||||
|
||||
tests.SetupApiKeys(t, db, store.cfg, apiKeys)
|
||||
|
||||
userWithPerm := &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: map[int64]map[string][]string{orgID: {serviceaccounts.ActionRead: {serviceaccounts.ScopeAll}}},
|
||||
}
|
||||
|
||||
expectedServiceAccount := func(i int, tokens int64) *serviceaccounts.ServiceAccountDTO {
|
||||
return &serviceaccounts.ServiceAccountDTO{
|
||||
Id: users[i].ID, UID: users[i].UID, Name: users[i].Name, Login: users[i].Login, OrgId: orgID, Role: "None", Tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
desc string
|
||||
query *serviceaccounts.SearchOrgServiceAccountsQuery
|
||||
expectedTotal int64 // Value of the result.TotalCount
|
||||
expectedCount int // Length of the result.ServiceAccounts slice
|
||||
expectedErr error
|
||||
desc string
|
||||
query *serviceaccounts.SearchOrgServiceAccountsQuery
|
||||
expectedTotal int64 // Value of the result.TotalCount
|
||||
expectedServiceAccounts []*serviceaccounts.ServiceAccountDTO
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
desc: "should list all service accounts",
|
||||
desc: "should list all service accounts with tokens count",
|
||||
query: &serviceaccounts.SearchOrgServiceAccountsQuery{
|
||||
OrgID: orgID,
|
||||
SignedInUser: userWithPerm,
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
CountTokens: true,
|
||||
},
|
||||
expectedTotal: 7,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
|
||||
expectedServiceAccount(0, 3),
|
||||
expectedServiceAccount(2, 2),
|
||||
expectedServiceAccount(3, 1),
|
||||
expectedServiceAccount(4, 0),
|
||||
expectedServiceAccount(5, 0),
|
||||
expectedServiceAccount(6, 0),
|
||||
expectedServiceAccount(7, 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should list all service accounts with no tokens count",
|
||||
query: &serviceaccounts.SearchOrgServiceAccountsQuery{
|
||||
OrgID: orgID,
|
||||
SignedInUser: userWithPerm,
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
},
|
||||
expectedTotal: 7,
|
||||
expectedCount: 7,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
|
||||
expectedServiceAccount(0, 0),
|
||||
expectedServiceAccount(2, 0),
|
||||
expectedServiceAccount(3, 0),
|
||||
expectedServiceAccount(4, 0),
|
||||
expectedServiceAccount(5, 0),
|
||||
expectedServiceAccount(6, 0),
|
||||
expectedServiceAccount(7, 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should list no service accounts without permissions",
|
||||
@@ -534,8 +579,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
},
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
},
|
||||
expectedTotal: 0,
|
||||
expectedCount: 0,
|
||||
expectedTotal: 0,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{},
|
||||
},
|
||||
{
|
||||
desc: "should list one service accounts with restricted permissions",
|
||||
@@ -551,7 +596,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
},
|
||||
expectedTotal: 2,
|
||||
expectedCount: 2,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
|
||||
expectedServiceAccount(0, 0),
|
||||
expectedServiceAccount(6, 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should list only external service accounts",
|
||||
@@ -559,9 +607,15 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
OrgID: orgID,
|
||||
SignedInUser: userWithPerm,
|
||||
Filter: serviceaccounts.FilterOnlyExternal,
|
||||
CountTokens: true,
|
||||
},
|
||||
expectedTotal: 4,
|
||||
expectedCount: 4,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
|
||||
expectedServiceAccount(0, 3),
|
||||
expectedServiceAccount(2, 2),
|
||||
expectedServiceAccount(3, 1),
|
||||
expectedServiceAccount(4, 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should return service accounts with sa-1-satest login",
|
||||
@@ -570,9 +624,14 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
Query: "sa-1-satest",
|
||||
SignedInUser: userWithPerm,
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
CountTokens: true,
|
||||
},
|
||||
expectedTotal: 3,
|
||||
expectedCount: 3,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
|
||||
expectedServiceAccount(5, 0),
|
||||
expectedServiceAccount(6, 0),
|
||||
expectedServiceAccount(7, 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should only count service accounts",
|
||||
@@ -582,8 +641,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
CountOnly: true,
|
||||
},
|
||||
expectedTotal: 7,
|
||||
expectedCount: 0,
|
||||
expectedTotal: 7,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{},
|
||||
},
|
||||
{
|
||||
desc: "should paginate result",
|
||||
@@ -595,7 +654,9 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
Filter: serviceaccounts.FilterIncludeAll,
|
||||
},
|
||||
expectedTotal: 7,
|
||||
expectedCount: 1,
|
||||
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
|
||||
expectedServiceAccount(7, 0),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
@@ -609,7 +670,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
|
||||
}
|
||||
|
||||
require.Equal(t, tc.expectedTotal, got.TotalCount)
|
||||
require.Len(t, got.ServiceAccounts, tc.expectedCount)
|
||||
require.Len(t, got.ServiceAccounts, len(tc.expectedServiceAccounts))
|
||||
for i, sa := range got.ServiceAccounts {
|
||||
require.EqualValues(t, tc.expectedServiceAccounts[i], sa)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -628,7 +692,7 @@ func TestIntegrationServiceAccountsStoreImpl_EnableServiceAccounts(t *testing.T)
|
||||
}
|
||||
|
||||
db, store := setupTestDatabase(t)
|
||||
orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
|
||||
_, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
|
||||
|
||||
fetchStates := func() map[int64]bool {
|
||||
sa1, err := store.RetrieveServiceAccount(ctx, &serviceaccounts.GetServiceAccountQuery{OrgID: orgID, ID: 1})
|
||||
|
||||
@@ -120,6 +120,7 @@ type SearchOrgServiceAccountsQuery struct {
|
||||
Page int
|
||||
Limit int
|
||||
CountOnly bool
|
||||
CountTokens bool
|
||||
SignedInUser identity.Requester
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,18 @@ func SetupApiKey(t *testing.T, store db.DB, cfg *setting.Cfg, testKey TestApiKey
|
||||
return key
|
||||
}
|
||||
|
||||
func SetupApiKeys(t *testing.T, store db.DB, cfg *setting.Cfg, testKeys []TestApiKey) []*apikey.APIKey {
|
||||
result := make([]*apikey.APIKey, len(testKeys))
|
||||
for i, testKey := range testKeys {
|
||||
result[i] = SetupApiKey(t, store, cfg, testKey)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SetupUsersServiceAccounts creates in "test org" all users or service accounts passed in parameter
|
||||
// To achieve this, it sets the AutoAssignOrg and AutoAssignOrgId settings.
|
||||
func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (orgID int64) {
|
||||
func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (users []user.User, orgID int64) {
|
||||
role := string(org.RoleNone)
|
||||
|
||||
quotaService := quotaimpl.ProvideService(sqlStore, cfg)
|
||||
@@ -129,8 +138,9 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t
|
||||
cfg.AutoAssignOrg = true
|
||||
cfg.AutoAssignOrgId = int(org.ID)
|
||||
|
||||
users = make([]user.User, len(testUsers))
|
||||
for i := range testUsers {
|
||||
_, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{
|
||||
newUser, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{
|
||||
Login: testUsers[i].Login,
|
||||
IsServiceAccount: testUsers[i].IsServiceAccount,
|
||||
DefaultOrgRole: role,
|
||||
@@ -138,6 +148,8 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t
|
||||
OrgID: org.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
users[i] = *newUser
|
||||
}
|
||||
return org.ID
|
||||
return users, org.ID
|
||||
}
|
||||
+1
-73
@@ -5,11 +5,9 @@ package setting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -480,12 +478,7 @@ type Cfg struct {
|
||||
Zanzana ZanzanaSettings
|
||||
|
||||
// GRPC Server.
|
||||
GRPCServerNetwork string
|
||||
GRPCServerAddress string
|
||||
GRPCServerTLSConfig *tls.Config
|
||||
GRPCServerEnableLogging bool // log request and response of each unary gRPC call
|
||||
GRPCServerMaxRecvMsgSize int
|
||||
GRPCServerMaxSendMsgSize int
|
||||
GRPCServer GRPCServerSettings
|
||||
|
||||
CustomResponseHeaders map[string]string
|
||||
|
||||
@@ -1799,71 +1792,6 @@ func (cfg *Cfg) readAlertingSettings(iniFile *ini.File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error {
|
||||
server := iniFile.Section("grpc_server")
|
||||
errPrefix := "grpc_server:"
|
||||
useTLS := server.Key("use_tls").MustBool(false)
|
||||
certFile := server.Key("cert_file").String()
|
||||
keyFile := server.Key("cert_key").String()
|
||||
if useTLS {
|
||||
serverCert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s error loading X509 key pair: %w", errPrefix, err)
|
||||
}
|
||||
cfg.GRPCServerTLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{serverCert},
|
||||
ClientAuth: tls.NoClientCert,
|
||||
}
|
||||
}
|
||||
|
||||
cfg.GRPCServerNetwork = valueAsString(server, "network", "tcp")
|
||||
cfg.GRPCServerAddress = valueAsString(server, "address", "")
|
||||
cfg.GRPCServerEnableLogging = server.Key("enable_logging").MustBool(false)
|
||||
cfg.GRPCServerMaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0)
|
||||
cfg.GRPCServerMaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0)
|
||||
switch cfg.GRPCServerNetwork {
|
||||
case "unix":
|
||||
if cfg.GRPCServerAddress != "" {
|
||||
// Explicitly provided path for unix domain socket.
|
||||
if stat, err := os.Stat(cfg.GRPCServerAddress); os.IsNotExist(err) {
|
||||
// File does not exist - nice, nothing to do.
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("%s error getting stat for a file: %s", errPrefix, cfg.GRPCServerAddress)
|
||||
} else {
|
||||
if stat.Mode()&fs.ModeSocket == 0 {
|
||||
return fmt.Errorf("%s file %s already exists and is not a unix domain socket", errPrefix, cfg.GRPCServerAddress)
|
||||
}
|
||||
// Unix domain socket file, should be safe to remove.
|
||||
err := os.Remove(cfg.GRPCServerAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s can't remove unix socket file: %s", errPrefix, cfg.GRPCServerAddress)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use temporary file path for a unix domain socket.
|
||||
tf, err := os.CreateTemp("", "gf_grpc_server_api")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s error creating tmp file: %v", errPrefix, err)
|
||||
}
|
||||
unixPath := tf.Name()
|
||||
if err := tf.Close(); err != nil {
|
||||
return fmt.Errorf("%s error closing tmp file: %v", errPrefix, err)
|
||||
}
|
||||
if err := os.Remove(unixPath); err != nil {
|
||||
return fmt.Errorf("%s error removing tmp file: %v", errPrefix, err)
|
||||
}
|
||||
cfg.GRPCServerAddress = unixPath
|
||||
}
|
||||
case "tcp":
|
||||
if cfg.GRPCServerAddress == "" {
|
||||
cfg.GRPCServerAddress = "127.0.0.1:10000"
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s unsupported network %s", errPrefix, cfg.GRPCServerNetwork)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error {
|
||||
snapshots := iniFile.Section("snapshots")
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type GRPCServerSettings struct {
|
||||
Enabled bool
|
||||
Network string
|
||||
Address string // with flags, call Process to fill this field defaults
|
||||
TLSConfig *tls.Config // with flags, call Process to fill this field
|
||||
EnableLogging bool // log request and response of each unary gRPC call
|
||||
MaxRecvMsgSize int
|
||||
MaxSendMsgSize int
|
||||
|
||||
// Internal fields
|
||||
useTLS bool
|
||||
certFile string
|
||||
keyFile string
|
||||
}
|
||||
|
||||
func gRPCServerSettingsError(msg string, args ...interface{}) error {
|
||||
return fmt.Errorf("grpc_server: "+msg, args...)
|
||||
}
|
||||
|
||||
func (c *GRPCServerSettings) Process() error {
|
||||
err := c.processTLSConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.processAddress()
|
||||
}
|
||||
|
||||
func (c *GRPCServerSettings) processTLSConfig() error {
|
||||
if !c.useTLS {
|
||||
return nil
|
||||
}
|
||||
serverCert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile)
|
||||
if err != nil {
|
||||
return gRPCServerSettingsError("error loading X509 key pair: %w", err)
|
||||
}
|
||||
c.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{serverCert},
|
||||
ClientAuth: tls.NoClientCert,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GRPCServerSettings) processAddress() error {
|
||||
switch c.Network {
|
||||
case "unix":
|
||||
if c.Address != "" {
|
||||
// Explicitly provided path for unix domain socket.
|
||||
if stat, err := os.Stat(c.Address); os.IsNotExist(err) {
|
||||
// File does not exist - nice, nothing to do.
|
||||
} else if err != nil {
|
||||
return gRPCServerSettingsError("error getting stat for a file: %s", c.Address)
|
||||
} else {
|
||||
if stat.Mode()&fs.ModeSocket == 0 {
|
||||
return gRPCServerSettingsError("file %s already exists and is not a unix domain socket", c.Address)
|
||||
}
|
||||
// Unix domain socket file, should be safe to remove.
|
||||
err := os.Remove(c.Address)
|
||||
if err != nil {
|
||||
return gRPCServerSettingsError("can't remove unix socket file: %s", c.Address)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use temporary file path for a unix domain socket.
|
||||
tf, err := os.CreateTemp("", "gf_grpc_server_api")
|
||||
if err != nil {
|
||||
return gRPCServerSettingsError("error creating tmp file: %v", err)
|
||||
}
|
||||
unixPath := tf.Name()
|
||||
if err := tf.Close(); err != nil {
|
||||
return gRPCServerSettingsError("error closing tmp file: %v", err)
|
||||
}
|
||||
if err := os.Remove(unixPath); err != nil {
|
||||
return gRPCServerSettingsError("error removing tmp file: %v", err)
|
||||
}
|
||||
c.Address = unixPath
|
||||
}
|
||||
return nil
|
||||
case "tcp":
|
||||
if c.Address == "" {
|
||||
c.Address = "127.0.0.1:10000"
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return gRPCServerSettingsError("unsupported network %s", c.Network)
|
||||
}
|
||||
}
|
||||
|
||||
func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error {
|
||||
server := iniFile.Section("grpc_server")
|
||||
|
||||
// This setting can be used to replace the `FlagGrpcServer` feature flag
|
||||
cfg.GRPCServer.Enabled = server.Key("enabled").MustBool(false)
|
||||
|
||||
cfg.GRPCServer.useTLS = server.Key("use_tls").MustBool(false)
|
||||
cfg.GRPCServer.certFile = server.Key("cert_file").String()
|
||||
cfg.GRPCServer.keyFile = server.Key("cert_key").String()
|
||||
|
||||
if err := cfg.GRPCServer.processTLSConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.GRPCServer.Network = valueAsString(server, "network", "tcp")
|
||||
cfg.GRPCServer.Address = valueAsString(server, "address", "")
|
||||
cfg.GRPCServer.EnableLogging = server.Key("enable_logging").MustBool(false)
|
||||
cfg.GRPCServer.MaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0)
|
||||
cfg.GRPCServer.MaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0)
|
||||
|
||||
return cfg.GRPCServer.processAddress()
|
||||
}
|
||||
|
||||
func (c *GRPCServerSettings) AddFlags(fs *pflag.FlagSet) {
|
||||
fs.BoolVar(&c.Enabled, "grpc-server-enabled", false, "Enable gRPC server")
|
||||
fs.StringVar(&c.Network, "grpc-server-network", "tcp", "Network type for the gRPC server (tcp, unix)")
|
||||
fs.StringVar(&c.Address, "grpc-server-address", "", "Address for the gRPC server")
|
||||
fs.BoolVar(&c.EnableLogging, "grpc-server-enable-logging", false, "Enable logging of gRPC requests and responses")
|
||||
fs.IntVar(&c.MaxRecvMsgSize, "grpc-server-max-recv-msg-size", 0, "Maximum size of a gRPC request message in bytes")
|
||||
fs.IntVar(&c.MaxSendMsgSize, "grpc-server-max-send-msg-size", 0, "Maximum size of a gRPC response message in bytes")
|
||||
|
||||
// Internal flags, we need to call ProcessTLSConfig
|
||||
fs.BoolVar(&c.useTLS, "grpc-server-use-tls", false, "Enable TLS for the gRPC server")
|
||||
fs.StringVar(&c.certFile, "grpc-server-cert-file", "", "Path to the certificate file for the gRPC server")
|
||||
fs.StringVar(&c.keyFile, "grpc-server-key-file", "", "Path to the certificate key file for the gRPC server")
|
||||
}
|
||||
@@ -44,6 +44,7 @@ var _ storage.Interface = (*Storage)(nil)
|
||||
// Optional settings that apply to a single resource
|
||||
type StorageOptions struct {
|
||||
LargeObjectSupport LargeObjectSupport
|
||||
InternalConversion func([]byte, runtime.Object) (runtime.Object, error)
|
||||
}
|
||||
|
||||
// Storage implements storage.Interface and storage resources as JSON files on disk.
|
||||
@@ -132,6 +133,14 @@ func (s *Storage) Versioner() storage.Versioner {
|
||||
return s.versioner
|
||||
}
|
||||
|
||||
func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Object, error) {
|
||||
if s.opts.InternalConversion != nil {
|
||||
return s.opts.InternalConversion(data, obj)
|
||||
}
|
||||
obj, _, err := s.codec.Decode(data, nil, obj)
|
||||
return obj, err
|
||||
}
|
||||
|
||||
// Create adds a new object at a key unless it already exists. 'ttl' is time-to-live
|
||||
// in seconds (0 means forever). If no error is returned and out is not nil, out will be
|
||||
// set to the read value from database.
|
||||
@@ -310,7 +319,7 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions,
|
||||
return resource.GetError(rsp.Error)
|
||||
}
|
||||
|
||||
_, _, err = s.codec.Decode(rsp.Value, nil, objPtr)
|
||||
_, err = s.convertToObject(rsp.Value, objPtr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -360,7 +369,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti
|
||||
}
|
||||
|
||||
for _, item := range rsp.Items {
|
||||
obj, _, err := s.codec.Decode(item.Value, nil, s.newFunc())
|
||||
obj, err := s.convertToObject(item.Value, s.newFunc())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -461,7 +470,7 @@ func (s *Storage) GuaranteedUpdate(
|
||||
existingObj = s.newFunc()
|
||||
if len(rsp.Value) > 0 {
|
||||
created = false
|
||||
_, _, err = s.codec.Decode(rsp.Value, nil, existingObj)
|
||||
_, err = s.convertToObject(rsp.Value, existingObj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||
"github.com/grafana/grafana/pkg/services/authn/grpcutils"
|
||||
"github.com/grafana/grafana/pkg/services/authz"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -32,6 +33,7 @@ func ProvideUnifiedStorageClient(
|
||||
db infraDB.DB,
|
||||
tracer tracing.Tracer,
|
||||
reg prometheus.Registerer,
|
||||
authzc authz.Client,
|
||||
) (resource.ResourceClient, error) {
|
||||
// See: apiserver.ApplyGrafanaConfig(cfg, features, o)
|
||||
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||
@@ -95,7 +97,7 @@ func ProvideUnifiedStorageClient(
|
||||
|
||||
// Use the local SQL
|
||||
default:
|
||||
server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg)
|
||||
server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg, authzc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/authlib/claims"
|
||||
@@ -24,3 +26,82 @@ func (c *staticAuthzClient) Compile(ctx context.Context, id claims.AuthInfo, req
|
||||
}
|
||||
|
||||
var _ authz.AccessClient = &staticAuthzClient{}
|
||||
|
||||
type groupResource map[string]map[string]interface{}
|
||||
|
||||
// authzLimitedClient is a client that enforces RBAC for the limited number of groups and resources.
|
||||
// This is a temporary solution until the authz service is fully implemented.
|
||||
// The authz service will be responsible for enforcing RBAC.
|
||||
// For now, it makes one call to the authz service for each list items. This is known to be inefficient.
|
||||
type authzLimitedClient struct {
|
||||
client authz.AccessChecker
|
||||
// whitelist is a map of group to resources that are compatible with RBAC.
|
||||
whitelist groupResource
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAuthzLimitedClient creates a new authzLimitedClient.
|
||||
func NewAuthzLimitedClient(client authz.AccessChecker) authz.AccessClient {
|
||||
logger := slog.Default().With("logger", "limited-authz-client")
|
||||
return &authzLimitedClient{
|
||||
client: client,
|
||||
whitelist: groupResource{
|
||||
"dashboard.grafana.app": map[string]interface{}{"dashboards": nil},
|
||||
"folder.grafana.app": map[string]interface{}{"folders": nil},
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Check implements authz.AccessClient.
|
||||
func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) {
|
||||
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", false, "allowed", true)
|
||||
return authz.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
t := time.Now()
|
||||
resp, err := c.client.Check(ctx, id, req)
|
||||
if err != nil {
|
||||
c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "error", err, "duration", time.Since(t))
|
||||
return resp, err
|
||||
}
|
||||
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "allowed", resp.Allowed, "duration", time.Since(t))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Compile implements authz.AccessClient.
|
||||
func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) {
|
||||
return func(namespace string, name, folder string) bool {
|
||||
// TODO: Implement For now we perform the check for each item.
|
||||
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
|
||||
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", false, "allowed", true)
|
||||
return true
|
||||
}
|
||||
t := time.Now()
|
||||
r, err := c.client.Check(ctx, id, authz.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: req.Group,
|
||||
Resource: req.Resource,
|
||||
Namespace: namespace,
|
||||
Name: name,
|
||||
Folder: folder,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "error", err, "duration", time.Since(t))
|
||||
return false
|
||||
}
|
||||
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "allowed", r.Allowed, "duration", time.Since(t))
|
||||
return r.Allowed
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c authzLimitedClient) IsCompatibleWithRBAC(group, resource string) bool {
|
||||
if _, ok := c.whitelist[group]; ok {
|
||||
if _, ok := c.whitelist[group][resource]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var _ authz.AccessClient = &authzLimitedClient{}
|
||||
@@ -0,0 +1,62 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthzLimitedClient_Check(t *testing.T) {
|
||||
mockClient := &staticAuthzClient{allowed: false}
|
||||
client := NewAuthzLimitedClient(mockClient)
|
||||
|
||||
tests := []struct {
|
||||
group string
|
||||
resource string
|
||||
expected bool
|
||||
}{
|
||||
{"dashboard.grafana.app", "dashboards", false},
|
||||
{"folder.grafana.app", "folders", false},
|
||||
{"unknown.group", "unknown.resource", true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
req := authz.CheckRequest{
|
||||
Group: test.group,
|
||||
Resource: test.resource,
|
||||
}
|
||||
resp, err := client.Check(context.Background(), nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.expected, resp.Allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthzLimitedClient_Compile(t *testing.T) {
|
||||
mockClient := &staticAuthzClient{allowed: false}
|
||||
client := NewAuthzLimitedClient(mockClient)
|
||||
|
||||
tests := []struct {
|
||||
group string
|
||||
resource string
|
||||
expected bool
|
||||
}{
|
||||
{"dashboard.grafana.app", "dashboards", false},
|
||||
{"folder.grafana.app", "folders", false},
|
||||
{"unknown.group", "unknown.resource", true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
req := authz.ListRequest{
|
||||
Group: test.group,
|
||||
Resource: test.resource,
|
||||
}
|
||||
checker, err := client.Compile(context.Background(), nil, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, checker)
|
||||
|
||||
result := checker("namespace", "name", "folder")
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user