Compare commits

..

13 Commits

Author SHA1 Message Date
grafana-delivery-bot[bot]
c3e34314f7 [release-11.6.8] CI: release-build.yml missing pipe (#113919)
CI: release-build.yml missing pipe (#113915)

(cherry picked from commit 92ef1c4942)

Co-authored-by: Kevin Minehart <5140827+kminehart@users.noreply.github.com>
2025-11-14 11:52:32 +00:00
Kevin Minehart
a58aee6987 [release-11.6.8] CI: Make notify-pr workflow optional (#113900)
* CI: Make notify-pr workflow optional (#113896)

* CI: Make notify-pr workflow optional

* also set repo to the current repo

* fix find-pr

(cherry picked from commit d92cb9f7a6)

* CI: Continue notify even on error

(cherry picked from commit 2e33b077f1)

* CI: Fix release-build bug; github.repository includes org (#113909)

* CI: Fix release-build bug; github.repository includes org

* set pipefail

* fix notify in release-build; this step should fail if it actually fails

(cherry picked from commit 9376d569cc)
2025-11-14 12:25:20 +01:00
grafana-delivery-bot[bot]
d4fcd25632 [release-11.6.8] Stricter validation for redirect URLs (#113863)
Stricter validation for redirect URLs (#113852)

(cherry picked from commit 3f48a6358f)
2025-11-13 18:48:43 +01:00
grafana-delivery-bot[bot]
40fc660cc8 [release-11.6.8] fix: file and file_meta migrations (#113280)
fix: file and file_meta migrations (#112611)

* fix file and file_meta migrations to check the database state to decide which migration to run

(cherry picked from commit 545b7bf8ff)

Co-authored-by: Will Assis <35489495+gassiss@users.noreply.github.com>
2025-11-12 12:41:18 -05:00
maicon
b0a08807b8 [release-11.6.8] Annotations: Honor dashboardUID on dashboardsWithVisibleAnnotations (#113235)
Annotations: Honor dashboardUID on dashboardsWithVisibleAnnotations (#112350)

* Annotations: Honor dashboardUID on dashboardsWithVisibleAnnotations



---------


(cherry picked from commit 75a1846344)

Signed-off-by: Maicon Costa <maiconscosta@gmail.com>
2025-10-30 14:26:30 -03:00
Haris Rozajac
9e20a7239f [release-11.6.8] Dashboard Save: Fix the issue of clicking Save button that wouldn't trigger save (#113190)
improve dashboard saving
2025-10-30 08:42:10 -06:00
grafana-delivery-bot[bot]
0e676de335 [release-11.6.8] Log TLS handshake EOF error as DEBUG instead INFO (#113097)
Log TLS handshake EOF error as DEBUG instead INFO (#112294)

* Log TLS handshake EOF error as DEBUG instead INFO



---------


(cherry picked from commit a75b01907d)

Signed-off-by: Maicon Costa <maiconscosta@gmail.com>
Co-authored-by: maicon <maiconscosta@gmail.com>
2025-10-28 16:05:34 -03:00
Haris Rozajac
46b9094c20 [release-11.6.8] Dashboards: Disable saving while title is validating (#113057)
save button validation
2025-10-28 08:39:25 -06:00
Kevin Minehart
3f28229ce7 [release-11.6.8] CI: Windows builds with CGO cross-compiler toolchain (#112933)
CI: Windows builds with CGO cross-compiler toolchain (#112922)

* CI: Windows builds with CGO cross-compiler toolchain

* fix comments

(cherry picked from commit e7a49fc472)
2025-10-24 14:08:01 +02:00
Kevin Minehart
74248bb482 [release-11.6.8] pkg/build: Add nocgo option (#112891)
* pkg/build: Add nocgo option (#112834)

Add nocgo option

(cherry picked from commit 2a0f149a63)

* CI: release windows no cgo (#112870)

* fix nocgo option

* set nocgo for windows and darwin

(cherry picked from commit 6b2b9bd7c2)
2025-10-23 18:36:42 +02:00
grafana-delivery-bot[bot]
0b928b9771 [release-11.6.8] Dashboards: Return the correct model in openapi spec (#112868)
Return the correct model (#112858)

(cherry picked from commit 0ba040e866)

Co-authored-by: Selene <selenepinillos@gmail.com>
2025-10-23 17:58:28 +02:00
grafana-delivery-bot[bot]
784bd3ee87 [release-11.6.8] Alerting: Fix unmarshalling of GettableStatus to include time intervals (#112731)
* Alerting: Fix unmarshalling of GettableStatus to include time intervals (#112602)

* move test files into test-data

* add test for the bug

* populate time-intervals of gettableStatus config

(cherry picked from commit 5f9a51418c)

* change test data to match the version of Alertmanager

---------

Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
2025-10-21 18:15:15 +00:00
grafana-delivery-bot[bot]
a599222a63 [release-11.6.7] Include author in patch creation (#112693)
Include author in patch creation (#112675)

Include author in security mirror

(cherry picked from commit ef2e62c852)

Co-authored-by: Kevin Minehart <5140827+kminehart@users.noreply.github.com>
2025-10-21 18:08:12 +02:00
20 changed files with 766 additions and 64 deletions

View File

@@ -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]

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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"}).

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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": {

View File

@@ -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}

View File

@@ -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": {