Compare commits
13 Commits
release-11
...
v11.6.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e34314f7 | ||
|
|
a58aee6987 | ||
|
|
d4fcd25632 | ||
|
|
40fc660cc8 | ||
|
|
b0a08807b8 | ||
|
|
9e20a7239f | ||
|
|
0e676de335 | ||
|
|
46b9094c20 | ||
|
|
3f28229ce7 | ||
|
|
74248bb482 | ||
|
|
0b928b9771 | ||
|
|
784bd3ee87 | ||
|
|
a599222a63 |
@@ -25,4 +25,5 @@ jobs:
|
||||
patch_ref: "${{ github.base_ref }}" # this is the target branch name, Ex: "main"
|
||||
patch_repo: "grafana/grafana-security-patches"
|
||||
patch_prefix: "${{ github.event.pull_request.number }}"
|
||||
sender: "${{ github.event.pull_request.user.login }}"
|
||||
secrets: inherit # zizmor: ignore[secrets-inherit]
|
||||
|
||||
13
.github/workflows/release-build.yml
vendored
13
.github/workflows/release-build.yml
vendored
@@ -311,20 +311,29 @@ jobs:
|
||||
repositories: '["grafana"]'
|
||||
permissions: '{"issues": "write", "pull_requests": "write", "contents": "read"}'
|
||||
- name: Find PR
|
||||
continue-on-error: true
|
||||
id: find-pr
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GRAFANA_COMMIT: ${{ needs.setup.outputs.grafana-commit }}
|
||||
run: echo "ISSUE_NUMBER=$(gh api "/repos/grafana/grafana/commits/${GRAFANA_COMMIT}/pulls" | jq -r '.[0].number')" >> "$GITHUB_ENV"
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -eo pipefail
|
||||
gh api "/repos/${REPO}/commits/${GRAFANA_COMMIT}/pulls" | jq -r '.[0].number' | tee issue_number.txt
|
||||
echo "ISSUE_NUMBER=$(cat issue_number.txt)" >> "$GITHUB_ENV"
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3
|
||||
if: ${{ steps.find-pr.outcome == 'success' }}
|
||||
id: fc
|
||||
continue-on-error: true
|
||||
with:
|
||||
issue-number: ${{ env.ISSUE_NUMBER }}
|
||||
comment-author: 'grafana-delivery-bot[bot]'
|
||||
body-includes: GitHub Actions Build
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Create or update comment
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v4
|
||||
if: ${{ steps.find-pr.outcome == 'success' }} # Run even if comment wasn't found
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
|
||||
@@ -1453,7 +1453,7 @@ type GetHomeDashboardResponseBody struct {
|
||||
// swagger:response dashboardVersionsResponse
|
||||
type DashboardVersionsResponse struct {
|
||||
// in: body
|
||||
Body []dashver.DashboardVersionMeta `json:"body"`
|
||||
Body *dashver.DashboardVersionResponseMeta `json:"body"`
|
||||
}
|
||||
|
||||
// swagger:response dashboardVersionResponse
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
stdlog "log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -401,6 +402,26 @@ func (hs *HTTPServer) AddNamedMiddleware(middleware routing.RegisterNamedMiddlew
|
||||
hs.namedMiddlewares = append(hs.namedMiddlewares, middleware)
|
||||
}
|
||||
|
||||
type customErrorLogger struct {
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
const tlsHandshakeErrorPrefix = "http: TLS handshake error from"
|
||||
const tlsHandshakeErrorSuffix = "EOF"
|
||||
|
||||
func (w *customErrorLogger) Write(msg []byte) (int, error) {
|
||||
// checks if the error is a TLS handshake error that ends with EOF
|
||||
if strings.Contains(string(msg), tlsHandshakeErrorPrefix) && strings.Contains(string(msg), tlsHandshakeErrorSuffix) {
|
||||
// log at debug level and remove new lines
|
||||
w.log.Debug(strings.ReplaceAll(string(msg), "\n", ""))
|
||||
} else {
|
||||
// log the error as is using the standard logger (the same way as the default http server does)
|
||||
stdlog.Print(string(msg))
|
||||
}
|
||||
|
||||
return len(msg), nil
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) Run(ctx context.Context) error {
|
||||
hs.context = ctx
|
||||
|
||||
@@ -413,6 +434,12 @@ func (hs *HTTPServer) Run(ctx context.Context) error {
|
||||
Handler: hs.web,
|
||||
ReadTimeout: hs.Cfg.ReadTimeout,
|
||||
}
|
||||
|
||||
customErrorLogger := &customErrorLogger{
|
||||
log: hs.log,
|
||||
}
|
||||
hs.httpSrv.ErrorLog = stdlog.New(customErrorLogger, "", 0)
|
||||
|
||||
switch hs.Cfg.Protocol {
|
||||
case setting.HTTP2Scheme, setting.HTTPSScheme:
|
||||
if err := hs.configureTLS(); err != nil {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -41,8 +40,10 @@ var getViewIndex = func() string {
|
||||
return viewIndex
|
||||
}
|
||||
|
||||
// Only allow redirects that start with a slash followed by an alphanumerical character, a dash or an underscore.
|
||||
var redirectRe = regexp.MustCompile(`^/[a-zA-Z0-9-_].*`)
|
||||
var redirectAllowRe = regexp.MustCompile(`^/[a-zA-Z0-9-_./]*$`)
|
||||
|
||||
// Do not allow redirect URLs that contain "//" or ".."
|
||||
var redirectDenyRe = regexp.MustCompile(`(//|\.\.)`)
|
||||
|
||||
var (
|
||||
errAbsoluteRedirectTo = errors.New("absolute URLs are not allowed for redirect_to cookie value")
|
||||
@@ -64,26 +65,11 @@ func (hs *HTTPServer) ValidateRedirectTo(redirectTo string) error {
|
||||
return errForbiddenRedirectTo
|
||||
}
|
||||
|
||||
// path should have exactly one leading slash
|
||||
if !strings.HasPrefix(to.Path, "/") {
|
||||
if redirectDenyRe.MatchString(to.Path) {
|
||||
return errForbiddenRedirectTo
|
||||
}
|
||||
|
||||
if strings.HasPrefix(to.Path, "//") {
|
||||
return errForbiddenRedirectTo
|
||||
}
|
||||
|
||||
if to.Path != "/" && !redirectRe.MatchString(to.Path) {
|
||||
return errForbiddenRedirectTo
|
||||
}
|
||||
|
||||
cleanPath := path.Clean(to.Path)
|
||||
// "." is what path.Clean returns for empty paths
|
||||
if cleanPath == "." {
|
||||
return errForbiddenRedirectTo
|
||||
}
|
||||
|
||||
if cleanPath != "/" && !redirectRe.MatchString(cleanPath) {
|
||||
if to.Path != "/" && !redirectAllowRe.MatchString(to.Path) {
|
||||
return errForbiddenRedirectTo
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ func GolangContainer(
|
||||
}
|
||||
|
||||
container := golang.Container(d, platform, goVersion)
|
||||
|
||||
if opts.CGOEnabled {
|
||||
container = container.
|
||||
WithExec([]string{"apk", "add", "--update", "wget", "build-base", "alpine-sdk", "musl", "musl-dev", "xz"}).
|
||||
|
||||
@@ -3,7 +3,6 @@ package middleware
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
@@ -13,8 +12,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
// Only allow redirects that start with a slash followed by an alphanumerical character, a dash or an underscore.
|
||||
var redirectRe = regexp.MustCompile(`^/?[a-zA-Z0-9-_].*`)
|
||||
var redirectAllowRe = regexp.MustCompile(`^/?[a-zA-Z0-9-_./]*$`)
|
||||
|
||||
// Do not allow redirect URLs that contain "//" or ".."
|
||||
var redirectDenyRe = regexp.MustCompile(`(//|\.\.)`)
|
||||
|
||||
// OrgRedirect changes org and redirects users if the
|
||||
// querystring `orgId` doesn't match the active org.
|
||||
@@ -66,9 +67,9 @@ func OrgRedirect(cfg *setting.Cfg, userSvc user.Service) web.Handler {
|
||||
}
|
||||
|
||||
func validRedirectPath(p string) bool {
|
||||
if p != "" && p != "/" && !redirectRe.MatchString(p) {
|
||||
if redirectDenyRe.MatchString(p) {
|
||||
return false
|
||||
}
|
||||
cleanPath := path.Clean(p)
|
||||
return cleanPath == "." || cleanPath == "/" || redirectRe.MatchString(cleanPath)
|
||||
|
||||
return p == "" || p == "/" || redirectAllowRe.MatchString(p)
|
||||
}
|
||||
|
||||
@@ -131,7 +131,9 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context,
|
||||
searchstore.OrgFilter{OrgId: query.OrgID},
|
||||
}
|
||||
|
||||
var dashboardUIDs []string
|
||||
if query.DashboardUID != "" {
|
||||
dashboardUIDs = append(dashboardUIDs, query.DashboardUID)
|
||||
filters = append(filters, searchstore.DashboardFilter{
|
||||
UIDs: []string{query.DashboardUID},
|
||||
})
|
||||
@@ -143,12 +145,13 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context,
|
||||
}
|
||||
|
||||
dashs, err := authz.dashSvc.SearchDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: query.SignedInUser.GetOrgID(),
|
||||
Filters: filters,
|
||||
SignedInUser: query.SignedInUser,
|
||||
Page: query.Page,
|
||||
Type: filterType,
|
||||
Limit: authz.searchDashboardsPageLimit,
|
||||
DashboardUIDs: dashboardUIDs,
|
||||
OrgId: query.SignedInUser.GetOrgID(),
|
||||
Filters: filters,
|
||||
SignedInUser: query.SignedInUser,
|
||||
Page: query.Page,
|
||||
Type: filterType,
|
||||
Limit: authz.searchDashboardsPageLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -18,18 +18,24 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/annotations/testutil"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/database"
|
||||
dashboardsservice "github.com/grafana/grafana/pkg/services/dashboards/service"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/quota/quotatest"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/permissions"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
|
||||
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -225,3 +231,90 @@ func TestIntegrationAuthorize(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardsWithVisibleAnnotations(t *testing.T) {
|
||||
store := db.InitTestDB(t)
|
||||
|
||||
user := &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
// Create permission filters
|
||||
p1 := permissions.NewAccessControlDashboardPermissionFilter(user, dashboardaccess.PERMISSION_VIEW, searchstore.TypeDashboard, featuremgmt.WithFeatures(), true, store.GetDialect())
|
||||
p2 := searchstore.OrgFilter{OrgId: 1}
|
||||
|
||||
// If DashboardUID is provided, it should be added as a filter
|
||||
p3 := searchstore.DashboardFilter{UIDs: []string{"uid1"}}
|
||||
|
||||
dashSvc := &dashboards.FakeDashboardService{}
|
||||
|
||||
// First call, without DashboardUID
|
||||
queryNoDashboardUID := &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: 1,
|
||||
SignedInUser: user,
|
||||
Type: "dash-db",
|
||||
Limit: int64(100),
|
||||
Page: int64(1),
|
||||
Filters: []any{
|
||||
p1,
|
||||
p2,
|
||||
},
|
||||
}
|
||||
dashSvc.On("SearchDashboards", mock.Anything, queryNoDashboardUID).Return(model.HitList{
|
||||
&model.Hit{UID: "uid1", ID: 101},
|
||||
&model.Hit{UID: "uid2", ID: 102},
|
||||
}, nil)
|
||||
|
||||
// Second call, with DashboardUID filter
|
||||
queryWithDashboardUID := &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: 1,
|
||||
SignedInUser: user,
|
||||
Type: "dash-db",
|
||||
Limit: int64(100),
|
||||
Page: int64(1),
|
||||
Filters: []any{
|
||||
p1,
|
||||
p2,
|
||||
// This filter should be added on second call
|
||||
p3,
|
||||
},
|
||||
DashboardUIDs: []string{"uid1"},
|
||||
}
|
||||
|
||||
dashSvc.On("SearchDashboards", mock.Anything, queryWithDashboardUID).Return(model.HitList{
|
||||
&model.Hit{UID: "uid1", ID: 101},
|
||||
}, nil)
|
||||
|
||||
// Create auth service
|
||||
authz := &AuthService{
|
||||
db: store,
|
||||
features: featuremgmt.WithFeatures(),
|
||||
dashSvc: dashSvc,
|
||||
searchDashboardsPageLimit: 100,
|
||||
}
|
||||
|
||||
// First call without DashboardUID
|
||||
result, err := authz.dashboardsWithVisibleAnnotations(context.Background(), annotations.ItemQuery{
|
||||
SignedInUser: user,
|
||||
OrgID: 1,
|
||||
Page: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
// Should return two dashboards
|
||||
assert.Equal(t, map[string]int64{"uid1": 101, "uid2": 102}, result)
|
||||
// Ensure SearchDashboards was called with correct query
|
||||
dashSvc.AssertCalled(t, "SearchDashboards", mock.Anything, queryNoDashboardUID)
|
||||
|
||||
// Second call with DashboardUID
|
||||
result, err = authz.dashboardsWithVisibleAnnotations(context.Background(), annotations.ItemQuery{
|
||||
SignedInUser: user,
|
||||
OrgID: 1,
|
||||
Page: 1,
|
||||
DashboardUID: "uid1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
// Should only return one dashboard
|
||||
assert.Equal(t, map[string]int64{"uid1": 101}, result)
|
||||
// Ensure SearchDashboards was called with correct query (including DashboardUID filter)
|
||||
dashSvc.AssertCalled(t, "SearchDashboards", mock.Anything, queryWithDashboardUID)
|
||||
}
|
||||
|
||||
@@ -462,10 +462,12 @@ func (s *GettableStatus) UnmarshalJSON(b []byte) error {
|
||||
|
||||
s.Cluster = amStatus.Cluster
|
||||
s.Config = &PostableApiAlertingConfig{Config: Config{
|
||||
Global: c.Global,
|
||||
Route: AsGrafanaRoute(c.Route),
|
||||
InhibitRules: c.InhibitRules,
|
||||
Templates: c.Templates,
|
||||
Global: c.Global,
|
||||
Route: AsGrafanaRoute(c.Route),
|
||||
InhibitRules: c.InhibitRules,
|
||||
Templates: c.Templates,
|
||||
MuteTimeIntervals: c.MuteTimeIntervals,
|
||||
TimeIntervals: c.TimeIntervals,
|
||||
}}
|
||||
s.Uptime = amStatus.Uptime
|
||||
s.VersionInfo = amStatus.VersionInfo
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package definitions
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -13,6 +15,32 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed test-data/*.*
|
||||
var testData embed.FS
|
||||
|
||||
func Test_GettableStatusUnmarshalJSON(t *testing.T) {
|
||||
incoming, err := testData.ReadFile(path.Join("test-data", "gettable-status.json"))
|
||||
require.Nil(t, err)
|
||||
|
||||
var actual GettableStatus
|
||||
require.NoError(t, json.Unmarshal(incoming, &actual))
|
||||
|
||||
actualJson, err := json.Marshal(actual)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected, err := testData.ReadFile(path.Join("test-data", "gettable-status-expected.json"))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(expected), string(actualJson))
|
||||
|
||||
v := reflect.ValueOf(actual.Config.Config)
|
||||
ty := v.Type()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldName := ty.Field(i).Name
|
||||
assert.False(t, field.IsZero(), "Field %s should not be zero value", fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GettableUserConfigUnmarshaling(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
desc, input string
|
||||
@@ -138,10 +166,10 @@ alertmanager_config: |
|
||||
func Test_GettableUserConfigRoundtrip(t *testing.T) {
|
||||
// raw contains secret fields. We'll unmarshal, re-marshal, and ensure
|
||||
// the fields are not redacted.
|
||||
yamlEncoded, err := os.ReadFile("alertmanager_test_artifact.yaml")
|
||||
yamlEncoded, err := testData.ReadFile(path.Join("test-data", "alertmanager_test_artifact.yaml"))
|
||||
require.Nil(t, err)
|
||||
|
||||
jsonEncoded, err := os.ReadFile("alertmanager_test_artifact.json")
|
||||
jsonEncoded, err := testData.ReadFile(path.Join("test-data", "alertmanager_test_artifact.json"))
|
||||
require.Nil(t, err)
|
||||
|
||||
// test GettableUserConfig (yamlDecode -> jsonEncode)
|
||||
@@ -160,7 +188,7 @@ func Test_GettableUserConfigRoundtrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_Marshaling_Validation(t *testing.T) {
|
||||
jsonEncoded, err := os.ReadFile("alertmanager_test_artifact.json")
|
||||
jsonEncoded, err := testData.ReadFile(path.Join("test-data", "alertmanager_test_artifact.json"))
|
||||
require.Nil(t, err)
|
||||
|
||||
var tmp GettableUserConfig
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"cluster": {
|
||||
"name": "01K7SGS3KSRG8FT5RZQPFN72NB",
|
||||
"peers": [
|
||||
{
|
||||
"address": "172.18.0.5:9094",
|
||||
"name": "01K7SGS3KSRG8FT5RZQPFN72NB"
|
||||
}
|
||||
],
|
||||
"status": "ready"
|
||||
},
|
||||
"config": {
|
||||
"global": {
|
||||
"resolve_timeout": "5m",
|
||||
"http_config": {
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": null
|
||||
},
|
||||
"smtp_from": "alertmanager@example.org",
|
||||
"smtp_hello": "localhost",
|
||||
"smtp_smarthost": "localhost:25",
|
||||
"smtp_require_tls": true,
|
||||
"pagerduty_url": "https://events.pagerduty.com/v2/enqueue",
|
||||
"opsgenie_api_url": "https://api.opsgenie.com/",
|
||||
"wechat_api_url": "https://qyapi.weixin.qq.com/cgi-bin/",
|
||||
"victorops_api_url": "https://alert.victorops.com/integrations/generic/20131114/alert/",
|
||||
"telegram_api_url": "https://api.telegram.org",
|
||||
"webex_api_url": "https://webexapis.com/v1/messages"
|
||||
},
|
||||
"route": {
|
||||
"receiver": "team-X-mails",
|
||||
"group_by": [
|
||||
"alertname",
|
||||
"cluster"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"receiver": "team-X-mails",
|
||||
"matchers": [
|
||||
"service=~\"^(foo1|foo2|baz)$\""
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"receiver": "team-X-pager",
|
||||
"matchers": [
|
||||
"severity=\"critical\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"receiver": "team-Y-mails",
|
||||
"matchers": [
|
||||
"service=\"files\""
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"receiver": "team-Y-pager",
|
||||
"matchers": [
|
||||
"severity=\"critical\""
|
||||
],
|
||||
"mute_time_intervals": [
|
||||
"nightly-quiet-time-deprecated"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"receiver": "team-DB-pager",
|
||||
"group_by": [
|
||||
"alertname",
|
||||
"cluster",
|
||||
"database"
|
||||
],
|
||||
"matchers": [
|
||||
"service=\"database\""
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"receiver": "team-X-pager",
|
||||
"matchers": [
|
||||
"owner=\"team-X\""
|
||||
],
|
||||
"mute_time_intervals": [
|
||||
"nightly-quiet-time"
|
||||
]
|
||||
},
|
||||
{
|
||||
"receiver": "team-Y-pager",
|
||||
"matchers": [
|
||||
"owner=\"team-Y\""
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"group_wait": "30s",
|
||||
"group_interval": "5m",
|
||||
"repeat_interval": "3h"
|
||||
},
|
||||
"inhibit_rules": [
|
||||
{
|
||||
"source_matchers": [
|
||||
"severity=\"critical\""
|
||||
],
|
||||
"target_matchers": [
|
||||
"severity=\"warning\""
|
||||
],
|
||||
"equal": [
|
||||
"alertname"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mute_time_intervals": [
|
||||
{
|
||||
"name": "nightly-quiet-time-deprecated",
|
||||
"time_intervals": [
|
||||
{
|
||||
"times": [
|
||||
{
|
||||
"start_time": "18:00",
|
||||
"end_time": "23:59"
|
||||
},
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"end_time": "07:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"time_intervals": [
|
||||
{
|
||||
"name": "nightly-quiet-time",
|
||||
"time_intervals": [
|
||||
{
|
||||
"times": [
|
||||
{
|
||||
"start_time": "18:00",
|
||||
"end_time": "23:59"
|
||||
},
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"end_time": "07:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"templates": [
|
||||
"/etc/alertmanager/templates/*.tmpl"
|
||||
],
|
||||
"receivers": [
|
||||
{
|
||||
"name": "team-X-mails",
|
||||
"email_configs": [
|
||||
{
|
||||
"send_resolved": false,
|
||||
"to": "team-X+alerts@example.org, team-Y+alerts@example.org",
|
||||
"from": "alertmanager@example.org",
|
||||
"hello": "localhost",
|
||||
"smarthost": "localhost:25",
|
||||
"headers": {
|
||||
"From": "alertmanager@example.org",
|
||||
"Subject": "{{ template \"email.default.subject\" . }}",
|
||||
"To": "team-X+alerts@example.org, team-Y+alerts@example.org"
|
||||
},
|
||||
"html": "{{ template \"email.default.html\" . }}",
|
||||
"require_tls": true,
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "team-X-pager",
|
||||
"email_configs": [
|
||||
{
|
||||
"send_resolved": false,
|
||||
"to": "team-X+alerts-critical@example.org",
|
||||
"from": "alertmanager@example.org",
|
||||
"hello": "localhost",
|
||||
"smarthost": "localhost:25",
|
||||
"headers": {
|
||||
"From": "alertmanager@example.org",
|
||||
"Subject": "{{ template \"email.default.subject\" . }}",
|
||||
"To": "team-X+alerts-critical@example.org"
|
||||
},
|
||||
"html": "{{ template \"email.default.html\" . }}",
|
||||
"require_tls": true,
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagerduty_configs": [
|
||||
{
|
||||
"send_resolved": true,
|
||||
"http_config": {
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": null
|
||||
},
|
||||
"routing_key": "<secret>",
|
||||
"url": "https://events.pagerduty.com/v2/enqueue",
|
||||
"client": "{{ template \"pagerduty.default.client\" . }}",
|
||||
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
|
||||
"description": "{{ template \"pagerduty.default.description\" .}}",
|
||||
"details": {
|
||||
"firing": "{{ template \"pagerduty.default.instances\" .Alerts.Firing }}",
|
||||
"num_firing": "{{ .Alerts.Firing | len }}",
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": "{{ template \"pagerduty.default.instances\" .Alerts.Resolved }}"
|
||||
},
|
||||
"source": "{{ template \"pagerduty.default.client\" . }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "team-Y-mails",
|
||||
"email_configs": [
|
||||
{
|
||||
"send_resolved": false,
|
||||
"to": "team-Y+alerts@example.org",
|
||||
"from": "alertmanager@example.org",
|
||||
"hello": "localhost",
|
||||
"smarthost": "localhost:25",
|
||||
"headers": {
|
||||
"From": "alertmanager@example.org",
|
||||
"Subject": "{{ template \"email.default.subject\" . }}",
|
||||
"To": "team-Y+alerts@example.org"
|
||||
},
|
||||
"html": "{{ template \"email.default.html\" . }}",
|
||||
"require_tls": true,
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "team-Y-pager",
|
||||
"pagerduty_configs": [
|
||||
{
|
||||
"send_resolved": true,
|
||||
"http_config": {
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": null
|
||||
},
|
||||
"routing_key": "<secret>",
|
||||
"url": "https://events.pagerduty.com/v2/enqueue",
|
||||
"client": "{{ template \"pagerduty.default.client\" . }}",
|
||||
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
|
||||
"description": "{{ template \"pagerduty.default.description\" .}}",
|
||||
"details": {
|
||||
"firing": "{{ template \"pagerduty.default.instances\" .Alerts.Firing }}",
|
||||
"num_firing": "{{ .Alerts.Firing | len }}",
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": "{{ template \"pagerduty.default.instances\" .Alerts.Resolved }}"
|
||||
},
|
||||
"source": "{{ template \"pagerduty.default.client\" . }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "team-DB-pager",
|
||||
"pagerduty_configs": [
|
||||
{
|
||||
"send_resolved": true,
|
||||
"http_config": {
|
||||
"tls_config": {
|
||||
"insecure_skip_verify": false
|
||||
},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": null
|
||||
},
|
||||
"routing_key": "<secret>",
|
||||
"url": "https://events.pagerduty.com/v2/enqueue",
|
||||
"client": "{{ template \"pagerduty.default.client\" . }}",
|
||||
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
|
||||
"description": "{{ template \"pagerduty.default.description\" .}}",
|
||||
"details": {
|
||||
"firing": "{{ template \"pagerduty.default.instances\" .Alerts.Firing }}",
|
||||
"num_firing": "{{ .Alerts.Firing | len }}",
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": "{{ template \"pagerduty.default.instances\" .Alerts.Resolved }}"
|
||||
},
|
||||
"source": "{{ template \"pagerduty.default.client\" . }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"uptime": "2025-10-17T16:41:50.500Z",
|
||||
"versionInfo": {
|
||||
"branch": "HEAD",
|
||||
"buildDate": "20250115-14:22:34",
|
||||
"buildUser": "root@40be7f318ba7",
|
||||
"goVersion": "go1.23.4",
|
||||
"revision": "4ce04fb010bd626fca35928dcfe82f6f2da52ced",
|
||||
"version": "0.28.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -67,4 +67,139 @@ func addDbFileStorageMigration(mg *migrator.Migrator) {
|
||||
|
||||
mg.AddMigration("migrate contents column to mediumblob for MySQL", migrator.NewRawSQLMigration("").
|
||||
Mysql("ALTER TABLE file MODIFY contents MEDIUMBLOB;"))
|
||||
|
||||
convertFilePathHashIndexToPrimaryKey(mg)
|
||||
convertFileMetaPathHashKeyIndexToPrimaryKey(mg)
|
||||
}
|
||||
|
||||
// This converts the existing unique constraint UQE_file_path_hash to a primary key in file table
|
||||
func convertFilePathHashIndexToPrimaryKey(mg *migrator.Migrator) {
|
||||
// migration 1 is to handle cases where the table was created with sql_generate_invisible_primary_key = ON
|
||||
// in this case we need to do everything in one sql statement
|
||||
mysqlMigration1 := migrator.NewRawSQLMigration("").Mysql(`
|
||||
ALTER TABLE file
|
||||
DROP PRIMARY KEY,
|
||||
DROP COLUMN my_row_id,
|
||||
DROP INDEX UQE_file_path_hash,
|
||||
ADD PRIMARY KEY (path_hash);
|
||||
`)
|
||||
mysqlMigration1.Condition = &migrator.IfColumnExistsCondition{TableName: "file", ColumnName: "my_row_id"}
|
||||
mg.AddMigration("drop my_row_id and add primary key to file table if my_row_id exists (auto-generated mysql column)", mysqlMigration1)
|
||||
|
||||
mysqlMigration2 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file DROP INDEX UQE_file_path_hash`)
|
||||
mysqlMigration2.Condition = &migrator.IfIndexExistsCondition{TableName: "file", IndexName: "UQE_file_path_hash"}
|
||||
mg.AddMigration("drop file_path unique index from file table if it exists (mysql)", mysqlMigration2)
|
||||
|
||||
mysqlMigration3 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file ADD PRIMARY KEY (path_hash);`)
|
||||
mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file", ColumnName: "path_hash"}
|
||||
mg.AddMigration("add primary key to file table if it doesn't exist (mysql)", mysqlMigration3)
|
||||
|
||||
postgres := `
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Drop the unique constraint if it exists
|
||||
DROP INDEX IF EXISTS "UQE_file_path_hash";
|
||||
|
||||
-- Add primary key if it doesn't already exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_index i WHERE indrelid = 'file'::regclass AND indisprimary) THEN
|
||||
ALTER TABLE file ADD PRIMARY KEY (path_hash);
|
||||
END IF;
|
||||
END $$;
|
||||
`
|
||||
|
||||
sqlite := `
|
||||
-- For SQLite we need to recreate the table with primary key. CREATE TABLE was generated by ".schema file" command after running migration.
|
||||
CREATE TABLE file_new
|
||||
(
|
||||
path TEXT NOT NULL,
|
||||
path_hash TEXT NOT NULL,
|
||||
parent_folder_path_hash TEXT NOT NULL,
|
||||
contents BLOB NOT NULL,
|
||||
etag TEXT NOT NULL,
|
||||
cache_control TEXT NOT NULL,
|
||||
content_disposition TEXT NOT NULL,
|
||||
updated DATETIME NOT NULL,
|
||||
created DATETIME NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (path_hash)
|
||||
);
|
||||
|
||||
INSERT INTO file_new (path, path_hash, parent_folder_path_hash, contents, etag, cache_control, content_disposition, updated, created, size, mime_type)
|
||||
SELECT path, path_hash, parent_folder_path_hash, contents, etag, cache_control, content_disposition, updated, created, size, mime_type FROM file;
|
||||
|
||||
DROP TABLE file;
|
||||
ALTER TABLE file_new RENAME TO file;
|
||||
|
||||
CREATE INDEX IDX_file_parent_folder_path_hash ON file (parent_folder_path_hash);
|
||||
`
|
||||
|
||||
// postgres and sqlite statements are idempotent so we can have only one condition-less migration
|
||||
migration := migrator.NewRawSQLMigration("").
|
||||
Postgres(postgres).
|
||||
SQLite(sqlite)
|
||||
|
||||
mg.AddMigration("add primary key to file table (postgres and sqlite)", migration)
|
||||
}
|
||||
|
||||
// This converts the existing unique constraint UQE_file_meta_path_hash_key to a primary key in file_meta table
|
||||
func convertFileMetaPathHashKeyIndexToPrimaryKey(mg *migrator.Migrator) {
|
||||
// migration 1 is to handle cases where the table was created with sql_generate_invisible_primary_key = ON
|
||||
// in this case we need to do everything in one sql statement
|
||||
mysqlMigration1 := migrator.NewRawSQLMigration("").Mysql(`
|
||||
ALTER TABLE file_meta
|
||||
DROP PRIMARY KEY,
|
||||
DROP COLUMN my_row_id,
|
||||
DROP INDEX UQE_file_meta_path_hash_key,
|
||||
ADD PRIMARY KEY (path_hash, ` + "`key`" + `);
|
||||
`)
|
||||
mysqlMigration1.Condition = &migrator.IfColumnExistsCondition{TableName: "file_meta", ColumnName: "my_row_id"}
|
||||
mg.AddMigration("drop my_row_id and add primary key to file_meta table if my_row_id exists (auto-generated mysql column)", mysqlMigration1)
|
||||
|
||||
mysqlMigration2 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file_meta DROP INDEX UQE_file_meta_path_hash_key`)
|
||||
mysqlMigration2.Condition = &migrator.IfIndexExistsCondition{TableName: "file_meta", IndexName: "UQE_file_meta_path_hash_key"}
|
||||
mg.AddMigration("drop file_path unique index from file_meta table if it exists (mysql)", mysqlMigration2)
|
||||
|
||||
mysqlMigration3 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file_meta ADD PRIMARY KEY (path_hash, ` + "`key`" + `);`)
|
||||
mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file_meta", ColumnName: "path_hash"}
|
||||
mg.AddMigration("add primary key to file_meta table if it doesn't exist (mysql)", mysqlMigration3)
|
||||
|
||||
postgres := `
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Drop the unique constraint if it exists
|
||||
DROP INDEX IF EXISTS "UQE_file_meta_path_hash_key";
|
||||
|
||||
-- Add primary key if it doesn't already exist
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_index i WHERE indrelid = 'file_meta'::regclass AND indisprimary) THEN
|
||||
ALTER TABLE file_meta ADD PRIMARY KEY (path_hash, ` + "`key`" + `);
|
||||
END IF;
|
||||
END $$;
|
||||
`
|
||||
|
||||
sqlite := `
|
||||
-- For SQLite we need to recreate the table with primary key. CREATE TABLE was generated by ".schema file_meta" command after running migration.
|
||||
CREATE TABLE file_meta_new
|
||||
(
|
||||
path_hash TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (path_hash, key)
|
||||
);
|
||||
|
||||
INSERT INTO file_meta_new (path_hash, key, value)
|
||||
SELECT path_hash, key, value FROM file_meta;
|
||||
|
||||
DROP TABLE file_meta;
|
||||
ALTER TABLE file_meta_new RENAME TO file_meta;
|
||||
`
|
||||
|
||||
// postgres and sqlite statements are idempotent so we can have only one condition-less migration
|
||||
migration := migrator.NewRawSQLMigration("").
|
||||
Postgres(postgres).
|
||||
SQLite(sqlite)
|
||||
|
||||
mg.AddMigration("add primary key to file_meta table (postgres and sqlite)", migration)
|
||||
}
|
||||
|
||||
@@ -46,3 +46,28 @@ type IfColumnNotExistsCondition struct {
|
||||
func (c *IfColumnNotExistsCondition) SQL(dialect Dialect) (string, []interface{}) {
|
||||
return dialect.ColumnCheckSQL(c.TableName, c.ColumnName)
|
||||
}
|
||||
|
||||
type IfColumnExistsCondition struct {
|
||||
ExistsMigrationCondition
|
||||
TableName string
|
||||
ColumnName string
|
||||
}
|
||||
|
||||
func (c *IfColumnExistsCondition) SQL(dialect Dialect) (string, []interface{}) {
|
||||
return dialect.ColumnCheckSQL(c.TableName, c.ColumnName)
|
||||
}
|
||||
|
||||
type IfPrimaryKeyNotExistsCondition struct {
|
||||
NotExistsMigrationCondition
|
||||
TableName string
|
||||
ColumnName string
|
||||
}
|
||||
|
||||
func (c *IfPrimaryKeyNotExistsCondition) SQL(dialect Dialect) (string, []interface{}) {
|
||||
// only use it with mysql
|
||||
if dialect.DriverName() == "mysql" {
|
||||
return "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME=? AND COLUMN_KEY='PRI'", []interface{}{c.TableName}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -12170,6 +12170,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DashboardVersionResponseMeta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"continueToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"versions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/DashboardVersionMeta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DataLink": {
|
||||
"description": "DataLink define what",
|
||||
"type": "object",
|
||||
@@ -20329,10 +20343,7 @@
|
||||
"dashboardVersionsResponse": {
|
||||
"description": "(empty)",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/DashboardVersionMeta"
|
||||
}
|
||||
"$ref": "#/definitions/DashboardVersionResponseMeta"
|
||||
}
|
||||
},
|
||||
"deleteCorrelationResponse": {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import debounce from 'debounce-promise';
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { UseFormSetValue, useForm } from 'react-hook-form';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@@ -28,7 +27,7 @@ export interface Props {
|
||||
export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
const { changedSaveModel } = changeInfo;
|
||||
|
||||
const { register, handleSubmit, setValue, formState, getValues, watch } = useForm<SaveDashboardAsFormDTO>({
|
||||
const { register, handleSubmit, setValue, formState, getValues, watch, trigger } = useForm<SaveDashboardAsFormDTO>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
title: changeInfo.isNew ? changedSaveModel.title! : `${changedSaveModel.title} Copy`,
|
||||
@@ -47,8 +46,42 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
const { state, onSaveDashboard } = useSaveDashboard(false);
|
||||
|
||||
const [contentSent, setContentSent] = useState<{ title?: string; folderUid?: string }>({});
|
||||
const [hasFolderChanged, setHasFolderChanged] = useState(false);
|
||||
|
||||
const validationTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Validate title on form mount to catch invalid default values
|
||||
useEffect(() => {
|
||||
trigger('title');
|
||||
}, [trigger]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue('title', e.target.value, { shouldDirty: true });
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
validationTimeoutRef.current = setTimeout(() => {
|
||||
trigger('title');
|
||||
}, 400);
|
||||
},
|
||||
[setValue, trigger]
|
||||
);
|
||||
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
|
||||
const isTitleValid = await trigger('title');
|
||||
|
||||
// This prevents the race between the new input and old validation state
|
||||
if (!isTitleValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getValues();
|
||||
|
||||
const result = await onSaveDashboard(dashboard, {
|
||||
@@ -81,8 +114,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
);
|
||||
|
||||
const saveButton = (overwrite: boolean) => {
|
||||
const showSaveButton = !isValid && hasFolderChanged ? true : isValid;
|
||||
return <SaveButton isValid={showSaveButton} isLoading={state.loading} onSave={onSave} overwrite={overwrite} />;
|
||||
return <SaveButton isValid={isValid} isLoading={state.loading} onSave={onSave} overwrite={overwrite} />;
|
||||
};
|
||||
function renderFooter(error?: Error) {
|
||||
const formValuesMatchContentSent =
|
||||
@@ -109,12 +141,9 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
<form onSubmit={handleSubmit(() => onSave(false))}>
|
||||
<Field label={<TitleFieldLabel onChange={setValue} />} invalid={!!errors.title} error={errors.title?.message}>
|
||||
<Input
|
||||
{...register('title', { required: 'Required', validate: validateDashboardName })}
|
||||
{...register('title', { required: 'Required', validate: validateDashboardName, onChange: handleTitleChange })}
|
||||
aria-label="Save dashboard title field"
|
||||
data-testid={selectors.components.Drawer.DashboardSaveDrawer.saveAsTitleInput}
|
||||
onChange={debounce(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue('title', e.target.value, { shouldValidate: true });
|
||||
}, 400)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -133,8 +162,8 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
<FolderPicker
|
||||
onChange={(uid: string | undefined, title: string | undefined) => {
|
||||
setValue('folder', { uid, title });
|
||||
const folderUid = dashboard.state.meta.folderUid;
|
||||
setHasFolderChanged(uid !== folderUid);
|
||||
// Re-validate title when folder changes to check for duplicates in new folder
|
||||
trigger('title');
|
||||
}}
|
||||
// Old folder picker fields
|
||||
value={formValues.folder?.uid}
|
||||
|
||||
@@ -447,10 +447,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardVersionMeta"
|
||||
},
|
||||
"type": "array"
|
||||
"$ref": "#/components/schemas/DashboardVersionResponseMeta"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4182,6 +4179,20 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardVersionResponseMeta": {
|
||||
"properties": {
|
||||
"continueToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"versions": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardVersionMeta"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DataLink": {
|
||||
"description": "DataLink define what",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user