Merge branch 'main' into grafana-git-ui-sync
# Conflicts: # .betterer.results # public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx
This commit is contained in:
commit
8d041bb8ba
143 files changed
+2872
-926
No files matched your search
@@ -762,6 +762,8 @@ embed.go @grafana/grafana-as-code
|
||||
/.github/workflows/close-milestone.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/release-pr.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/release-comms.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/migrate-prs.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/create-next-release-branch.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/codeowners-validator.yml @tolzhabayev
|
||||
/.github/workflows/codeql-analysis.yml @DanCech
|
||||
/.github/workflows/commands.yml @torkelo
|
||||
|
||||
@@ -10,24 +10,18 @@ jobs:
|
||||
if: github.repository == 'grafana/grafana'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "grafana/grafana-github-actions"
|
||||
path: ./actions
|
||||
ref: main
|
||||
- name: Install Actions
|
||||
run: npm install --production --prefix ./actions
|
||||
- name: "Generate token"
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
|
||||
with:
|
||||
app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }}
|
||||
private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }}
|
||||
- run: git config --global user.email '132647405+grafana-delivery-bot[bot]@users.noreply.github.com'
|
||||
- run: git config --global user.name 'grafana-delivery-bot[bot]'
|
||||
- run: git remote set-url origin "https://grafana-delivery-bot:${{ steps.generate_token.outputs.token }}@github.com/grafana/grafana.git"
|
||||
- name: Run backport
|
||||
uses: ./actions/backport
|
||||
uses: grafana/grafana-github-actions-go/backport@main
|
||||
with:
|
||||
metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}}
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
labelsToAdd: "backport"
|
||||
title: "[{{base}}] {{originalTitle}}"
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Create next release branch
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ownerRepo:
|
||||
type: string
|
||||
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
|
||||
required: true
|
||||
source:
|
||||
description: The release branch to increment (eg providing `release-11.2.3` will result in `release-11.2.4` being created)
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
token:
|
||||
required: true
|
||||
outputs:
|
||||
branch:
|
||||
description: The new branch that was created
|
||||
value: ${{ jobs.main.outputs.branch }}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ownerRepo:
|
||||
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
|
||||
source:
|
||||
description: The release branch to increment (eg providing `release-11.2.3` will result in `release-11.2.4` being created)
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
token:
|
||||
required: true
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
steps:
|
||||
- name: Create release branch
|
||||
id: branch
|
||||
uses: grafana/grafana-github-actions-go/bump-release@main
|
||||
with:
|
||||
ownerRepo: ${{ inputs.ownerRepo }}
|
||||
source: ${{ inputs.source }}
|
||||
token: ${{ secrets.token }}
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Migrate open PRs
|
||||
# Migrate open PRs from a superseded release branch to the current release branch and notify authors
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
from:
|
||||
description: 'The base branch to check for open PRs'
|
||||
required: true
|
||||
type: string
|
||||
to:
|
||||
description: 'The base branch to migrate open PRs to'
|
||||
required: true
|
||||
type: string
|
||||
ownerRepo:
|
||||
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
token:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from:
|
||||
description: 'The base branch to check for open PRs'
|
||||
required: true
|
||||
type: string
|
||||
to:
|
||||
description: 'The base branch to migrate open PRs to'
|
||||
required: true
|
||||
type: string
|
||||
ownerRepo:
|
||||
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
token:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Migrate PRs
|
||||
uses: grafana/grafana-github-actions-go/migrate-open-prs@main
|
||||
with:
|
||||
token: ${{ secrets.token }}
|
||||
ownerRepo: ${{ inputs.ownerRepo }}
|
||||
from: ${{ inputs.from }}
|
||||
to: ${{ inputs.to }}
|
||||
binary_release_tag: 'dev'
|
||||
@@ -3,7 +3,7 @@ name: publish-technical-documentation-release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- v[0-9]+.[0-9]+.x
|
||||
- release-v[0-9]+.[0-9]+.[0-9]+
|
||||
tags:
|
||||
- v[0-9]+.[0-9]+.[0-9]+
|
||||
paths:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: grafana/writers-toolkit/publish-technical-documentation-release@publish-technical-documentation-release/v2
|
||||
with:
|
||||
release_tag_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
|
||||
release_branch_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.x$"
|
||||
release_branch_with_patch_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
|
||||
release_branch_regexp: "^release-(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
|
||||
release_branch_with_patch_regexp: "^release-(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
|
||||
website_directory: content/docs/grafana
|
||||
version_suffix: ""
|
||||
@@ -27,10 +27,18 @@ jobs:
|
||||
name: Setup and establish latest
|
||||
outputs:
|
||||
version: ${{ steps.output.outputs.version }}
|
||||
release_branch: ${{ steps.output.outputs.release_branch }}
|
||||
dry_run: ${{ steps.output.outputs.dry_run }}
|
||||
latest: ${{ steps.output.outputs.latest }}
|
||||
token: ${{ steps.output.outputs.token }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Generate token"
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
|
||||
with:
|
||||
app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }}
|
||||
private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }}
|
||||
# The github-release action expects a `LATEST` value of a string of either '1' or '0'
|
||||
- if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
@@ -49,9 +57,51 @@ jobs:
|
||||
echo "latest: $LATEST"
|
||||
echo "version: $VERSION"
|
||||
|
||||
echo "release_branch=$(echo $VERSION | sed -s 's/^v/release-/g')" >> "$GITHUB_OUTPUT"
|
||||
echo "token=${{ steps.generate_token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
create_next_release_branch_grafana:
|
||||
name: Create next release branch (Grafana)
|
||||
needs: setup
|
||||
uses: ./.github/workflows/create-next-release-branch.yml
|
||||
with:
|
||||
ownerRepo: 'grafana/grafana'
|
||||
source: ${{ needs.setup.outputs.release_branch }}
|
||||
secrets:
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
create_next_release_branch_enterprise:
|
||||
name: Create next release branch (Grafana Enterprise)
|
||||
needs: setup
|
||||
uses: ./.github/workflows/create-next-release-branch.yml
|
||||
with:
|
||||
ownerRepo: 'grafana/grafana'
|
||||
source: ${{ needs.setup.outputs.release_branch }}
|
||||
secrets:
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
migrate_prs_grafana:
|
||||
needs:
|
||||
- setup
|
||||
- create_next_release_branch_grafana
|
||||
uses: ./.github/workflows/migrate-prs.yml
|
||||
with:
|
||||
ownerRepo: 'grafana/grafana'
|
||||
from: ${{ needs.setup.outputs.release_branch }}
|
||||
to: ${{ needs.create_next_release_branch_grafana.outputs.branch }}
|
||||
secrets:
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
migrate_prs_enterprise:
|
||||
needs:
|
||||
- setup
|
||||
- create_next_release_branch_enterprise
|
||||
uses: ./.github/workflows/migrate-prs.yml
|
||||
with:
|
||||
ownerRepo: 'grafana/grafana-enterprise'
|
||||
from: ${{ needs.setup.outputs.release_branch }}
|
||||
to: ${{ needs.create_next_release_branch_enterprise.outputs.branch }}
|
||||
secrets:
|
||||
token: ${{ needs.setup.outputs.token }}
|
||||
post_changelog_on_forum:
|
||||
needs: setup
|
||||
uses: ./.github/workflows/community-release.yml
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Please refrain from including any processes that do not result in code changes in this workflow. Instead, they should
|
||||
# either be triggered in the release promotion process or in the release comms process (that is triggered by merging
|
||||
# this PR).
|
||||
name: Complete a Grafana release
|
||||
name: Grafana Release PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
target:
|
||||
required: true
|
||||
type: string
|
||||
description: The base branch that these changes are being merged into
|
||||
description: The release branch pattern (eg v9.5.x) that these changes are being merged into
|
||||
backport:
|
||||
required: false
|
||||
type: string
|
||||
@@ -61,10 +61,17 @@ jobs:
|
||||
with:
|
||||
app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }}
|
||||
private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }}
|
||||
- name: Get release branch
|
||||
id: branch
|
||||
uses: grafana/grafana-github-actions-go/latest-release-branch@main
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
ownerRepo: 'grafana/grafana'
|
||||
pattern: ${{ inputs.target }}
|
||||
- name: Checkout Grafana
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.target }}
|
||||
ref: ${{ steps.branch.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Checkout Grafana (main)
|
||||
@@ -124,7 +131,6 @@ jobs:
|
||||
rm -f CHANGELOG.part changelog_items.md
|
||||
|
||||
git diff CHANGELOG.md
|
||||
|
||||
- name: "Prettify CHANGELOG.md"
|
||||
run: npx prettier --write CHANGELOG.md
|
||||
- name: Commit CHANGELOG.md changes
|
||||
@@ -137,7 +143,7 @@ jobs:
|
||||
|
||||
- name: Add package.json changes
|
||||
run: |
|
||||
git add package.json lerna.json yarn.lock packages public
|
||||
git add package.json lerna.json yarn.lock packages public e2e/test-plugins
|
||||
git commit -m "Update version to ${{ inputs.version }}"
|
||||
|
||||
- name: Git push
|
||||
@@ -151,7 +157,7 @@ jobs:
|
||||
$( [ "x${{ inputs.latest }}" == "xtrue" ] && printf %s '-l "release/latest"') \
|
||||
-l "no-changelog" \
|
||||
--dry-run=${{ inputs.dry_run }} \
|
||||
-B "${{ inputs.target }}" \
|
||||
-B "${{ steps.branch.outputs.branch }}" \
|
||||
--title "Release: ${{ inputs.version }}" \
|
||||
--body "These code changes must be merged after a release is complete"
|
||||
env:
|
||||
@@ -165,7 +171,7 @@ jobs:
|
||||
-l "product-approved" \
|
||||
-l "no-changelog" \
|
||||
--dry-run=${{ inputs.dry_run }} \
|
||||
-B "${{ inputs.target }}" \
|
||||
-B "${{ steps.branch.outputs.branch }}" \
|
||||
--title "Release: ${{ inputs.version }}" \
|
||||
--body "These code changes must be merged after a release is complete"
|
||||
env:
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks/plugincheck"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -20,7 +20,7 @@ type CheckService interface {
|
||||
type Service struct {
|
||||
datasourceSvc datasources.DataSourceService
|
||||
pluginStore pluginstore.Store
|
||||
pluginContextProvider datasource.PluginContextWrapper
|
||||
pluginContextProvider *plugincontext.Provider
|
||||
pluginClient plugins.Client
|
||||
pluginRepo repo.Service
|
||||
pluginPreinstall plugininstaller.Preinstall
|
||||
@@ -28,7 +28,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
func ProvideService(datasourceSvc datasources.DataSourceService, pluginStore pluginstore.Store,
|
||||
pluginContextProvider datasource.PluginContextWrapper, pluginClient plugins.Client,
|
||||
pluginContextProvider *plugincontext.Provider, pluginClient plugins.Client,
|
||||
pluginRepo repo.Service, pluginPreinstall plugininstaller.Preinstall, managedPlugins managedplugins.Manager) *Service {
|
||||
return &Service{
|
||||
datasourceSvc: datasourceSvc,
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func New(
|
||||
datasourceSvc datasources.DataSourceService,
|
||||
pluginStore pluginstore.Store,
|
||||
pluginContextProvider datasource.PluginContextWrapper,
|
||||
pluginContextProvider pluginContextProvider,
|
||||
pluginClient plugins.Client,
|
||||
) checks.Check {
|
||||
return &check{
|
||||
@@ -32,7 +32,7 @@ func New(
|
||||
type check struct {
|
||||
DatasourceSvc datasources.DataSourceService
|
||||
PluginStore pluginstore.Store
|
||||
PluginContextProvider datasource.PluginContextWrapper
|
||||
PluginContextProvider pluginContextProvider
|
||||
PluginClient plugins.Client
|
||||
}
|
||||
|
||||
@@ -62,11 +62,11 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check
|
||||
}
|
||||
|
||||
// Health check execution
|
||||
pCtx, err := c.PluginContextProvider.PluginContextForDataSource(ctx, &backend.DataSourceInstanceSettings{
|
||||
Type: ds.Type,
|
||||
UID: ds.UID,
|
||||
APIVersion: ds.APIVersion,
|
||||
})
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pCtx, err := c.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
|
||||
if err != nil {
|
||||
klog.ErrorS(err, "Error creating plugin context", "datasource", ds.Name)
|
||||
continue
|
||||
@@ -96,3 +96,7 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check
|
||||
Errors: dsErrs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type pluginContextProvider interface {
|
||||
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,8 @@ func TestCheck_Run(t *testing.T) {
|
||||
PluginClient: mockPluginClient,
|
||||
}
|
||||
|
||||
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
||||
report, err := check.Run(ctx, &advisor.CheckSpec{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), report.Count)
|
||||
@@ -51,7 +53,8 @@ func TestCheck_Run(t *testing.T) {
|
||||
PluginClient: mockPluginClient,
|
||||
}
|
||||
|
||||
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
||||
report, err := check.Run(ctx, &advisor.CheckSpec{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), report.Count)
|
||||
@@ -74,7 +77,8 @@ func TestCheck_Run(t *testing.T) {
|
||||
PluginClient: mockPluginClient,
|
||||
}
|
||||
|
||||
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
|
||||
report, err := check.Run(ctx, &advisor.CheckSpec{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), report.Count)
|
||||
@@ -94,12 +98,10 @@ func (m *MockDatasourceSvc) GetAllDataSources(ctx context.Context, query *dataso
|
||||
}
|
||||
|
||||
type MockPluginContextProvider struct {
|
||||
datasource.PluginContextWrapper
|
||||
|
||||
pCtx backend.PluginContext
|
||||
}
|
||||
|
||||
func (m *MockPluginContextProvider) PluginContextForDataSource(ctx context.Context, datasourceSettings *backend.DataSourceInstanceSettings) (backend.PluginContext, error) {
|
||||
func (m *MockPluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) {
|
||||
return m.pCtx, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,9 @@ disable_brute_force_login_protection = false
|
||||
# max number of failed login attempts before user gets locked
|
||||
brute_force_login_protection_max_attempts = 5
|
||||
|
||||
# disable protection against brute force login attempts by IP address
|
||||
disable_ip_address_login_protection = true
|
||||
|
||||
# set to true if you host Grafana behind HTTPS. default is false.
|
||||
cookie_secure = false
|
||||
|
||||
|
||||
@@ -360,6 +360,9 @@
|
||||
# max number of failed login attempts before user gets locked
|
||||
;brute_force_login_protection_max_attempts = 5
|
||||
|
||||
# disable protection against brute force login attempts by IP address
|
||||
; disable_ip_address_login_protection = true
|
||||
|
||||
# set to true if you host Grafana behind HTTPS. default is false.
|
||||
;cookie_secure = false
|
||||
|
||||
|
||||
@@ -19,11 +19,6 @@ title: Create and manage reports
|
||||
description: Generate and share PDF reports from your Grafana dashboards
|
||||
weight: 600
|
||||
refs:
|
||||
change-ui-theme:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
|
||||
grafana-enterprise:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/
|
||||
|
||||
@@ -11,6 +11,12 @@ menuTitle: Settings
|
||||
title: Reporting settings
|
||||
description: Manage organizational Reporting settings
|
||||
weight: 700
|
||||
refs:
|
||||
change-ui-theme:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
|
||||
---
|
||||
|
||||
# Reporting settings
|
||||
|
||||
@@ -139,7 +139,6 @@ The **412** status code is used for explaining that you cannot create the dashbo
|
||||
There can be different reasons for this:
|
||||
|
||||
- The dashboard has been changed by someone else, `status=version-mismatch`
|
||||
- A dashboard with the same name in the folder already exists, `status=name-exists`
|
||||
- A dashboard with the same uid already exists, `status=name-exists`
|
||||
- The dashboard belongs to plugin `<plugin title>`, `status=plugin-dashboard`
|
||||
|
||||
@@ -156,8 +155,6 @@ Content-Length: 97
|
||||
}
|
||||
```
|
||||
|
||||
In case of title already exists the `status` property will be `name-exists`.
|
||||
|
||||
## Get dashboard by uid
|
||||
|
||||
`GET /api/dashboards/uid/:uid`
|
||||
|
||||
@@ -301,6 +301,10 @@ Path to the certificate file (if `protocol` is set to `https` or `h2`).
|
||||
|
||||
Path to the certificate key file (if `protocol` is set to `https` or `h2`).
|
||||
|
||||
#### `cert_pass`
|
||||
|
||||
Optional. Password to decrypt encrypted certificates.
|
||||
|
||||
#### `certs_watch_interval`
|
||||
|
||||
Controls whether `cert_key` and `cert_file` are periodically watched for changes.
|
||||
@@ -696,6 +700,10 @@ An existing user's account is unable to login for five minutes if all login atte
|
||||
Configure how many login attempts a user can have within a five minute window before their account is locked.
|
||||
Default is `5`.
|
||||
|
||||
#### `disable_ip_address_login_protection`
|
||||
|
||||
Set to `true` to disable [brute force login protection by IP address](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#account-lockout). Default is `true`. Anyone from the IP address will be unable to login for 5 minutes if all login attempts are spent within a 5 minute window.
|
||||
|
||||
#### `cookie_secure`
|
||||
|
||||
Set to `true` if you host Grafana behind HTTPS. Default is `false`.
|
||||
|
||||
@@ -25,6 +25,7 @@ The following table shows all supported authentication methods and the features
|
||||
| [Auth Proxy]({{< relref "./auth-proxy" >}}) | no | yes | yes | no | yes | no | N/A | no | N/A | N/A |
|
||||
| [Azure AD OAuth]({{< relref "./azuread" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
|
||||
| [Basic auth]({{< relref "./grafana" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A |
|
||||
| [Passwordless auth]({{< relref "./passwordless" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A |
|
||||
| [Generic OAuth]({{< relref "./generic-oauth" >}}) | yes | yes | yes | yes | yes | no | N/A | yes | yes | yes |
|
||||
| [GitHub OAuth]({{< relref "./github" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
|
||||
| [GitLab OAuth]({{< relref "./gitlab" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: Learn how to configure passwordless authentication with magic links in Grafana
|
||||
labels:
|
||||
products:
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Passwordless
|
||||
title: Configure passwordless authentication with magic links
|
||||
weight: 200
|
||||
---
|
||||
|
||||
# Configure passwordless authentication with magic links
|
||||
|
||||
Passwordless authentication lets Grafana users authenticate with a magic link or one-time password (OTP) sent via email.
|
||||
|
||||
## Enable passwordless authentication
|
||||
|
||||
{{< docs/experimental product="passwordless authentication" featureFlag="passwordlessMagicLinkAuthentication" >}}
|
||||
|
||||
To enable passwordless authentication, use the following configuration:
|
||||
|
||||
```bash
|
||||
[auth.passwordless]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
## Code expiration
|
||||
|
||||
By default, the one-time password (OTP) sent to a user's email is valid for 20 minutes. Use the `code_expiration` option to change the duration that the OTP is valid.
|
||||
|
||||
```bash
|
||||
[auth.passwordless]
|
||||
enabled = true
|
||||
code_expiration = 20m
|
||||
```
|
||||
|
||||
## Enable SMTP server
|
||||
|
||||
The SMTP server must be enabled so that Grafana can send emails.
|
||||
The following configuration enables the SMTP server.
|
||||
For more information on configuring the SMTP server, refer to [SMTP](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#smtp).
|
||||
|
||||
```bash
|
||||
[smtp]
|
||||
enabled = true
|
||||
```
|
||||
@@ -244,6 +244,8 @@ To configure Grafana HTTPS and restart Grafana, complete the following steps.
|
||||
|
||||
> **Note**: The standard port for SSL traffic is 443, which you can use instead of Grafana's default port 3000. This change might require additional operating system privileges or configuration to bind to lower-numbered privileged ports.
|
||||
|
||||
1. Optional. From Grafana v11.2, edit the `cert_pass` configuration option with the decryption password if you are using encrypted certificates.
|
||||
|
||||
1. [Restart the Grafana server]({{< relref "./start-restart-grafana#linux" >}}) using `systemd`, `init.d`, or the binary as appropriate for your environment.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -32,10 +32,10 @@ func checkNilRequester(r Requester) bool {
|
||||
|
||||
const serviceName = "service"
|
||||
|
||||
// WithServiceIdentitiy sets creates an identity representing the service itself in provided org and store it in context.
|
||||
// WithServiceIdentity sets creates an identity representing the service itself in provided org and store it in context.
|
||||
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
|
||||
// static permissions so it can be used in legacy code paths.
|
||||
func WithServiceIdentitiy(ctx context.Context, orgID int64) (context.Context, Requester) {
|
||||
func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) {
|
||||
r := &StaticRequester{
|
||||
Type: types.TypeAccessPolicy,
|
||||
Name: serviceName,
|
||||
|
||||
@@ -5,11 +5,12 @@ import "strconv"
|
||||
type SchemaVersionMigrationFunc func(map[string]interface{}) error
|
||||
|
||||
const (
|
||||
MINIUM_VERSION = 39
|
||||
MINIUM_VERSION = 38
|
||||
LATEST_VERSION = 40
|
||||
)
|
||||
|
||||
var Migrations = map[int]SchemaVersionMigrationFunc{
|
||||
39: V39,
|
||||
40: V40,
|
||||
}
|
||||
|
||||
|
||||
@@ -55,3 +55,21 @@ func TestGetSchemaVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type migrationTestCase struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
}
|
||||
|
||||
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := migrationFunc(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, tt.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package schemaversion
|
||||
|
||||
// V39 updates the configuration of the Timeseries to table transformation
|
||||
// to support multiple options per query
|
||||
func V39(dashboard map[string]interface{}) error {
|
||||
dashboard["schemaVersion"] = int(39)
|
||||
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, panel := range panels {
|
||||
p, ok := panel.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
transformations, ok := p["transformations"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, transformation := range transformations {
|
||||
t, ok := transformation.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we run into a timeSeriesTable transformation
|
||||
// and it doesn't have undefined options then we migrate
|
||||
if t["id"] != "timeSeriesTable" {
|
||||
continue
|
||||
}
|
||||
|
||||
options, ok := t["options"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
refIdStats, ok := options["refIdToStat"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// For each {refIdtoStat} record which maps refId to a statistic
|
||||
// we add that to the stat property of the new
|
||||
// RefIdTransformerOptions interface which includes multiple settings
|
||||
transformationOptions := make(map[string]interface{})
|
||||
for refId, stat := range refIdStats {
|
||||
transformationOptions[refId] = map[string]interface{}{"stat": stat}
|
||||
}
|
||||
|
||||
// Update the options
|
||||
t["options"] = transformationOptions
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package schemaversion_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
)
|
||||
|
||||
func TestV39(t *testing.T) {
|
||||
tests := []migrationTestCase{
|
||||
{
|
||||
name: "no transformations",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 38,
|
||||
"title": "Test Dashboard",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"title": "Panel 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
"schemaVersion": 39,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"title": "Panel 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeSeriesTable transformation with refIdToStat",
|
||||
input: map[string]interface{}{
|
||||
"schemaVersion": 38,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "timeSeriesTable",
|
||||
"options": map[string]interface{}{
|
||||
"refIdToStat": map[string]interface{}{
|
||||
"A": "mean",
|
||||
"B": "max",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 39,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "timeSeriesTable",
|
||||
"options": map[string]interface{}{
|
||||
"A": map[string]interface{}{
|
||||
"stat": "mean",
|
||||
},
|
||||
"B": map[string]interface{}{
|
||||
"stat": "max",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-timeSeriesTable transformation is not modified",
|
||||
input: map[string]interface{}{
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "otherTransform",
|
||||
"options": map[string]interface{}{
|
||||
"refIdToStat": map[string]interface{}{
|
||||
"A": "mean",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"schemaVersion": 39,
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"transformations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "otherTransform",
|
||||
"options": map[string]interface{}{
|
||||
"refIdToStat": map[string]interface{}{
|
||||
"A": "mean",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
runMigrationTests(t, tests, schemaversion.V39)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestV40(t *testing.T) {
|
||||
@@ -50,21 +49,3 @@ func TestV40(t *testing.T) {
|
||||
|
||||
runMigrationTests(t, tests, schemaversion.V40)
|
||||
}
|
||||
|
||||
type migrationTestCase struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
}
|
||||
|
||||
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := migrationFunc(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, tt.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations \u0026 Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"transformations": [
|
||||
{
|
||||
"id": "timeSeriesTable",
|
||||
"options": {
|
||||
"refIdToStat": {
|
||||
"A": "mean",
|
||||
"B": "max"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.5.0-81438",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Panel Title",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": true,
|
||||
"schemaVersion": 38,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "utc",
|
||||
"title": "New dashboard",
|
||||
"version": 0,
|
||||
"weekStart": ""
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations \u0026 Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.5.0-81438",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Panel Title",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "timeSeriesTable",
|
||||
"options": {
|
||||
"A": {
|
||||
"stat": "mean"
|
||||
},
|
||||
"B": {
|
||||
"stat": "max"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": true,
|
||||
"schemaVersion": 39,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "utc",
|
||||
"title": "New dashboard",
|
||||
"version": 0,
|
||||
"weekStart": ""
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations \u0026 Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.5.0-81438",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Panel Title",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "timeSeriesTable",
|
||||
"options": {
|
||||
"A": {
|
||||
"stat": "mean"
|
||||
},
|
||||
"B": {
|
||||
"stat": "max"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": "",
|
||||
"schemaVersion": 40,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "utc",
|
||||
"title": "New dashboard",
|
||||
"version": 0,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations \u0026 Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.5.0-81438",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Panel Title",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": true,
|
||||
"schemaVersion": 39,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "utc",
|
||||
"title": "New dashboard",
|
||||
"version": 0,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -34,6 +34,8 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct
|
||||
|
||||
func Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
|
||||
*out = in.Unstructured
|
||||
out.Object["title"] = in.Title
|
||||
if in.Title != "" {
|
||||
out.Object["title"] = in.Title
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -34,6 +34,8 @@ func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstruct
|
||||
|
||||
func Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
|
||||
*out = in.Unstructured
|
||||
out.Object["title"] = in.Title
|
||||
if in.Title != "" {
|
||||
out.Object["title"] = in.Title
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func WithRequester(handler http.Handler) http.Handler {
|
||||
} else if ok && info.GetName() == user.APIServerUser ||
|
||||
slices.Contains(info.GetGroups(), user.SystemPrivilegedGroup) {
|
||||
// For system:apiserver we use the identity of the service itself
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 1)
|
||||
req = req.WithContext(ctx)
|
||||
} else {
|
||||
klog.V(5).Info("unable to map the k8s user to grafana requester", "user", info)
|
||||
|
||||
@@ -159,7 +159,7 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b
|
||||
startSync := time.Now()
|
||||
|
||||
ctx = klog.NewContext(ctx, log)
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 0)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 0)
|
||||
ctx = request.WithNamespace(ctx, cfg.RequestInfo.Namespace)
|
||||
ctx = request.WithRequestInfo(ctx, cfg.RequestInfo)
|
||||
|
||||
@@ -179,7 +179,9 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b
|
||||
|
||||
log.Info("got items from unified storage", "items", len(storageList))
|
||||
|
||||
legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{})
|
||||
legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{
|
||||
Limit: int64(cfg.DataSyncerRecordsLimit),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err, "unable to extract list from legacy storage")
|
||||
return
|
||||
@@ -295,10 +297,39 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b
|
||||
}
|
||||
|
||||
func getList(ctx context.Context, obj rest.Lister, listOptions *metainternalversion.ListOptions) ([]runtime.Object, error) {
|
||||
ll, err := obj.List(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var allItems []runtime.Object
|
||||
|
||||
for {
|
||||
if int64(len(allItems)) >= listOptions.Limit {
|
||||
return nil, fmt.Errorf("list has more than %d records. Aborting sync", listOptions.Limit)
|
||||
}
|
||||
|
||||
ll, err := obj.List(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items, err := meta.ExtractList(ll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allItems = append(allItems, items...)
|
||||
|
||||
// Get continue token from the list metadata.
|
||||
listMeta, err := meta.ListAccessor(ll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no continue token, we're done paginating.
|
||||
if listMeta.GetContinue() == "" {
|
||||
break
|
||||
}
|
||||
|
||||
// Set continue token for next page.
|
||||
listOptions.Continue = listMeta.GetContinue()
|
||||
}
|
||||
|
||||
return meta.ExtractList(ll)
|
||||
return allItems, nil
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func folderTreeCollector(folderService folder.Service) legacyTupleCollector {
|
||||
ctx, span := tracer.Start(ctx, "accesscontrol.migrator.folderTreeCollector")
|
||||
defer span.End()
|
||||
|
||||
ctx, ident := identity.WithServiceIdentitiy(ctx, orgID)
|
||||
ctx, ident := identity.WithServiceIdentity(ctx, orgID)
|
||||
|
||||
q := folder.GetFoldersQuery{
|
||||
OrgID: orgID,
|
||||
|
||||
@@ -118,7 +118,7 @@ func ProvideDashboardPermissions(
|
||||
ctx, span := tracer.Start(ctx, "accesscontrol.ossaccesscontrol.ProvideDashboardPermissions.ResourceValidator")
|
||||
defer span.End()
|
||||
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, orgID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
|
||||
dashboard, err := getDashboard(ctx, orgID, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -131,7 +131,7 @@ func ProvideDashboardPermissions(
|
||||
return nil
|
||||
},
|
||||
InheritedScopesSolver: func(ctx context.Context, orgID int64, resourceID string) ([]string, error) {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, orgID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
|
||||
dashboard, err := getDashboard(ctx, orgID, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -99,7 +99,7 @@ func ProvideFolderPermissions(
|
||||
ctx, span := tracer.Start(ctx, "accesscontrol.ossaccesscontrol.ProvideFolderPermissions.ResourceValidator")
|
||||
defer span.End()
|
||||
|
||||
ctx, ident := identity.WithServiceIdentitiy(ctx, orgID)
|
||||
ctx, ident := identity.WithServiceIdentity(ctx, orgID)
|
||||
_, err := folderService.Get(ctx, &folder.GetFolderQuery{
|
||||
UID: &resourceID,
|
||||
OrgID: orgID,
|
||||
@@ -124,7 +124,7 @@ func ProvideFolderPermissions(
|
||||
return nil
|
||||
},
|
||||
InheritedScopesSolver: func(ctx context.Context, orgID int64, resourceID string) ([]string, error) {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, orgID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
|
||||
return dashboards.GetInheritedScopes(ctx, orgID, resourceID, folderService)
|
||||
},
|
||||
Assignments: resourcepermissions.Assignments{
|
||||
|
||||
@@ -39,6 +39,14 @@ func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, u
|
||||
return nil, errPasswordAuthFailed.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
|
||||
}
|
||||
|
||||
ok, err = c.loginAttempts.ValidateIPAddress(ctx, web.RemoteAddr(r.HTTPRequest))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for IP address - login for IP address temporarily blocked")
|
||||
}
|
||||
|
||||
if len(password) == 0 {
|
||||
return nil, errPasswordAuthFailed.Errorf("no password provided")
|
||||
}
|
||||
@@ -56,8 +64,9 @@ func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, u
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
if errors.Is(clientErrs, errInvalidPassword) {
|
||||
_ = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest))
|
||||
err = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, errPasswordAuthFailed.Errorf("failed to authenticate identity: %w", clientErrs)
|
||||
|
||||
@@ -2,6 +2,8 @@ package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -18,7 +20,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
|
||||
desc string
|
||||
username string
|
||||
password string
|
||||
req *authn.Request
|
||||
blockLogin bool
|
||||
clients []authn.PasswordClient
|
||||
expectedErr error
|
||||
@@ -30,7 +31,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
|
||||
desc: "should success when password client return identity",
|
||||
username: "test",
|
||||
password: "test",
|
||||
req: &authn.Request{},
|
||||
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser}}},
|
||||
expectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser},
|
||||
},
|
||||
@@ -38,7 +38,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
|
||||
desc: "should success when found in second client",
|
||||
username: "test",
|
||||
password: "test",
|
||||
req: &authn.Request{},
|
||||
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser}}},
|
||||
expectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser},
|
||||
},
|
||||
@@ -46,14 +45,12 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
|
||||
desc: "should fail for empty password",
|
||||
username: "test",
|
||||
password: "",
|
||||
req: &authn.Request{},
|
||||
expectedErr: errPasswordAuthFailed,
|
||||
},
|
||||
{
|
||||
desc: "should if login is blocked by to many attempts",
|
||||
username: "test",
|
||||
password: "test",
|
||||
req: &authn.Request{},
|
||||
blockLogin: true,
|
||||
expectedErr: errPasswordAuthFailed,
|
||||
},
|
||||
@@ -61,7 +58,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
|
||||
desc: "should fail when not found in any clients",
|
||||
username: "test",
|
||||
password: "test",
|
||||
req: &authn.Request{},
|
||||
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}},
|
||||
expectedErr: errPasswordAuthFailed,
|
||||
},
|
||||
@@ -70,8 +66,22 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tt.clients...)
|
||||
|
||||
identity, err := c.AuthenticatePassword(context.Background(), tt.req, tt.username, tt.password)
|
||||
r := &authn.Request{
|
||||
OrgID: 12345,
|
||||
HTTPRequest: &http.Request{
|
||||
Method: "GET",
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/api/v1/resource",
|
||||
},
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"User-Agent": []string{"MyApp/1.0"},
|
||||
},
|
||||
},
|
||||
}
|
||||
identity, err := c.AuthenticatePassword(context.Background(), r, tt.username, tt.password)
|
||||
if tt.expectedErr != nil {
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
assert.Nil(t, identity)
|
||||
|
||||
@@ -105,7 +105,6 @@ func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*auth
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: add IP address validation
|
||||
ok, err := c.loginAttempts.Validate(ctx, form.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -115,6 +114,15 @@ func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*auth
|
||||
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
|
||||
}
|
||||
|
||||
ok, err = c.loginAttempts.ValidateIPAddress(ctx, web.RemoteAddr(r.HTTPRequest))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for IP address - login for IP address temporarily blocked")
|
||||
}
|
||||
|
||||
err = c.loginAttempts.Add(ctx, form.Email, web.RemoteAddr(r.HTTPRequest))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -45,6 +46,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -154,7 +156,7 @@ func (dr *DashboardServiceImpl) Count(ctx context.Context, scopeParams *quota.Sc
|
||||
|
||||
total := int64(0)
|
||||
for _, org := range orgs {
|
||||
ctx, _ := identity.WithServiceIdentitiy(ctx, org.ID)
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
||||
orgDashboards, err := dr.CountDashboardsInOrg(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -231,7 +233,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardData(ctx context.Context,
|
||||
for _, org := range orgs {
|
||||
func(orgID int64) {
|
||||
g.Go(func() error {
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ProvisionedRepo: name,
|
||||
OrgId: orgID,
|
||||
})
|
||||
@@ -272,7 +274,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx con
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: org.ID,
|
||||
DashboardIds: []int64{dashboardID},
|
||||
})
|
||||
@@ -299,7 +301,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: orgID,
|
||||
DashboardUIDs: []string{dashboardUID},
|
||||
})
|
||||
@@ -556,9 +558,9 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
ctx, _ := identity.WithServiceIdentitiy(ctx, org.ID)
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
||||
// find all dashboards in the org that have a file repo set that is not in the given readers list
|
||||
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
|
||||
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ProvisionedReposNotIn: cmd.ReaderNames,
|
||||
OrgId: org.ID,
|
||||
})
|
||||
@@ -657,7 +659,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
|
||||
dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval)
|
||||
}
|
||||
|
||||
ctx, ident := identity.WithServiceIdentitiy(ctx, dto.OrgID)
|
||||
ctx, ident := identity.WithServiceIdentity(ctx, dto.OrgID)
|
||||
dto.User = ident
|
||||
|
||||
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false)
|
||||
@@ -697,7 +699,7 @@ func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C
|
||||
ctx, span := tracer.Start(ctx, "dashboards.service.SaveFolderForProvisionedDashboards")
|
||||
defer span.End()
|
||||
|
||||
ctx, ident := identity.WithServiceIdentitiy(ctx, dto.OrgID)
|
||||
ctx, ident := identity.WithServiceIdentity(ctx, dto.OrgID)
|
||||
dto.SignedInUser = ident
|
||||
|
||||
f, err := dr.folderService.Create(ctx, dto)
|
||||
@@ -842,7 +844,7 @@ func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, das
|
||||
|
||||
// DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned.
|
||||
func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, orgId)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, orgId)
|
||||
return dr.deleteDashboard(ctx, dashboardId, "", orgId, false)
|
||||
}
|
||||
|
||||
@@ -924,7 +926,7 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, org.ID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
|
||||
dash, err := dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: org.ID, ID: dashboardId})
|
||||
if err != nil {
|
||||
// if we can't find it in this org, try the next one
|
||||
@@ -989,10 +991,8 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *
|
||||
}
|
||||
|
||||
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
||||
// nolint:staticcheck
|
||||
inFolder := dash.FolderID > 0
|
||||
var permissions []accesscontrol.SetResourcePermissionCommand
|
||||
|
||||
var permissions []accesscontrol.SetResourcePermissionCommand
|
||||
if !provisioned && dto.User.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
|
||||
userID, err := dto.User.GetInternalID()
|
||||
if err != nil {
|
||||
@@ -1004,7 +1004,7 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *
|
||||
}
|
||||
}
|
||||
|
||||
if !inFolder {
|
||||
if dash.FolderUID == "" {
|
||||
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
|
||||
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
|
||||
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
|
||||
@@ -1025,9 +1025,7 @@ func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context,
|
||||
return
|
||||
}
|
||||
|
||||
inFolder := f.ParentUID != ""
|
||||
var permissions []accesscontrol.SetResourcePermissionCommand
|
||||
|
||||
if !provisioned && cmd.SignedInUser.IsIdentityType(claims.TypeUser) {
|
||||
userID, err := cmd.SignedInUser.GetInternalID()
|
||||
if err != nil {
|
||||
@@ -1039,7 +1037,7 @@ func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
if !inFolder {
|
||||
if f.ParentUID == "" {
|
||||
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
|
||||
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
|
||||
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
|
||||
@@ -1439,7 +1437,12 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64
|
||||
}
|
||||
|
||||
// We need a list of dashboard uids inside the folder to delete related public dashboards
|
||||
dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: u, FolderUIDs: folderUIDs, OrgId: orgID})
|
||||
dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
SignedInUser: u,
|
||||
FolderUIDs: folderUIDs,
|
||||
OrgId: orgID,
|
||||
Type: searchstore.TypeDashboard,
|
||||
})
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
||||
}
|
||||
@@ -1732,8 +1735,12 @@ type dashboardProvisioningWithUID struct {
|
||||
DashboardUID string
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, query.OrgId)
|
||||
func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) {
|
||||
if query == nil {
|
||||
return nil, errors.New("query cannot be nil")
|
||||
}
|
||||
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId)
|
||||
|
||||
if query.ProvisionedRepo != "" {
|
||||
query.ProvisionedRepo = provisionedFileNameWithPrefix(query.ProvisionedRepo)
|
||||
@@ -1747,7 +1754,9 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
|
||||
query.ProvisionedReposNotIn = repos
|
||||
}
|
||||
|
||||
searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, &query)
|
||||
query.Type = searchstore.TypeDashboard
|
||||
|
||||
searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1810,6 +1819,11 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) searchDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboards.Dashboard, error) {
|
||||
if query == nil {
|
||||
return nil, errors.New("query cannot be nil")
|
||||
}
|
||||
query.Type = searchstore.TypeDashboard
|
||||
|
||||
response, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1899,6 +1899,134 @@ func TestCountInFolders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchDashboardsThroughK8sRaw(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
k8sCliMock := new(client.MockK8sHandler)
|
||||
service := &DashboardServiceImpl{k8sclient: k8sCliMock}
|
||||
query := &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: 1,
|
||||
}
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Dashboard 1"),
|
||||
[]byte("folder1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil)
|
||||
res, err := service.searchDashboardsThroughK8s(ctx, query)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []*dashboards.Dashboard{
|
||||
{
|
||||
UID: "uid",
|
||||
OrgID: 1,
|
||||
FolderUID: "folder1",
|
||||
Title: "Dashboard 1",
|
||||
Slug: "dashboard-1", // should be slugified
|
||||
},
|
||||
}, res)
|
||||
assert.Equal(t, "dash-db", query.Type) // query type should be added
|
||||
}
|
||||
|
||||
func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
k8sCliMock := new(client.MockK8sHandler)
|
||||
service := &DashboardServiceImpl{k8sclient: k8sCliMock}
|
||||
query := &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: 1,
|
||||
}
|
||||
dashboardUnstructuredProvisioned := unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
"annotations": map[string]any{
|
||||
utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
|
||||
utils.AnnoKeyRepoHash: "hash",
|
||||
utils.AnnoKeyRepoPath: "path/to/file",
|
||||
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
}}
|
||||
dashboardUnstructuredNotProvisioned := unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid2",
|
||||
},
|
||||
"spec": map[string]any{},
|
||||
}}
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Dashboard 1"),
|
||||
[]byte("folder1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "uid2",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Dashboard 2"),
|
||||
[]byte("folder2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil)
|
||||
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructuredProvisioned, nil).Once()
|
||||
k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructuredNotProvisioned, nil).Once()
|
||||
res, err := service.searchProvisionedDashboardsThroughK8s(ctx, query)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []*dashboardProvisioningWithUID{
|
||||
{
|
||||
DashboardUID: "uid",
|
||||
DashboardProvisioning: dashboards.DashboardProvisioning{
|
||||
Name: "test",
|
||||
ExternalID: "path/to/file",
|
||||
CheckSum: "hash",
|
||||
Updated: 1735689600,
|
||||
},
|
||||
},
|
||||
}, res) // only should return the one provisioned dashboard
|
||||
assert.Equal(t, "dash-db", query.Type) // query type should be added as dashboards only
|
||||
}
|
||||
|
||||
func TestLegacySaveCommandToUnstructured(t *testing.T) {
|
||||
namespace := "test-namespace"
|
||||
t.Run("successfully converts save command to unstructured", func(t *testing.T) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -1028,7 +1029,12 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm
|
||||
// if dashboard restore is on we don't delete public dashboards, the hard delete will take care of it later
|
||||
if !s.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) {
|
||||
// We need a list of dashboard uids inside the folder to delete related public dashboards
|
||||
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folderUIDs, OrgId: cmd.OrgID})
|
||||
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
SignedInUser: cmd.SignedInUser,
|
||||
FolderUIDs: folderUIDs,
|
||||
OrgId: cmd.OrgID,
|
||||
Type: searchstore.TypeDashboard,
|
||||
})
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -737,7 +738,12 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folders, OrgId: cmd.OrgID})
|
||||
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
SignedInUser: cmd.SignedInUser,
|
||||
FolderUIDs: folders,
|
||||
OrgId: cmd.OrgID,
|
||||
Type: searchstore.TypeDashboard,
|
||||
})
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/search"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -250,8 +251,9 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn
|
||||
}
|
||||
|
||||
// then find the dashboards that were supposed to be connected to this element
|
||||
_, requester := identity.WithServiceIdentitiy(c, signedInUser.GetOrgID())
|
||||
_, requester := identity.WithServiceIdentity(c, signedInUser.GetOrgID())
|
||||
dashs, err := l.dashboardsService.FindDashboards(c, &dashboards.FindPersistedDashboardsQuery{
|
||||
Type: searchstore.TypeDashboard,
|
||||
OrgId: signedInUser.GetOrgID(),
|
||||
DashboardIds: dashboardIDs,
|
||||
SignedInUser: requester, // a user may be able to delete a library element but not read all dashboards. We still need to run this check, so we don't allow deleting elements if dashboards are connected
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
|
||||
type Service interface {
|
||||
// Add adds a new login attempt record for provided username
|
||||
Add(ctx context.Context, username, IPAddress string) error
|
||||
Add(ctx context.Context, username, ipAddress string) error
|
||||
// Validate checks if username has to many login attempts inside a window.
|
||||
// Will return true if provided username do not have too many attempts.
|
||||
Validate(ctx context.Context, username string) (bool, error)
|
||||
// Validate checks if IP address has to many login attempts inside a window.
|
||||
// Will return true if provided IP address do not have too many attempts.
|
||||
ValidateIPAddress(ctx context.Context, ipAddress string) (bool, error)
|
||||
// Reset resets all login attempts attached to username
|
||||
Reset(ctx context.Context, username string) error
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (s *Service) Add(ctx context.Context, username, IPAddress string) error {
|
||||
|
||||
_, err := s.store.CreateLoginAttempt(ctx, CreateLoginAttemptCommand{
|
||||
Username: strings.ToLower(username),
|
||||
IpAddress: IPAddress,
|
||||
IPAddress: IPAddress,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -84,6 +84,28 @@ func (s *Service) Validate(ctx context.Context, username string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) ValidateIPAddress(ctx context.Context, IPAddress string) (bool, error) {
|
||||
if s.cfg.DisableIPAddressLoginProtection {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
loginAttemptCountQuery := GetIPLoginAttemptCountQuery{
|
||||
IPAddress: IPAddress,
|
||||
Since: time.Now().Add(-loginAttemptsWindow),
|
||||
}
|
||||
|
||||
count, err := s.store.GetIPLoginAttemptCount(ctx, loginAttemptCountQuery)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if count >= s.cfg.BruteForceLoginProtectionMaxAttempts {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) cleanup(ctx context.Context) {
|
||||
err := s.lock.LockAndExecute(ctx, "delete old login attempts", time.Minute*10, func(context.Context) {
|
||||
cmd := DeleteOldLoginAttemptsCommand{
|
||||
|
||||
@@ -22,40 +22,40 @@ func TestService_Validate(t *testing.T) {
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "When brute force protection enabled and user login attempt count is less than max",
|
||||
name: "Should be valid when brute force protection enabled and user login attempt count is less than max",
|
||||
loginAttempts: maxInvalidLoginAttempts - 1,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "When brute force protection enabled and user login attempt count equals max",
|
||||
name: "Should be invalid when brute force protection enabled and user login attempt count equals max",
|
||||
loginAttempts: maxInvalidLoginAttempts,
|
||||
expected: false,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "When brute force protection enabled and user login attempt count is greater than max",
|
||||
name: "Should be invalid when brute force protection enabled and user login attempt count is greater than max",
|
||||
loginAttempts: maxInvalidLoginAttempts + 1,
|
||||
expected: false,
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
{
|
||||
name: "When brute force protection disabled and user login attempt count is less than max",
|
||||
name: "Should be valid when brute force protection disabled and user login attempt count is less than max",
|
||||
loginAttempts: maxInvalidLoginAttempts - 1,
|
||||
disabled: true,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "When brute force protection disabled and user login attempt count equals max",
|
||||
name: "Should be valid when brute force protection disabled and user login attempt count equals max",
|
||||
loginAttempts: maxInvalidLoginAttempts,
|
||||
disabled: true,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "When brute force protection disabled and user login attempt count is greater than max",
|
||||
name: "Should be valid when brute force protection disabled and user login attempt count is greater than max",
|
||||
loginAttempts: maxInvalidLoginAttempts + 1,
|
||||
disabled: true,
|
||||
expected: true,
|
||||
@@ -83,7 +83,7 @@ func TestService_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAttempts(t *testing.T) {
|
||||
func TestUserLoginAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := setting.NewCfg()
|
||||
cfg.DisableBruteForceLoginProtection = false
|
||||
@@ -109,6 +109,102 @@ func TestLoginAttempts(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestService_ValidateIPAddress(t *testing.T) {
|
||||
const maxInvalidLoginAttempts = 5
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
loginAttempts int64
|
||||
disabled bool
|
||||
expected bool
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "Should be valid when brute force protection enabled and IP address login attempt count is less than max",
|
||||
loginAttempts: maxInvalidLoginAttempts - 1,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Should be invalid when brute force protection enabled and IP address login attempt count equals max",
|
||||
loginAttempts: maxInvalidLoginAttempts,
|
||||
expected: false,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Should be invalid when brute force protection enabled and IP address login attempt count is greater than max",
|
||||
loginAttempts: maxInvalidLoginAttempts + 1,
|
||||
expected: false,
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Should be valid when brute force protection disabled and IP address login attempt count is less than max",
|
||||
loginAttempts: maxInvalidLoginAttempts - 1,
|
||||
disabled: true,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Should be valid when brute force protection disabled and IP address login attempt count equals max",
|
||||
loginAttempts: maxInvalidLoginAttempts,
|
||||
disabled: true,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Should be valid when brute force protection disabled and IP address login attempt count is greater than max",
|
||||
loginAttempts: maxInvalidLoginAttempts + 1,
|
||||
disabled: true,
|
||||
expected: true,
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.BruteForceLoginProtectionMaxAttempts = maxInvalidLoginAttempts
|
||||
cfg.DisableIPAddressLoginProtection = tt.disabled
|
||||
service := &Service{
|
||||
store: fakeStore{
|
||||
ExpectedCount: tt.loginAttempts,
|
||||
ExpectedErr: tt.expectedErr,
|
||||
},
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
ok, err := service.ValidateIPAddress(context.Background(), "192.168.1.1")
|
||||
assert.Equal(t, tt.expected, ok)
|
||||
assert.Equal(t, tt.expectedErr, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLoginAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := setting.NewCfg()
|
||||
cfg.DisableIPAddressLoginProtection = false
|
||||
cfg.BruteForceLoginProtectionMaxAttempts = 3
|
||||
db := db.InitTestDB(t)
|
||||
service := ProvideService(db, cfg, nil)
|
||||
|
||||
_ = service.Add(ctx, "user1", "192.168.1.1")
|
||||
_ = service.Add(ctx, "user2", "10.0.0.123")
|
||||
_ = service.Add(ctx, "user3", "192.168.1.1")
|
||||
_ = service.Add(ctx, "user4", "[::1]")
|
||||
_ = service.Add(ctx, "user5", "192.168.1.1")
|
||||
_ = service.Add(ctx, "user6", "192.168.1.1")
|
||||
|
||||
count, err := service.store.GetIPLoginAttemptCount(ctx, GetIPLoginAttemptCountQuery{IPAddress: "192.168.1.1"})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(4), count)
|
||||
|
||||
ok, err := service.ValidateIPAddress(ctx, "192.168.1.1")
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
var _ store = new(fakeStore)
|
||||
|
||||
type fakeStore struct {
|
||||
@@ -121,6 +217,10 @@ func (f fakeStore) GetUserLoginAttemptCount(ctx context.Context, query GetUserLo
|
||||
return f.ExpectedCount, f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f fakeStore) GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) {
|
||||
return f.ExpectedCount, f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f fakeStore) CreateLoginAttempt(ctx context.Context, command CreateLoginAttemptCommand) (loginattempt.LoginAttempt, error) {
|
||||
return loginattempt.LoginAttempt{}, f.ExpectedErr
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
type CreateLoginAttemptCommand struct {
|
||||
Username string
|
||||
IpAddress string
|
||||
IPAddress string
|
||||
}
|
||||
|
||||
type GetUserLoginAttemptCountQuery struct {
|
||||
@@ -14,6 +14,11 @@ type GetUserLoginAttemptCountQuery struct {
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
type GetIPLoginAttemptCountQuery struct {
|
||||
IPAddress string
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
type DeleteOldLoginAttemptsCommand struct {
|
||||
OlderThan time.Time
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ type store interface {
|
||||
DeleteOldLoginAttempts(ctx context.Context, cmd DeleteOldLoginAttemptsCommand) (int64, error)
|
||||
DeleteLoginAttempts(ctx context.Context, cmd DeleteLoginAttemptsCommand) error
|
||||
GetUserLoginAttemptCount(ctx context.Context, query GetUserLoginAttemptCountQuery) (int64, error)
|
||||
GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error)
|
||||
}
|
||||
|
||||
func (xs *xormStore) CreateLoginAttempt(ctx context.Context, cmd CreateLoginAttemptCommand) (result loginattempt.LoginAttempt, err error) {
|
||||
err = xs.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
loginAttempt := loginattempt.LoginAttempt{
|
||||
Username: cmd.Username,
|
||||
IpAddress: cmd.IpAddress,
|
||||
IpAddress: cmd.IPAddress,
|
||||
Created: xs.now().Unix(),
|
||||
}
|
||||
|
||||
@@ -73,6 +74,22 @@ func (xs *xormStore) GetUserLoginAttemptCount(ctx context.Context, query GetUser
|
||||
And("created >= ?", query.Since.Unix()).
|
||||
Count(loginAttempt)
|
||||
|
||||
return queryErr
|
||||
})
|
||||
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (xs *xormStore) GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) {
|
||||
var total int64
|
||||
err := xs.db.WithDbSession(ctx, func(dbSession *db.Session) error {
|
||||
var queryErr error
|
||||
loginAttempt := new(loginattempt.LoginAttempt)
|
||||
total, queryErr = dbSession.
|
||||
Where("ip_address = ?", query.IPAddress).
|
||||
And("created >= ?", query.Since.Unix()).
|
||||
Count(loginAttempt)
|
||||
|
||||
if queryErr != nil {
|
||||
return queryErr
|
||||
}
|
||||
|
||||
@@ -60,21 +60,21 @@ func TestIntegrationLoginAttemptsQuery(t *testing.T) {
|
||||
|
||||
_, err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
|
||||
Username: user,
|
||||
IpAddress: "192.168.0.1",
|
||||
IPAddress: "192.168.0.1",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
mockTime = timePlusOneMinute
|
||||
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
|
||||
Username: user,
|
||||
IpAddress: "192.168.0.1",
|
||||
IPAddress: "192.168.0.1",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
mockTime = timePlusTwoMinutes
|
||||
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
|
||||
Username: user,
|
||||
IpAddress: "192.168.0.1",
|
||||
IPAddress: "192.168.0.1",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -125,21 +125,21 @@ func TestIntegrationLoginAttemptsDelete(t *testing.T) {
|
||||
|
||||
_, err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
|
||||
Username: user,
|
||||
IpAddress: "192.168.0.1",
|
||||
IPAddress: "192.168.0.1",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
mockTime = timePlusOneMinute
|
||||
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
|
||||
Username: user,
|
||||
IpAddress: "192.168.0.1",
|
||||
IPAddress: "192.168.0.1",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
mockTime = timePlusTwoMinutes
|
||||
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
|
||||
Username: user,
|
||||
IpAddress: "192.168.0.1",
|
||||
IPAddress: "192.168.0.1",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
|
||||
@@ -24,3 +24,7 @@ func (f FakeLoginAttemptService) Reset(ctx context.Context, username string) err
|
||||
func (f FakeLoginAttemptService) Validate(ctx context.Context, username string) (bool, error) {
|
||||
return f.ExpectedValid, f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f FakeLoginAttemptService) ValidateIPAddress(ctx context.Context, IpAddress string) (bool, error) {
|
||||
return f.ExpectedValid, f.ExpectedErr
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type MockLoginAttemptService struct {
|
||||
ExpectedErr error
|
||||
}
|
||||
|
||||
func (f *MockLoginAttemptService) Add(ctx context.Context, username, IPAddress string) error {
|
||||
func (f *MockLoginAttemptService) Add(ctx context.Context, username, ipAddress string) error {
|
||||
f.AddCalled = true
|
||||
return f.ExpectedErr
|
||||
}
|
||||
@@ -31,3 +31,8 @@ func (f *MockLoginAttemptService) Validate(ctx context.Context, username string)
|
||||
f.ValidateCalled = true
|
||||
return f.ExpectedValid, f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f *MockLoginAttemptService) ValidateIPAddress(ctx context.Context, ipAddress string) (bool, error) {
|
||||
f.ValidateCalled = true
|
||||
return f.ExpectedValid, f.ExpectedErr
|
||||
}
|
||||
@@ -585,8 +585,6 @@ func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[stri
|
||||
|
||||
gettableExtendedRuleNode := apimodels.GettableExtendedRuleNode{
|
||||
GrafanaManagedAlert: &apimodels.GettableGrafanaRule{
|
||||
ID: r.ID,
|
||||
OrgID: r.OrgID,
|
||||
Title: r.Title,
|
||||
Condition: r.Condition,
|
||||
Data: ApiAlertQueriesFromAlertQueries(r.Data),
|
||||
@@ -757,23 +755,36 @@ type userIDToUserInfoFn func(id *ngmodels.UserUID) *apimodels.UserInfo
|
||||
|
||||
// getIdentityName returns name of either user or service account
|
||||
func (srv RulerSrv) resolveUserIdToNameFn(ctx context.Context) userIDToUserInfoFn {
|
||||
cache := map[ngmodels.UserUID]*apimodels.UserInfo{
|
||||
ngmodels.AlertingUserUID: {
|
||||
UID: string(ngmodels.AlertingUserUID),
|
||||
},
|
||||
ngmodels.FileProvisioningUserUID: {
|
||||
UID: string(ngmodels.FileProvisioningUserUID),
|
||||
},
|
||||
}
|
||||
return func(id *ngmodels.UserUID) *apimodels.UserInfo {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
if val, ok := cache[*id]; ok {
|
||||
return val
|
||||
}
|
||||
u, err := srv.userService.GetByUID(ctx, &user.GetUserByUIDQuery{
|
||||
UID: string(*id),
|
||||
})
|
||||
var result string
|
||||
var name string
|
||||
if err != nil {
|
||||
srv.log.FromContext(ctx).Warn("Failed to get user by uid. Defaulting to an empty name", "uid", id, "error", err)
|
||||
}
|
||||
if u != nil {
|
||||
result = u.NameOrFallback()
|
||||
name = u.NameOrFallback()
|
||||
}
|
||||
return &apimodels.UserInfo{
|
||||
result := &apimodels.UserInfo{
|
||||
UID: string(*id),
|
||||
Name: result,
|
||||
Name: name,
|
||||
}
|
||||
cache[*id] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -404,6 +404,24 @@ func TestRouteGetRuleByUID(t *testing.T) {
|
||||
Name: "Test",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "recognize system identifier (alerting)",
|
||||
UpdatedBy: &models.AlertingUserUID,
|
||||
User: nil,
|
||||
UserServiceError: nil,
|
||||
Expected: &apimodels.UserInfo{
|
||||
UID: string(models.AlertingUserUID),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "recognize system identifier (provisioning)",
|
||||
UpdatedBy: &models.FileProvisioningUserUID,
|
||||
User: nil,
|
||||
UserServiceError: nil,
|
||||
Expected: &apimodels.UserInfo{
|
||||
UID: string(models.FileProvisioningUserUID),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
|
||||
@@ -1593,10 +1593,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -1621,10 +1617,6 @@
|
||||
"notification_settings": {
|
||||
"$ref": "#/definitions/AlertRuleNotificationSettings"
|
||||
},
|
||||
"orgId": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/definitions/Provenance"
|
||||
},
|
||||
|
||||
@@ -550,8 +550,6 @@ type PostableGrafanaRule struct {
|
||||
|
||||
// swagger:model
|
||||
type GettableGrafanaRule struct {
|
||||
ID int64 `json:"id" yaml:"id"`
|
||||
OrgID int64 `json:"orgId" yaml:"orgId"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
Condition string `json:"condition" yaml:"condition"`
|
||||
Data []AlertQuery `json:"data" yaml:"data"`
|
||||
|
||||
@@ -1593,10 +1593,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -1621,10 +1617,6 @@
|
||||
"notification_settings": {
|
||||
"$ref": "#/definitions/AlertRuleNotificationSettings"
|
||||
},
|
||||
"orgId": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/definitions/Provenance"
|
||||
},
|
||||
|
||||
@@ -5281,10 +5281,6 @@
|
||||
"Error"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -5309,10 +5305,6 @@
|
||||
"notification_settings": {
|
||||
"$ref": "#/definitions/AlertRuleNotificationSettings"
|
||||
},
|
||||
"orgId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/definitions/Provenance"
|
||||
},
|
||||
|
||||
@@ -47,6 +47,11 @@ var (
|
||||
ErrNoPanel = errors.New("no panel")
|
||||
)
|
||||
|
||||
var (
|
||||
FileProvisioningUserUID = UserUID("__provisioning__")
|
||||
AlertingUserUID = UserUID("__alerting__")
|
||||
)
|
||||
|
||||
// swagger:enum NoDataState
|
||||
type NoDataState string
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
|
||||
}
|
||||
}
|
||||
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
ids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{
|
||||
ids, err := service.ruleStore.InsertAlertRules(ctx, userUidOrFallback(user), []models.AlertRule{
|
||||
rule,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -359,7 +359,7 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
|
||||
}
|
||||
}
|
||||
|
||||
return service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updateRules)
|
||||
return service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), updateRules)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
|
||||
New: *update.New,
|
||||
})
|
||||
}
|
||||
if err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updates); err != nil {
|
||||
if err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), updates); err != nil {
|
||||
return fmt.Errorf("failed to update alert rules: %w", err)
|
||||
}
|
||||
for _, update := range delta.Update {
|
||||
@@ -522,7 +522,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
|
||||
}
|
||||
|
||||
if len(delta.New) > 0 {
|
||||
uids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), withoutNilAlertRules(delta.New))
|
||||
uids, err := service.ruleStore.InsertAlertRules(ctx, userUidOrFallback(user), withoutNilAlertRules(delta.New))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert alert rules: %w", err)
|
||||
}
|
||||
@@ -618,7 +618,7 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident
|
||||
return models.AlertRule{}, err
|
||||
}
|
||||
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), []models.UpdateRule{
|
||||
err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), []models.UpdateRule{
|
||||
{
|
||||
Existing: storedRule,
|
||||
New: rule,
|
||||
@@ -871,3 +871,11 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func userUidOrFallback(user identity.Requester) *models.UserUID {
|
||||
userUID := models.NewUserUID(user)
|
||||
if user == nil {
|
||||
return &models.FileProvisioningUserUID
|
||||
}
|
||||
return userUID
|
||||
}
|
||||
@@ -314,8 +314,6 @@ func (r ruleWithFolder) Fingerprint() fingerprint {
|
||||
|
||||
// fields that do not affect the state.
|
||||
// TODO consider removing fields below from the fingerprint
|
||||
writeInt(rule.ID)
|
||||
writeInt(rule.OrgID)
|
||||
writeInt(int64(rule.For))
|
||||
if rule.DashboardUID != nil {
|
||||
writeString(*rule.DashboardUID)
|
||||
|
||||
@@ -262,6 +262,8 @@ func TestRuleWithFolderFingerprint(t *testing.T) {
|
||||
"UpdatedBy": {},
|
||||
"IntervalSeconds": {},
|
||||
"Annotations": {},
|
||||
"ID": {},
|
||||
"OrgID": {},
|
||||
}
|
||||
|
||||
tp := reflect.TypeOf(rule).Elem()
|
||||
|
||||
@@ -988,7 +988,7 @@ func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgI
|
||||
}
|
||||
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
|
||||
// and not by the user who changed the receiver's title.
|
||||
return result, nil, st.UpdateAlertRules(ctx, nil, updates)
|
||||
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
|
||||
}
|
||||
|
||||
// RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name.
|
||||
@@ -1065,7 +1065,7 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings(
|
||||
}
|
||||
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
|
||||
// and not by the user who changed the receiver's title.
|
||||
return result, nil, st.UpdateAlertRules(ctx, nil, updates)
|
||||
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
|
||||
}
|
||||
|
||||
func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error {
|
||||
|
||||
@@ -11,17 +11,24 @@ type AttachedFile struct {
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// EmbeddedContent struct represents an embedded file.
|
||||
type EmbeddedContent struct {
|
||||
Name string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// Message is representation of the email message.
|
||||
type Message struct {
|
||||
To []string
|
||||
SingleEmail bool
|
||||
From string
|
||||
Subject string
|
||||
Body map[string]string
|
||||
Info string
|
||||
ReplyTo []string
|
||||
EmbeddedFiles []string
|
||||
AttachedFiles []*AttachedFile
|
||||
To []string
|
||||
SingleEmail bool
|
||||
From string
|
||||
Subject string
|
||||
Body map[string]string
|
||||
Info string
|
||||
ReplyTo []string
|
||||
EmbeddedFiles []string
|
||||
EmbeddedContents []EmbeddedContent
|
||||
AttachedFiles []*AttachedFile
|
||||
}
|
||||
|
||||
func setDefaultTemplateData(cfg *setting.Cfg, data map[string]any, u *user.User) {
|
||||
|
||||
@@ -112,14 +112,15 @@ func (ns *NotificationService) buildEmailMessage(cmd *SendEmailCommand) (*Messag
|
||||
|
||||
addr := mail.Address{Name: ns.Cfg.Smtp.FromName, Address: ns.Cfg.Smtp.FromAddress}
|
||||
return &Message{
|
||||
To: cmd.To,
|
||||
SingleEmail: cmd.SingleEmail,
|
||||
From: addr.String(),
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
EmbeddedFiles: cmd.EmbeddedFiles,
|
||||
AttachedFiles: buildAttachedFiles(cmd.AttachedFiles),
|
||||
ReplyTo: cmd.ReplyTo,
|
||||
To: cmd.To,
|
||||
SingleEmail: cmd.SingleEmail,
|
||||
From: addr.String(),
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
EmbeddedFiles: cmd.EmbeddedFiles,
|
||||
EmbeddedContents: cmd.EmbeddedContents,
|
||||
AttachedFiles: buildAttachedFiles(cmd.AttachedFiles),
|
||||
ReplyTo: cmd.ReplyTo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,16 @@ type SendEmailAttachFile struct {
|
||||
|
||||
// SendEmailCommand is the command for sending emails
|
||||
type SendEmailCommand struct {
|
||||
To []string
|
||||
SingleEmail bool
|
||||
Template string
|
||||
Subject string
|
||||
Data map[string]any
|
||||
Info string
|
||||
ReplyTo []string
|
||||
EmbeddedFiles []string
|
||||
AttachedFiles []*SendEmailAttachFile
|
||||
To []string
|
||||
SingleEmail bool
|
||||
Template string
|
||||
Subject string
|
||||
Data map[string]any
|
||||
Info string
|
||||
ReplyTo []string
|
||||
EmbeddedFiles []string
|
||||
EmbeddedContents []EmbeddedContent
|
||||
AttachedFiles []*SendEmailAttachFile
|
||||
}
|
||||
|
||||
// SendEmailCommandSync is the command for sending emails synchronously
|
||||
|
||||
@@ -201,15 +201,16 @@ func __dangerouslyInjectHTML(s string) template.HTML {
|
||||
|
||||
func (ns *NotificationService) SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error {
|
||||
message, err := ns.buildEmailMessage(&SendEmailCommand{
|
||||
Data: cmd.Data,
|
||||
Info: cmd.Info,
|
||||
Template: cmd.Template,
|
||||
To: cmd.To,
|
||||
SingleEmail: cmd.SingleEmail,
|
||||
EmbeddedFiles: cmd.EmbeddedFiles,
|
||||
AttachedFiles: cmd.AttachedFiles,
|
||||
Subject: cmd.Subject,
|
||||
ReplyTo: cmd.ReplyTo,
|
||||
Data: cmd.Data,
|
||||
Info: cmd.Info,
|
||||
Template: cmd.Template,
|
||||
To: cmd.To,
|
||||
SingleEmail: cmd.SingleEmail,
|
||||
EmbeddedFiles: cmd.EmbeddedFiles,
|
||||
EmbeddedContents: cmd.EmbeddedContents,
|
||||
AttachedFiles: cmd.AttachedFiles,
|
||||
Subject: cmd.Subject,
|
||||
ReplyTo: cmd.ReplyTo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -132,6 +132,31 @@ func TestSendEmailSync(t *testing.T) {
|
||||
require.Equal(t, []byte("text file content"), file.Content)
|
||||
})
|
||||
|
||||
t.Run("When embedding readers to emails", func(t *testing.T) {
|
||||
ns, mailer := createSut(t, bus)
|
||||
cmd := &SendEmailCommandSync{
|
||||
SendEmailCommand: SendEmailCommand{
|
||||
Subject: "subject",
|
||||
To: []string{"asdf@grafana.com"},
|
||||
SingleEmail: true,
|
||||
Template: "welcome_on_signup",
|
||||
EmbeddedContents: []EmbeddedContent{
|
||||
{Name: "embed.jpg", Content: []byte("image content")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEmpty(t, mailer.Sent)
|
||||
sent := mailer.Sent[len(mailer.Sent)-1]
|
||||
require.Len(t, sent.EmbeddedContents, 1)
|
||||
f := sent.EmbeddedContents[0]
|
||||
require.Equal(t, "embed.jpg", f.Name)
|
||||
require.Equal(t, "image content", string(f.Content))
|
||||
})
|
||||
|
||||
t.Run("When SMTP disabled in configuration", func(t *testing.T) {
|
||||
cfg := createSmtpConfig()
|
||||
cfg.Smtp.Enabled = false
|
||||
|
||||
@@ -142,6 +142,13 @@ func (sc *SmtpClient) setFiles(
|
||||
m.Embed(file)
|
||||
}
|
||||
|
||||
for _, file := range msg.EmbeddedContents {
|
||||
m.Embed(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error {
|
||||
_, err := writer.Write(file.Content)
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
for _, file := range msg.AttachedFiles {
|
||||
file := file
|
||||
m.Attach(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error {
|
||||
|
||||
@@ -45,7 +45,7 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context,
|
||||
files []*AlertingFile) error {
|
||||
for _, file := range files {
|
||||
for _, group := range file.Groups {
|
||||
ctx, u := identity.WithServiceIdentitiy(ctx, group.OrgID)
|
||||
ctx, u := identity.WithServiceIdentity(ctx, group.OrgID)
|
||||
|
||||
folderUID, err := prov.getOrCreateFolderFullpath(ctx, group.FolderFullpath, group.OrgID)
|
||||
if err != nil {
|
||||
@@ -121,7 +121,7 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderFullpath(
|
||||
|
||||
func (prov *defaultAlertRuleProvisioner) getOrCreateFolderByTitle(
|
||||
ctx context.Context, folderName string, orgID int64, parentUID *string) (string, error) {
|
||||
ctx, user := identity.WithServiceIdentitiy(ctx, orgID)
|
||||
ctx, user := identity.WithServiceIdentity(ctx, orgID)
|
||||
|
||||
cmd := &folder.GetFolderQuery{
|
||||
Title: &folderName,
|
||||
|
||||
@@ -148,7 +148,7 @@ func (fr *FileReader) isDatabaseAccessRestricted() bool {
|
||||
// storeDashboardsInFolder saves dashboards from the filesystem on disk to the folder from config
|
||||
func (fr *FileReader) storeDashboardsInFolder(ctx context.Context, filesFoundOnDisk map[string]os.FileInfo,
|
||||
dashboardRefs map[string]*dashboards.DashboardProvisioning, usageTracker *usageTracker) error {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, fr.Cfg.OrgID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
|
||||
|
||||
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.dashboardProvisioningService, fr.Cfg.Folder)
|
||||
if err != nil && !errors.Is(err, ErrFolderNameMissing) {
|
||||
@@ -180,7 +180,7 @@ func (fr *FileReader) storeDashboardsInFoldersFromFileStructure(ctx context.Cont
|
||||
folderName = filepath.Base(dashboardsFolder)
|
||||
}
|
||||
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, fr.Cfg.OrgID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
|
||||
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.dashboardProvisioningService, folderName)
|
||||
if err != nil && !errors.Is(err, ErrFolderNameMissing) {
|
||||
return fmt.Errorf("%w with name %q from file system structure: %w", ErrGetOrCreateFolder, folderName, err)
|
||||
|
||||
@@ -411,7 +411,7 @@ func TestDashboardFileReader(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 1)
|
||||
_, _, err = r.getOrCreateFolder(ctx, cfg, fakeService, cfg.Folder)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -433,7 +433,7 @@ func TestDashboardFileReader(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 1)
|
||||
_, _, err = r.getOrCreateFolder(ctx, cfg, fakeService, cfg.Folder)
|
||||
require.ErrorIs(t, err, dashboards.ErrFolderInvalidUID)
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestDuplicatesValidator(t *testing.T) {
|
||||
const folderName = "duplicates-validator-folder"
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 1)
|
||||
|
||||
fakeStore := &fakeDashboardStore{}
|
||||
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
|
||||
@@ -115,7 +115,7 @@ func TestDuplicatesValidator(t *testing.T) {
|
||||
const folderName = "duplicates-validator-folder"
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 1)
|
||||
|
||||
fakeStore := &fakeDashboardStore{}
|
||||
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
|
||||
@@ -221,7 +221,7 @@ func TestDuplicatesValidator(t *testing.T) {
|
||||
duplicates := duplicateValidator.getDuplicates()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, 1)
|
||||
|
||||
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards/validation"
|
||||
"github.com/grafana/grafana/pkg/services/query"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -375,7 +376,13 @@ func (pd *PublicDashboardServiceImpl) FindAllWithPagination(ctx context.Context,
|
||||
dashUIDs[i] = pubdash.DashboardUid
|
||||
}
|
||||
|
||||
dashboardsFound, err := pd.dashboardService.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: query.OrgID, DashboardUIDs: dashUIDs, SignedInUser: query.User, Limit: int64(len(dashUIDs))})
|
||||
dashboardsFound, err := pd.dashboardService.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: query.OrgID,
|
||||
DashboardUIDs: dashUIDs,
|
||||
SignedInUser: query.User,
|
||||
Limit: int64(len(dashUIDs)),
|
||||
Type: searchstore.TypeDashboard,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ErrInternalServerError.Errorf("FindAllWithPagination: GetDashboards: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
@@ -39,6 +40,8 @@ type ServiceAccountsService struct {
|
||||
log log.Logger
|
||||
backgroundLog log.Logger
|
||||
secretScanService secretscan.Checker
|
||||
orgService org.Service
|
||||
serverLock *serverlock.ServerLockService
|
||||
|
||||
secretScanEnabled bool
|
||||
secretScanInterval time.Duration
|
||||
@@ -54,6 +57,7 @@ func ProvideServiceAccountsService(
|
||||
orgService org.Service,
|
||||
acService accesscontrol.Service,
|
||||
permissions accesscontrol.ServiceAccountPermissionsService,
|
||||
serverLockService *serverlock.ServerLockService,
|
||||
) (*ServiceAccountsService, error) {
|
||||
serviceAccountsStore := database.ProvideServiceAccountsStore(
|
||||
cfg,
|
||||
@@ -71,6 +75,8 @@ func ProvideServiceAccountsService(
|
||||
store: serviceAccountsStore,
|
||||
log: log.New("serviceaccounts"),
|
||||
backgroundLog: log.New("serviceaccounts.background"),
|
||||
orgService: orgService,
|
||||
serverLock: serverLockService,
|
||||
}
|
||||
|
||||
if err := RegisterRoles(acService); err != nil {
|
||||
@@ -102,6 +108,17 @@ func (sa *ServiceAccountsService) Run(ctx context.Context) error {
|
||||
sa.log.Warn("Failed to get usage metrics", "error", err.Error())
|
||||
}
|
||||
|
||||
err := sa.serverLock.LockAndExecute(ctx, "migrate API keys to service accounts", time.Minute*30, func(context.Context) {
|
||||
err := sa.migrateAPIKeysForAllOrgs(ctx)
|
||||
if err != nil {
|
||||
sa.log.Warn("Failed to migrate API keys", "error", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
sa.log.Error("Failed to lock and execute the migration of API keys to service accounts", "error", err)
|
||||
}
|
||||
|
||||
updateStatsTicker := time.NewTicker(metricsCollectionInterval)
|
||||
defer updateStatsTicker.Stop()
|
||||
|
||||
@@ -299,6 +316,51 @@ func (sa *ServiceAccountsService) MigrateApiKeysToServiceAccounts(ctx context.Co
|
||||
return sa.store.MigrateApiKeysToServiceAccounts(ctx, orgID)
|
||||
}
|
||||
|
||||
func (sa *ServiceAccountsService) migrateAPIKeysForAllOrgs(ctx context.Context) error {
|
||||
sa.log.Debug("Starting to migrate API keys to service accounts")
|
||||
|
||||
total := 0
|
||||
migrated := 0
|
||||
failed := 0
|
||||
errorsTotal := 0
|
||||
|
||||
defer func() {
|
||||
if total > 0 || errorsTotal > 0 {
|
||||
sa.log.Info("API key migration finished", "total_keys", total, "successful_keys", migrated, "failed_keys", failed, "errors", errorsTotal)
|
||||
}
|
||||
setAPIKeyMigrationStats(total, migrated, failed)
|
||||
}()
|
||||
|
||||
orgs, err := sa.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, o := range orgs {
|
||||
sa.log.Debug("Migrating API keys for an org", "orgId", o.ID)
|
||||
|
||||
result, err := sa.store.MigrateApiKeysToServiceAccounts(ctx, o.ID)
|
||||
if err != nil {
|
||||
sa.log.Warn("Failed to migrate API keys", "error", err.Error(), "orgId", o.ID)
|
||||
errorsTotal += 1
|
||||
continue
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
sa.log.Warn("Some API keys failed to be migrated", "total_keys", result.Total, "failed_keys", result.Failed, "orgId", o.ID)
|
||||
} else if result.Total > 0 {
|
||||
sa.log.Info("API key migration was successful", "orgId", o.ID, "total_keys", result.Total)
|
||||
} else {
|
||||
sa.log.Debug("No API keys found to migrate", "orgId", o.ID)
|
||||
}
|
||||
|
||||
total += result.Total
|
||||
migrated += result.Migrated
|
||||
failed += result.Failed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validOrgID(orgID int64) error {
|
||||
if orgID == 0 {
|
||||
return serviceaccounts.ErrServiceAccountInvalidOrgID.Errorf("invalid org ID 0 has been specified")
|
||||
|
||||
@@ -20,6 +20,15 @@ var (
|
||||
// MStatTotalServiceAccountTokens is a metric gauge for total number of service account tokens
|
||||
MStatTotalServiceAccountTokens prometheus.Gauge
|
||||
|
||||
// MStatTotalMigratedAPIKeysToSATokens is a metric gauge for total number of API keys to be migrated to service account tokens
|
||||
MStatTotalMigratedAPIKeysToSATokens prometheus.Gauge
|
||||
|
||||
// MStatSuccessfullyMigratedAPIKeysToSATokens is a metric gauge for total number of successful migrations of API keys to service account tokens
|
||||
MStatSuccessfullyMigratedAPIKeysToSATokens prometheus.Gauge
|
||||
|
||||
// MStatFailedMigratedAPIKeysToSATokens is a metric gauge for total number of failed migrations of API keys to service account tokens
|
||||
MStatFailedMigratedAPIKeysToSATokens prometheus.Gauge
|
||||
|
||||
Initialised bool = false
|
||||
)
|
||||
|
||||
@@ -42,10 +51,31 @@ func init() {
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MStatTotalMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_total_migrated_api_keys_to_sa_tokens",
|
||||
Help: "total number of API keys to be migrated to service account tokens",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MStatSuccessfullyMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_successfully_migrated_api_keys_to_sa_tokens",
|
||||
Help: "total number of successful migrations of API keys to service account tokens",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MStatFailedMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_failed_migrated_api_keys_to_sa_tokens",
|
||||
Help: "total number of failed migrations of API keys to service account tokens",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
prometheus.MustRegister(
|
||||
MStatTotalServiceAccounts,
|
||||
MStatTotalServiceAccountTokens,
|
||||
MStatTotalServiceAccountsNoRole,
|
||||
MStatTotalMigratedAPIKeysToSATokens,
|
||||
MStatSuccessfullyMigratedAPIKeysToSATokens,
|
||||
MStatFailedMigratedAPIKeysToSATokens,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,3 +111,9 @@ func (sa *ServiceAccountsService) getUsageMetrics(ctx context.Context) (map[stri
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func setAPIKeyMigrationStats(total, migrated, failed int) {
|
||||
MStatTotalMigratedAPIKeysToSATokens.Set(float64(total))
|
||||
MStatSuccessfullyMigratedAPIKeysToSATokens.Set(float64(migrated))
|
||||
MStatFailedMigratedAPIKeysToSATokens.Set(float64(failed))
|
||||
}
|
||||
@@ -44,7 +44,7 @@ type sqlStatsService struct {
|
||||
func (ss *sqlStatsService) getDashboardCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) {
|
||||
count := int64(0)
|
||||
for _, org := range orgs {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, org.ID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
|
||||
dashsCount, err := ss.dashSvc.CountDashboardsInOrg(ctx, org.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -58,7 +58,7 @@ func (ss *sqlStatsService) getDashboardCount(ctx context.Context, orgs []*org.Or
|
||||
func (ss *sqlStatsService) getTagCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) {
|
||||
total := 0
|
||||
for _, org := range orgs {
|
||||
ctx, _ = identity.WithServiceIdentitiy(ctx, org.ID)
|
||||
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
|
||||
tags, err := ss.dashSvc.GetDashboardTags(ctx, &dashboards.GetDashboardTagsQuery{
|
||||
OrgID: org.ID,
|
||||
})
|
||||
@@ -74,7 +74,7 @@ func (ss *sqlStatsService) getTagCount(ctx context.Context, orgs []*org.OrgDTO)
|
||||
func (ss *sqlStatsService) getFolderCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) {
|
||||
total := 0
|
||||
for _, org := range orgs {
|
||||
ctx, ident := identity.WithServiceIdentitiy(ctx, org.ID)
|
||||
ctx, ident := identity.WithServiceIdentity(ctx, org.ID)
|
||||
folders, err := ss.folderSvc.GetFolders(ctx, folder.GetFoldersQuery{
|
||||
OrgID: org.ID,
|
||||
SignedInUser: ident,
|
||||
|
||||
@@ -157,6 +157,7 @@ type Cfg struct {
|
||||
DisableInitAdminCreation bool
|
||||
DisableBruteForceLoginProtection bool
|
||||
BruteForceLoginProtectionMaxAttempts int64
|
||||
DisableIPAddressLoginProtection bool
|
||||
CookieSecure bool
|
||||
CookieSameSiteDisabled bool
|
||||
CookieSameSiteMode http.SameSite
|
||||
@@ -1532,6 +1533,7 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error {
|
||||
|
||||
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
|
||||
cfg.BruteForceLoginProtectionMaxAttempts = security.Key("brute_force_login_protection_max_attempts").MustInt64(5)
|
||||
cfg.DisableIPAddressLoginProtection = security.Key("disable_ip_address_login_protection").MustBool(true)
|
||||
|
||||
// Ensure at least one login attempt can be performed.
|
||||
if cfg.BruteForceLoginProtectionMaxAttempts <= 0 {
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestIDTokenExtractor(t *testing.T) {
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
t.Run("should return an empty token when grafana identity is set", func(t *testing.T) {
|
||||
ctx, _ := identity.WithServiceIdentitiy(context.Background(), 0)
|
||||
ctx, _ := identity.WithServiceIdentity(context.Background(), 0)
|
||||
token, err := idTokenExtractor(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
|
||||
@@ -460,9 +460,9 @@ func (s *searchSupport) handleEvent(ctx context.Context, evt *WrittenEvent) {
|
||||
// record latency from when event was created to when it was indexed
|
||||
latencySeconds := float64(time.Now().UnixMicro()-evt.ResourceVersion) / 1e6
|
||||
span.AddEvent("index latency", trace.WithAttributes(attribute.Float64("latency_seconds", latencySeconds)))
|
||||
if latencySeconds > 5 {
|
||||
s.log.Debug("high index latency object details", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace)
|
||||
s.log.Warn("high index latency", "latency", latencySeconds)
|
||||
s.log.Debug("indexed new object", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace, "rv", evt.ResourceVersion)
|
||||
if latencySeconds > 1 {
|
||||
s.log.Warn("high index latency object details", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace, "rv", evt.ResourceVersion)
|
||||
}
|
||||
if IndexMetrics != nil {
|
||||
IndexMetrics.IndexLatency.WithLabelValues(evt.Key.Resource).Observe(latencySeconds)
|
||||
|
||||
@@ -1143,8 +1143,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
"__panelId__": "1"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"title": "AlwaysFiring",
|
||||
"condition": "A",
|
||||
"data": [{
|
||||
@@ -1186,8 +1184,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
"expr": "",
|
||||
"for":"0s",
|
||||
"grafana_alert": {
|
||||
"id": 2,
|
||||
"orgId": 1,
|
||||
"title": "AlwaysFiringButSilenced",
|
||||
"condition": "A",
|
||||
"data": [{
|
||||
@@ -1241,8 +1237,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
"__panelId__": "1"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"title": "AlwaysFiring",
|
||||
"condition": "A",
|
||||
"data": [{
|
||||
@@ -1583,7 +1577,6 @@ func TestIntegrationRuleCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
GrafanaManagedAlert: &apimodels.GettableGrafanaRule{
|
||||
OrgID: 1,
|
||||
Title: "test1 rule1",
|
||||
Condition: "A",
|
||||
Data: []apimodels.AlertQuery{
|
||||
@@ -2526,8 +2519,6 @@ func TestIntegrationQuota(t *testing.T) {
|
||||
"expr":"",
|
||||
"for": "2m",
|
||||
"grafana_alert":{
|
||||
"id":1,
|
||||
"orgId":1,
|
||||
"title":"Updated alert rule",
|
||||
"condition":"A",
|
||||
"data":[
|
||||
@@ -2641,8 +2632,6 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
|
||||
"annotation1": "val1"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"title": "rule under folder default",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
@@ -3124,8 +3113,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"label1": "val1"
|
||||
},
|
||||
"grafana_alert":{
|
||||
"id":1,
|
||||
"orgId":1,
|
||||
"title":"AlwaysFiring",
|
||||
"condition":"A",
|
||||
"data":[
|
||||
@@ -3170,8 +3157,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expr":"",
|
||||
"for": "0s",
|
||||
"grafana_alert":{
|
||||
"id":2,
|
||||
"orgId":1,
|
||||
"title":"AlwaysFiringButSilenced",
|
||||
"condition":"A",
|
||||
"data":[
|
||||
@@ -3488,8 +3473,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"label2": "val2"
|
||||
},
|
||||
"grafana_alert":{
|
||||
"id":1,
|
||||
"orgId":1,
|
||||
"title":"AlwaysNormal",
|
||||
"condition":"A",
|
||||
"data":[
|
||||
@@ -3607,8 +3590,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expr":"",
|
||||
"for": "30s",
|
||||
"grafana_alert":{
|
||||
"id":1,
|
||||
"orgId":1,
|
||||
"title":"AlwaysNormal",
|
||||
"condition":"A",
|
||||
"data":[
|
||||
@@ -3705,8 +3686,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expr":"",
|
||||
"for": "30s",
|
||||
"grafana_alert":{
|
||||
"id":1,
|
||||
"orgId":1,
|
||||
"title":"AlwaysNormal",
|
||||
"condition":"A",
|
||||
"data":[
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"title": "Rule1",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
@@ -60,8 +58,6 @@
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 2,
|
||||
"orgId": 1,
|
||||
"title": "Rule2",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 3,
|
||||
"orgId": 1,
|
||||
"title": "Rule3",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"title": "Rule1",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
@@ -60,8 +58,6 @@
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 2,
|
||||
"orgId": 1,
|
||||
"title": "Rule2",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
|
||||
@@ -17,17 +17,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
)
|
||||
|
||||
var gvr = schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.GroupVersionResource) {
|
||||
t.Run("simple crud+list", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
@@ -64,6 +58,7 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created, obj.GetName())
|
||||
require.Equal(t, int64(1), obj.GetGeneration())
|
||||
require.Equal(t, "Test empty dashboard", obj.Object["spec"].(map[string]any)["title"])
|
||||
|
||||
wrap, err := utils.MetaAccessor(obj)
|
||||
require.NoError(t, err)
|
||||
@@ -89,6 +84,7 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
require.Equal(t, obj.GetName(), updated.GetName())
|
||||
require.Equal(t, obj.GetUID(), updated.GetUID())
|
||||
require.Less(t, obj.GetResourceVersion(), updated.GetResourceVersion())
|
||||
require.Equal(t, "Changed title", updated.Object["spec"].(map[string]any)["title"])
|
||||
|
||||
// Delete the object, skipping the provisioned dashboard check
|
||||
zeroInt64 := int64(0)
|
||||
@@ -104,12 +100,17 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationDashboardsApp(t *testing.T) {
|
||||
func TestIntegrationDashboardsAppV0Alpha1(t *testing.T) {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Run("with dual writer mode 0", func(t *testing.T) {
|
||||
t.Run("v0alpha1 with dual writer mode 0", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
@@ -118,10 +119,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper)
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("with dual writer mode 1", func(t *testing.T) {
|
||||
t.Run("v0alpha1 with dual writer mode 1", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
@@ -130,10 +131,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper)
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("with dual writer mode 2", func(t *testing.T) {
|
||||
t.Run("v0alpha1 with dual writer mode 2", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
@@ -142,10 +143,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper)
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("with dual writer mode 3", func(t *testing.T) {
|
||||
t.Run("v0alpha1 with dual writer mode 3", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
@@ -154,10 +155,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper)
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("with dual writer mode 4", func(t *testing.T) {
|
||||
t.Run("v0alpha1 with dual writer mode 4", func(t *testing.T) {
|
||||
t.Skip("skipping test because of authorizer issue")
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
@@ -167,6 +168,78 @@ func TestIntegrationDashboardsApp(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper)
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationDashboardsAppV1Alpha1(t *testing.T) {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v1alpha1",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Run("v1alpha1 with dual writer mode 0", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("v1alpha1 with dual writer mode 1", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("v1alpha1 with dual writer mode 2", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("v1alpha1 with dual writer mode 3", func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
|
||||
t.Run("v1alpha1 with dual writer mode 4", func(t *testing.T) {
|
||||
t.Skip("skipping test because of authorizer issue")
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: 4,
|
||||
},
|
||||
},
|
||||
})
|
||||
runDashboardTest(t, helper, gvr)
|
||||
})
|
||||
}
|
||||
@@ -16013,10 +16013,6 @@
|
||||
"Error"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -16041,10 +16037,6 @@
|
||||
"notification_settings": {
|
||||
"$ref": "#/definitions/AlertRuleNotificationSettings"
|
||||
},
|
||||
"orgId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/definitions/Provenance"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
import { FolderPickerSkeleton } from './Skeleton';
|
||||
|
||||
const SuspendingNestedFolderPicker = lazy(() =>
|
||||
import('./NestedFolderPicker').then((module) => ({ default: module.NestedFolderPicker }))
|
||||
);
|
||||
|
||||
// Lazily load folder picker, is what is exposed to plugins through @grafana/runtime
|
||||
export const LazyFolderPicker = (props: Parameters<typeof SuspendingNestedFolderPicker>[0]) => {
|
||||
return (
|
||||
<Suspense fallback={<FolderPickerSkeleton />}>
|
||||
<SuspendingNestedFolderPicker {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { PermissionLevelString } from 'app/types';
|
||||
|
||||
import {
|
||||
treeViewersCanEdit,
|
||||
@@ -187,7 +186,7 @@ describe('NestedFolderPicker', () => {
|
||||
});
|
||||
|
||||
it('shows items the user can view, with the prop', async () => {
|
||||
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
|
||||
render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
await userEvent.click(button);
|
||||
@@ -209,7 +208,7 @@ describe('NestedFolderPicker', () => {
|
||||
});
|
||||
|
||||
it('can expand and collapse a folder to show its children', async () => {
|
||||
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
|
||||
render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
|
||||
|
||||
// Open the picker and wait for children to load
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
@@ -240,7 +239,7 @@ describe('NestedFolderPicker', () => {
|
||||
});
|
||||
|
||||
it('can expand and collapse a folder to show its children with the keyboard', async () => {
|
||||
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
|
||||
render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
|
||||
const button = await screen.findByRole('button', { name: 'Select folder' });
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface NestedFolderPickerProps {
|
||||
excludeUIDs?: string[];
|
||||
|
||||
/* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit */
|
||||
permission?: PermissionLevelString.View | PermissionLevelString.Edit;
|
||||
permission?: 'view' | 'edit';
|
||||
|
||||
/* Callback for when the user selects a folder */
|
||||
onChange?: (folderUID: string | undefined, folderName: string | undefined, repository?: RepositoryView) => void;
|
||||
@@ -53,7 +53,7 @@ async function getSearchResults(searchQuery: string, permission?: PermissionLeve
|
||||
query: searchQuery,
|
||||
kind: ['folder'],
|
||||
limit: 100,
|
||||
permission: permission,
|
||||
permission,
|
||||
});
|
||||
|
||||
const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
|
||||
@@ -66,7 +66,7 @@ export function NestedFolderPicker({
|
||||
showRootFolder = true,
|
||||
clearable = false,
|
||||
excludeUIDs,
|
||||
permission = PermissionLevelString.Edit,
|
||||
permission = 'edit',
|
||||
onChange,
|
||||
}: NestedFolderPickerProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -83,12 +83,24 @@ export function NestedFolderPicker({
|
||||
const overlayId = useId();
|
||||
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
|
||||
const lastSearchTimestamp = useRef<number>(0);
|
||||
|
||||
// Map the permission string union to enum value for compatibility
|
||||
const permissionLevel = useMemo(() => {
|
||||
if (permission === 'view') {
|
||||
return PermissionLevelString.View;
|
||||
} else if (permission === 'edit') {
|
||||
return PermissionLevelString.Edit;
|
||||
}
|
||||
|
||||
throw new Error('Invalid permission');
|
||||
}, [permission]);
|
||||
|
||||
const isBrowsing = Boolean(overlayOpen && !(search && searchResults));
|
||||
const {
|
||||
items: browseFlatTree,
|
||||
isLoading: isBrowseLoading,
|
||||
requestNextPage: fetchFolderPage,
|
||||
} = useFoldersQuery(isBrowsing, foldersOpenState, permission);
|
||||
} = useFoldersQuery(isBrowsing, foldersOpenState, permissionLevel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
@@ -99,7 +111,7 @@ export function NestedFolderPicker({
|
||||
const timestamp = Date.now();
|
||||
setIsFetchingSearchResults(true);
|
||||
|
||||
debouncedSearch(search, permission).then((queryResponse) => {
|
||||
debouncedSearch(search, permissionLevel).then((queryResponse) => {
|
||||
// Only keep the results if it's was issued after the most recently resolved search.
|
||||
// This prevents results showing out of order if first request is slower than later ones.
|
||||
// We don't need to worry about clearing the isFetching state either - if there's a later
|
||||
@@ -111,7 +123,7 @@ export function NestedFolderPicker({
|
||||
lastSearchTimestamp.current = timestamp;
|
||||
}
|
||||
});
|
||||
}, [search, permission]);
|
||||
}, [search, permissionLevel]);
|
||||
|
||||
// the order of middleware is important!
|
||||
const middleware = [
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { css } from '@emotion/css';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import type { GrafanaTheme2 } from '@grafana/data';
|
||||
import { getInputStyles, useStyles2 } from '@grafana/ui';
|
||||
|
||||
// This component is used as a fallback for codesplitting, so aim to keep
|
||||
// the bundle size of it as small as possible :)
|
||||
export function FolderPickerSkeleton() {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<button type="button" className={styles.fakeInput} aria-disabled>
|
||||
<Skeleton width={100} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
const baseStyles = getInputStyles({ theme });
|
||||
|
||||
return {
|
||||
wrapper: baseStyles.wrapper,
|
||||
inputWrapper: baseStyles.inputWrapper,
|
||||
fakeInput: css([
|
||||
baseStyles.input,
|
||||
{
|
||||
textAlign: 'left',
|
||||
},
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,14 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { forwardRef, ReactNode, ButtonHTMLAttributes } from 'react';
|
||||
import * as React from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, getInputStyles, useTheme2, Text } from '@grafana/ui';
|
||||
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { FolderPickerSkeleton } from './Skeleton';
|
||||
|
||||
interface TriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isLoading: boolean;
|
||||
handleClearSelection?: (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => void;
|
||||
@@ -28,6 +29,10 @@ function Trigger(
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <FolderPickerSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.inputWrapper}>
|
||||
@@ -43,9 +48,7 @@ function Trigger(
|
||||
{...rest}
|
||||
ref={ref}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton width={100} />
|
||||
) : label ? (
|
||||
{label ? (
|
||||
<Text truncate>{label}</Text>
|
||||
) : (
|
||||
<Text truncate color="secondary">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObjectState } from '@grafana/scenes';
|
||||
import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { isInCloneChain } from '../utils/clone';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
@@ -165,18 +166,18 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
|
||||
<>
|
||||
<div className={styles.expandOptionsWrapper}>
|
||||
<ToolbarButton
|
||||
tooltip={'Open options pane'}
|
||||
icon={'arrow-to-right'}
|
||||
tooltip={t('dashboard.edit-pane.open', 'Open options pane')}
|
||||
icon="arrow-to-right"
|
||||
onClick={onToggleCollapse}
|
||||
variant="canvas"
|
||||
className={styles.rotate180}
|
||||
aria-label={'Open options pane'}
|
||||
aria-label={t('dashboard.edit-pane.open', 'Open options pane')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{openOverlay && (
|
||||
<Resizable className={cx(styles.fixed, styles.container)} defaultSize={{ height: '100%', width: '20vw' }}>
|
||||
<ElementEditPane element={editableElement} key={editableElement.getTypeName()} />
|
||||
<ElementEditPane element={editableElement} key={editableElement.typeName} />
|
||||
</Resizable>
|
||||
)}
|
||||
</>
|
||||
@@ -185,7 +186,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={paneRef}>
|
||||
<ElementEditPane element={editableElement} key={editableElement.getTypeName()} />
|
||||
<ElementEditPane element={editableElement} key={editableElement.typeName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Input, TextArea } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
|
||||
import { EditableDashboardElement } from '../scene/types';
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
|
||||
export class DashboardEditableElement implements EditableDashboardElement {
|
||||
public isEditableDashboardElement: true = true;
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly typeName = 'Dashboard';
|
||||
|
||||
public constructor(private dashboard: DashboardScene) {}
|
||||
|
||||
@@ -21,13 +23,13 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
|
||||
const dashboardOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: 'Dashboard options',
|
||||
title: t('dashboard.options.title', 'Dashboard options'),
|
||||
id: 'dashboard-options',
|
||||
isOpenDefault: true,
|
||||
})
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: 'Title',
|
||||
title: t('dashboard.options.title-option', 'Title'),
|
||||
render: function renderTitle() {
|
||||
return <DashboardTitleInput dashboard={dashboard} />;
|
||||
},
|
||||
@@ -35,7 +37,7 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: 'Description',
|
||||
title: t('dashboard.options.description', 'Description'),
|
||||
render: function renderTitle() {
|
||||
return <DashboardDescriptionInput dashboard={dashboard} />;
|
||||
},
|
||||
@@ -47,10 +49,6 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
|
||||
return [dashboardOptions, layoutCategory];
|
||||
}
|
||||
|
||||
public getTypeName(): string {
|
||||
return 'Dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardTitleInput({ dashboard }: { dashboard: DashboardScene }) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Stack, useStyles2 } from '@grafana/ui';
|
||||
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
|
||||
|
||||
import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types';
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
export interface Props {
|
||||
element: EditableDashboardElement | MultiSelectedEditableDashboardElement;
|
||||
@@ -19,7 +20,7 @@ export function ElementEditPane({ element }: Props) {
|
||||
{element.renderActions && (
|
||||
<OptionsPaneCategory
|
||||
id="selected-item"
|
||||
title={element.getTypeName()}
|
||||
title={element.typeName}
|
||||
isOpenDefault={true}
|
||||
className={styles.noBorderTop}
|
||||
>
|
||||
|
||||
@@ -2,12 +2,9 @@ import { SceneObject, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||
import { ElementSelectionContextItem } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import {
|
||||
EditableDashboardElement,
|
||||
isBulkActionElement,
|
||||
isEditableDashboardElement,
|
||||
MultiSelectedEditableDashboardElement,
|
||||
} from '../scene/types';
|
||||
import { isBulkActionElement } from '../scene/types/BulkActionElement';
|
||||
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
import { DashboardEditableElement } from './DashboardEditableElement';
|
||||
import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement';
|
||||
|
||||
+5
-6
@@ -3,10 +3,13 @@ import { ReactNode } from 'react';
|
||||
import { Stack, Text, Button } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { BulkActionElement, MultiSelectedEditableDashboardElement } from '../scene/types';
|
||||
import { BulkActionElement } from '../scene/types/BulkActionElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement {
|
||||
public isMultiSelectedEditableDashboardElement: true = true;
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly typeName = 'Objects';
|
||||
|
||||
private items?: BulkActionElement[];
|
||||
|
||||
constructor(items: BulkActionElement[]) {
|
||||
@@ -19,10 +22,6 @@ export class MultiSelectedObjectsEditableElement implements MultiSelectedEditabl
|
||||
}
|
||||
};
|
||||
|
||||
public getTypeName(): string {
|
||||
return 'Objects';
|
||||
}
|
||||
|
||||
renderActions(): ReactNode {
|
||||
return (
|
||||
<Stack direction="column">
|
||||
|
||||
+3
-6
@@ -5,11 +5,12 @@ import { Button, Stack, Text } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
|
||||
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement {
|
||||
public isMultiSelectedEditableDashboardElement: true = true;
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly typeName = 'Panels';
|
||||
|
||||
private items?: VizPanel[];
|
||||
|
||||
@@ -34,10 +35,6 @@ export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEdita
|
||||
}
|
||||
};
|
||||
|
||||
public getTypeName(): string {
|
||||
return 'Panels';
|
||||
}
|
||||
|
||||
renderActions(): ReactNode {
|
||||
return (
|
||||
<Stack direction="column">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { sceneGraph, VizPanel } from '@grafana/scenes';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
PanelDescriptionTextArea,
|
||||
PanelFrameTitleInput,
|
||||
} from '../panel-edit/getPanelFrameOptions';
|
||||
import { BulkActionElement, EditableDashboardElement, isDashboardLayoutItem } from '../scene/types';
|
||||
import { BulkActionElement } from '../scene/types/BulkActionElement';
|
||||
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
|
||||
export class VizPanelEditableElement implements EditableDashboardElement, BulkActionElement {
|
||||
public isEditableDashboardElement: true = true;
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly typeName = 'Panel';
|
||||
|
||||
public constructor(private panel: VizPanel) {}
|
||||
|
||||
@@ -26,13 +29,13 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
|
||||
const panelOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: 'Panel options',
|
||||
title: t('dashboard.viz-panel.options.title', 'Panel options'),
|
||||
id: 'panel-options',
|
||||
isOpenDefault: true,
|
||||
})
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: 'Title',
|
||||
title: t('dashboard.viz-panel.options.title-option', 'Title'),
|
||||
value: panel.state.title,
|
||||
popularRank: 1,
|
||||
render: function renderTitle() {
|
||||
@@ -42,7 +45,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: 'Description',
|
||||
title: t('dashboard.viz-panel.options.description', 'Description'),
|
||||
value: panel.state.description,
|
||||
render: function renderDescription() {
|
||||
return <PanelDescriptionTextArea panel={panel} />;
|
||||
@@ -51,7 +54,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: 'Transparent background',
|
||||
title: t('dashboard.viz-panel.options.transparent-background', 'Transparent background'),
|
||||
render: function renderTransparent() {
|
||||
return <PanelBackgroundSwitch panel={panel} />;
|
||||
},
|
||||
@@ -96,16 +99,12 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
return categories;
|
||||
}
|
||||
|
||||
public getTypeName(): string {
|
||||
return 'Panel';
|
||||
}
|
||||
|
||||
public onDelete = () => {
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
|
||||
layout.removePanel(this.panel);
|
||||
};
|
||||
|
||||
public renderActions(): React.ReactNode {
|
||||
public renderActions(): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="secondary">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types';
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
import { ElementSelection } from './ElementSelection';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api';
|
||||
|
||||
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
|
||||
import { getPanelChanges } from '../saving/getDashboardChanges';
|
||||
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types';
|
||||
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
|
||||
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
|
||||
import {
|
||||
activateSceneObjectAndParentTree,
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user