Dashboard Schema V2: Fix public dashboards and snapshots (#110060)

* wip; public dashboards and snapshots work

* Chore: Fix example of major release (#110007)

baldm0mma/ fix example of major release

* CI: Push docker images to dockerhub on merges to main (#110056)

* support extracting queries in schema V2

* fix lint and test

* fix test

* clean up

* clean up

* apply feedback about early returns

* fix url issue when clicking open original dashboard in v1

* refactor to early returns

* fix api version comparison

---------

Co-authored-by: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com>
Co-authored-by: Kevin Minehart <5140827+kminehart@users.noreply.github.com>
This commit is contained in:
Haris Rozajac
2025-09-10 13:09:37 -06:00
committed by GitHub
parent 7c0a44579c
commit 11898abccb
6 changed files with 462 additions and 24 deletions
@@ -159,6 +159,12 @@ func (pd *PublicDashboardServiceImpl) GetQueryDataResponse(ctx context.Context,
// buildMetricRequest merges public dashboard parameters with dashboard and returns a metrics request to be sent to query backend
func (pd *PublicDashboardServiceImpl) buildMetricRequest(dashboard *dashboards.Dashboard, publicDashboard *models.PublicDashboard, panelID int64, reqDTO models.PublicDashboardQueryDTO) (dtos.MetricRequest, error) {
isV2 := dashboard.Data.Get("elements").Interface() != nil
if isV2 {
return pd.buildMetricRequestV2(dashboard, publicDashboard, panelID, reqDTO)
}
// group queries by panel
queriesByPanel := groupQueriesByPanelId(dashboard.Data)
queries, ok := queriesByPanel[panelID]
@@ -183,6 +189,31 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(dashboard *dashboards.D
}, nil
}
func (pd *PublicDashboardServiceImpl) buildMetricRequestV2(dashboard *dashboards.Dashboard, publicDashboard *models.PublicDashboard, panelID int64, reqDTO models.PublicDashboardQueryDTO) (dtos.MetricRequest, error) {
// group queries by panel for V2
queriesByPanel := groupQueriesByPanelIdV2(dashboard.Data)
queries, ok := queriesByPanel[panelID]
if !ok {
return dtos.MetricRequest{}, models.ErrPanelNotFound.Errorf("buildMetricRequestV2: public dashboard panel not found")
}
ts := buildTimeSettingsV2(dashboard, reqDTO, publicDashboard, panelID)
// determine safe resolution to query data at
safeInterval, safeResolution := pd.getSafeIntervalAndMaxDataPoints(reqDTO, ts)
for i := range queries {
queries[i].Set("intervalMs", safeInterval)
queries[i].Set("maxDataPoints", safeResolution)
queries[i].Set("queryCachingTTL", reqDTO.QueryCachingTTL)
}
return dtos.MetricRequest{
From: ts.From,
To: ts.To,
Queries: queries,
}, nil
}
func groupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.Json {
result := make(map[int64][]*simplejson.Json)
@@ -191,6 +222,89 @@ func groupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.J
return result
}
func groupQueriesByPanelIdV2(dashboard *simplejson.Json) map[int64][]*simplejson.Json {
result := make(map[int64][]*simplejson.Json)
elementsMap := dashboard.Get("elements").MustMap()
for _, element := range elementsMap {
element := simplejson.NewFromAny(element)
var panelQueries []*simplejson.Json
hasExpression := panelHasAnExpressionSchemaV2(element)
// For schema v2, queries are nested in element.spec.data.spec.queries
spec := element.Get("spec")
if spec.Interface() == nil {
result[element.Get("spec").Get("id").MustInt64()] = panelQueries
continue
}
data := spec.Get("data")
if data.Interface() == nil {
result[element.Get("spec").Get("id").MustInt64()] = panelQueries
continue
}
dataSpec := data.Get("spec")
if dataSpec.Interface() == nil {
result[element.Get("spec").Get("id").MustInt64()] = panelQueries
continue
}
queries := dataSpec.Get("queries")
if queries.Interface() == nil {
result[element.Get("spec").Get("id").MustInt64()] = panelQueries
continue
}
for _, queryObj := range queries.MustArray() {
query := simplejson.NewFromAny(queryObj)
// Check if query is hidden (PanelQuery.spec.hidden)
panelQuerySpec := query.Get("spec")
if panelQuerySpec.Interface() == nil {
continue
}
if !hasExpression && panelQuerySpec.Get("hidden").MustBool() {
continue
}
// Extract the actual query from PanelQuery.spec.query
dataQueryKind := panelQuerySpec.Get("query")
if dataQueryKind.Interface() == nil {
continue
}
dataQuerySpec := dataQueryKind.Get("spec")
if dataQuerySpec.Interface() == nil {
continue
}
dataQuerySpec.Del("exemplar")
group := dataQueryKind.Get("group").MustString()
// if query target has no datasource, set it to have the datasource on the panel
if _, ok := dataQuerySpec.CheckGet("datasource"); !ok {
uid := getDataSourceUidFromJsonSchemaV2(dataQueryKind)
datasource := map[string]any{"type": group, "uid": uid}
dataQuerySpec.Set("datasource", datasource)
}
// We don't support exemplars for public dashboards currently
dataQuerySpec.Del("exemplar")
// The query object contains the DataQuery with the actual expression
panelQueries = append(panelQueries, dataQuerySpec)
}
result[element.Get("spec").Get("id").MustInt64()] = panelQueries
}
return result
}
func extractQueriesFromPanels(panels []any, result map[int64][]*simplejson.Json) {
for _, panelObj := range panels {
panel := simplejson.NewFromAny(panelObj)
@@ -242,6 +356,54 @@ func panelHasAnExpression(panel *simplejson.Json) bool {
return hasExpression
}
func panelHasAnExpressionSchemaV2(panel *simplejson.Json) bool {
var hasExpression bool
// For schema v2, check the nested structure: spec.data.spec.queries[].spec.query
spec := panel.Get("spec")
if spec.Interface() == nil {
return hasExpression
}
data := spec.Get("data")
if data.Interface() == nil {
return hasExpression
}
dataSpec := data.Get("spec")
if dataSpec.Interface() == nil {
return hasExpression
}
queries := dataSpec.Get("queries")
if queries.Interface() == nil {
return hasExpression
}
for _, queryObj := range queries.MustArray() {
query := simplejson.NewFromAny(queryObj)
// Navigate to the actual query object
querySpec := query.Get("spec")
if querySpec.Interface() == nil {
continue
}
queryData := querySpec.Get("query")
if queryData.Interface() == nil {
continue
}
// Check if this query is an expression
if expr.NodeTypeFromDatasourceUID(getDataSourceUidFromJsonSchemaV2(queryData)) == expr.TypeCMDNode {
hasExpression = true
break
}
}
return hasExpression
}
func getDataSourceUidFromJson(query *simplejson.Json) string {
uid := query.Get("datasource").Get("uid").MustString()
@@ -253,6 +415,18 @@ func getDataSourceUidFromJson(query *simplejson.Json) string {
return uid
}
func getDataSourceUidFromJsonSchemaV2(query *simplejson.Json) string {
// For schema v2, datasource info is in query.datasource
uid := query.Get("datasource").Get("name").MustString()
// before 8.3 special types could be sent as datasource (expr)
if uid == "" {
uid = query.Get("datasource").MustString()
}
return uid
}
func sanitizeMetadataFromQueryData(res *backend.QueryDataResponse) {
for k := range res.Responses {
frames := res.Responses[k].Frames
@@ -310,6 +484,28 @@ func buildTimeSettings(d *dashboards.Dashboard, reqDTO models.PublicDashboardQue
}
}
// buildTimeSettingsV2 builds time settings for V2 dashboards
func buildTimeSettingsV2(d *dashboards.Dashboard, reqDTO models.PublicDashboardQueryDTO, pd *models.PublicDashboard, panelID int64) models.TimeSettings {
from, to, timezone := getTimeRangeValuesOrDefaultV2(d, reqDTO, pd.TimeSelectionEnabled, panelID)
timeRange := NewTimeRange(from, to)
timeFrom, _ := timeRange.ParseFrom(
gtime.WithLocation(timezone),
)
timeTo, _ := timeRange.ParseTo(
gtime.WithLocation(timezone),
)
timeToAsEpoch := timeTo.UnixMilli()
timeFromAsEpoch := timeFrom.UnixMilli()
// Were using epoch ms because this is used to build a MetricRequest, which is used by query caching, which want the time range in epoch milliseconds.
return models.TimeSettings{
From: strconv.FormatInt(timeFromAsEpoch, 10),
To: strconv.FormatInt(timeToAsEpoch, 10),
}
}
// returns from, to and timezone from the request if the timeSelection is enabled or the dashboard default values
func getTimeRangeValuesOrDefault(reqDTO models.PublicDashboardQueryDTO, d *dashboards.Dashboard, timeSelectionEnabled bool, panelID int64) (string, string, *time.Location) {
from := d.Data.GetPath("time", "from").MustString()
@@ -344,6 +540,43 @@ func getTimeRangeValuesOrDefault(reqDTO models.PublicDashboardQueryDTO, d *dashb
return from, to, timezone
}
// getTimeRangeValuesOrDefaultV2 returns from, to and timezone from the request if the timeSelection is enabled or the dashboard default values for V2
func getTimeRangeValuesOrDefaultV2(d *dashboards.Dashboard, reqDTO models.PublicDashboardQueryDTO, timeSelectionEnabled bool, panelID int64) (string, string, *time.Location) {
// In V2, time settings are in dashboard.timeSettings
timeSettings := d.Data.Get("timeSettings")
from := timeSettings.Get("from").MustString()
to := timeSettings.Get("to").MustString()
dashboardTimezone := timeSettings.Get("timezone").MustString()
// Check for panel-specific time override in V2 structure
panelRelativeTime := getPanelRelativeTimeRangeV2(d.Data, panelID)
if panelRelativeTime != "" {
from = panelRelativeTime
}
// we use the values from the request if the time selection is enabled and the values are valid
if timeSelectionEnabled {
if reqDTO.TimeRange.From != "" && reqDTO.TimeRange.To != "" {
from = reqDTO.TimeRange.From
to = reqDTO.TimeRange.To
}
if reqDTO.TimeRange.Timezone != "" {
if userTimezone, err := time.LoadLocation(reqDTO.TimeRange.Timezone); err == nil {
return from, to, userTimezone
}
}
}
// if the dashboardTimezone is blank or there is an error default is UTC
timezone, err := time.LoadLocation(dashboardTimezone)
if err != nil {
return from, to, time.UTC
}
return from, to, timezone
}
func getPanelRelativeTimeRange(dashboard *simplejson.Json, panelID int64) string {
for _, panelObj := range dashboard.Get("panels").MustArray() {
panel := simplejson.NewFromAny(panelObj)
@@ -355,3 +588,51 @@ func getPanelRelativeTimeRange(dashboard *simplejson.Json, panelID int64) string
return ""
}
func getPanelRelativeTimeRangeV2(dashboard *simplejson.Json, panelID int64) string {
// In V2, check elements for panel-specific time settings
elements := dashboard.Get("elements")
if elements.Interface() == nil {
return ""
}
elementsMap := elements.MustMap()
for _, element := range elementsMap {
element := simplejson.NewFromAny(element)
// Check if this is the panel we're looking for
if element.Get("spec").Get("id").MustInt64() != panelID {
continue
}
// Check for time override in data.spec.queryOptions.timeFrom
spec := element.Get("spec")
if spec.Interface() == nil {
return ""
}
data := spec.Get("data")
if data.Interface() == nil {
return ""
}
dataSpec := data.Get("spec")
if dataSpec.Interface() == nil {
return ""
}
queryOptions := dataSpec.Get("queryOptions")
if queryOptions.Interface() == nil {
return ""
}
timeFrom := queryOptions.Get("timeFrom")
if timeFrom.Interface() != nil {
return timeFrom.MustString()
}
return ""
}
return ""
}