Compare commits

..

11 Commits

Author SHA1 Message Date
Ryan McKinley
894f51a9db Merge remote-tracking branch 'origin/main' into index-owner-reference 2025-12-29 17:34:06 +03:00
Ivan Ortega Alba
30ad61e0e9 Dashboards: Fix adhoc filter click when panel has no panel-level datasource (#115576)
* V2: Panel datasource is defined only for mixed ds

* if getDatasourceFromQueryRunner only returns ds.type, resolve to full ds ref throgh ds service

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
2025-12-29 10:29:50 +01:00
Oscar Kilhed
0b58cd3900 Dashboard: Remove BOMs from links during conversion (#115689)
* Dashboard: Add test case for BOM characters in link URLs

This test demonstrates the issue where BOM (Byte Order Mark) characters
in dashboard link URLs cause CUE validation errors during v1 to v2
conversion ('illegal byte order mark').

The test input contains BOMs in various URL locations:
- Dashboard links
- Panel data links
- Field config override links
- Options dataLinks
- Field config default links

* Dashboard: Strip BOM characters from URLs during v1 to v2 conversion

BOM (Byte Order Mark) characters in dashboard link URLs cause CUE
validation errors ('illegal byte order mark') when opening v2 dashboards.

This fix strips BOMs from all URL fields during conversion:
- Dashboard links
- Panel data links
- Field config override links
- Options dataLinks
- Field config default links

The stripBOM helper recursively processes nested structures to ensure
all string values have BOMs removed.

* Dashboard: Strip BOM characters in frontend v2 conversion

Add stripBOMs parameter to sortedDeepCloneWithoutNulls utility to remove
Byte Order Mark (U+FEFF) characters from all strings when serializing
dashboards to v2 format.

This prevents CUE validation errors ('illegal byte order mark') that occur
when BOMs are present in any string field. BOMs can be introduced through
copy/paste from certain editors or text sources.

Applied at the final serialization step so it catches BOMs from:
- Existing v1 dashboards being converted
- New data entered during dashboard editing
2025-12-29 09:53:45 +01:00
Matheus Macabu
4ba2fe6cce Auditing: Add Event struct to map audit logs into (#115509) 2025-12-29 09:31:58 +01:00
Ryan McKinley
e57c30681d merge main 2025-12-11 09:22:29 +03:00
Ryan McKinley
b378907585 Merge remote-tracking branch 'origin/main' into index-owner-reference 2025-12-11 09:19:04 +03:00
Ryan McKinley
62bdae94ed with query 2025-12-09 20:44:34 +03:00
Ryan McKinley
0091b44b2a Merge remote-tracking branch 'origin/main' into index-owner-reference 2025-12-09 17:21:48 +03:00
Ryan McKinley
307e9cdce3 update swagger 2025-12-08 11:43:57 +03:00
Ryan McKinley
66eb5e35cd add to document builder 2025-12-08 11:18:33 +03:00
Ryan McKinley
a95de85062 merge main 2025-12-08 11:07:16 +03:00
47 changed files with 1379 additions and 348 deletions

View File

@@ -0,0 +1,142 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"title": "BOM Stripping Test Dashboard",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"schemaVersion": 42,
"tags": ["test", "bom"],
"editable": true,
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"url": "http://example.com?var=${datasource}&other=value",
"targetBlank": true,
"icon": "external link"
}
],
"panels": [
{
"id": 1,
"type": "table",
"title": "Panel with BOM in field config override links",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green"},
{"color": "red", "value": 80}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}&var-server=${__value.raw}"
}
]
}
]
}
]
},
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}&var=value",
"targetBlank": true
}
],
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "test-ds"
}
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Panel with BOM in options dataLinks",
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"options": {
"legend": {
"showLegend": true,
"displayMode": "list",
"placement": "bottom"
},
"dataLinks": [
{
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}&time=${__value.time}",
"targetBlank": true
}
]
},
"fieldConfig": {
"defaults": {
"links": [
{
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}&value=${__value.raw}",
"targetBlank": false
}
]
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "test-ds"
}
}
]
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m"]
}
}
}

View File

@@ -120,7 +120,7 @@
"value": [
{
"title": "filter",
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}

View File

@@ -124,7 +124,7 @@
"value": [
{
"title": "filter",
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}

View File

@@ -0,0 +1,161 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"description": "Testing that BOM characters are stripped from URLs during conversion",
"editable": true,
"links": [
{
"icon": "external link",
"targetBlank": true,
"title": "Dashboard link with BOM",
"type": "link",
"url": "http://example.com?var=${datasource}\u0026other=value"
}
],
"panels": [
{
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"links": [
{
"targetBlank": true,
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}\u0026var=value"
}
],
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A"
}
],
"title": "Panel with BOM in field config override links",
"type": "table"
},
{
"fieldConfig": {
"defaults": {
"links": [
{
"targetBlank": false,
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}"
}
]
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"dataLinks": [
{
"targetBlank": true,
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}\u0026time=${__value.time}"
}
],
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A"
}
],
"title": "Panel with BOM in options dataLinks",
"type": "timeseries"
}
],
"schemaVersion": 42,
"tags": [
"test",
"bom"
],
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m"
]
},
"title": "BOM Stripping Test Dashboard"
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -0,0 +1,242 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Panel with BOM in field config override links",
"description": "",
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}\u0026var=value",
"targetBlank": true
}
],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "table",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": null,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "Panel with BOM in options dataLinks",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "",
"options": {
"dataLinks": [
{
"targetBlank": true,
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}\u0026time=${__value.time}"
}
],
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
}
},
"fieldConfig": {
"defaults": {
"links": [
{
"targetBlank": false,
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}"
}
]
},
"overrides": []
}
}
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-2"
}
}
}
]
}
},
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"icon": "external link",
"tooltip": "",
"url": "http://example.com?var=${datasource}\u0026other=value",
"tags": [],
"asDropdown": false,
"targetBlank": true,
"includeVars": false,
"keepTime": false
}
],
"liveNow": false,
"preload": false,
"tags": [
"test",
"bom"
],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "BOM Stripping Test Dashboard",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -0,0 +1,246 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Panel with BOM in field config override links",
"description": "",
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}\u0026var=value",
"targetBlank": true
}
],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "test-ds"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "table",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": null,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "Panel with BOM in options dataLinks",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "test-ds"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "",
"spec": {
"options": {
"dataLinks": [
{
"targetBlank": true,
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}\u0026time=${__value.time}"
}
],
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
}
},
"fieldConfig": {
"defaults": {
"links": [
{
"targetBlank": false,
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}"
}
]
},
"overrides": []
}
}
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-2"
}
}
}
]
}
},
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"icon": "external link",
"tooltip": "",
"url": "http://example.com?var=${datasource}\u0026other=value",
"tags": [],
"asDropdown": false,
"targetBlank": true,
"includeVars": false,
"keepTime": false
}
],
"liveNow": false,
"preload": false,
"tags": [
"test",
"bom"
],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "BOM Stripping Test Dashboard",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -229,6 +229,36 @@ func getBoolField(m map[string]interface{}, key string, defaultValue bool) bool
return defaultValue
}
// stripBOM removes Byte Order Mark (BOM) characters from a string.
// BOMs (U+FEFF) can be introduced through copy/paste from certain editors
// and cause CUE validation errors ("illegal byte order mark").
func stripBOM(s string) string {
return strings.ReplaceAll(s, "\ufeff", "")
}
// stripBOMFromInterface recursively strips BOM characters from all strings
// in an interface{} value (map, slice, or string).
func stripBOMFromInterface(v interface{}) interface{} {
switch val := v.(type) {
case string:
return stripBOM(val)
case map[string]interface{}:
result := make(map[string]interface{}, len(val))
for k, v := range val {
result[k] = stripBOMFromInterface(v)
}
return result
case []interface{}:
result := make([]interface{}, len(val))
for i, item := range val {
result[i] = stripBOMFromInterface(item)
}
return result
default:
return v
}
}
func getUnionField[T ~string](m map[string]interface{}, key string) *T {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok && str != "" {
@@ -393,7 +423,8 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa
// Optional field - only set if present
if url, exists := linkMap["url"]; exists {
if urlStr, ok := url.(string); ok {
dashLink.Url = &urlStr
cleanUrl := stripBOM(urlStr)
dashLink.Url = &cleanUrl
}
}
@@ -2239,7 +2270,7 @@ func transformDataLinks(panelMap map[string]interface{}) []dashv2alpha1.Dashboar
if linkMap, ok := link.(map[string]interface{}); ok {
dataLink := dashv2alpha1.DashboardDataLink{
Title: schemaversion.GetStringValue(linkMap, "title"),
Url: schemaversion.GetStringValue(linkMap, "url"),
Url: stripBOM(schemaversion.GetStringValue(linkMap, "url")),
}
if _, exists := linkMap["targetBlank"]; exists {
targetBlank := getBoolField(linkMap, "targetBlank", false)
@@ -2331,6 +2362,12 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo
}
}
// Strip BOMs from options (may contain dataLinks with URLs that have BOMs)
cleanedOptions := stripBOMFromInterface(options)
if cleanedMap, ok := cleanedOptions.(map[string]interface{}); ok {
options = cleanedMap
}
// Build field config by mapping each field individually
fieldConfigSource := extractFieldConfigSource(fieldConfig)
@@ -2474,9 +2511,14 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da
hasDefaults = true
}
// Extract array field
// Extract array field - strip BOMs from link URLs
if linksArray, ok := extractArrayField(defaults, "links"); ok {
fieldConfigDefaults.Links = linksArray
cleanedLinks := stripBOMFromInterface(linksArray)
if cleanedArray, ok := cleanedLinks.([]interface{}); ok {
fieldConfigDefaults.Links = cleanedArray
} else {
fieldConfigDefaults.Links = linksArray
}
hasDefaults = true
}
@@ -2762,9 +2804,11 @@ func extractFieldConfigOverrides(fieldConfig map[string]interface{}) []dashv2alp
fieldOverride.Properties = make([]dashv2alpha1.DashboardDynamicConfigValue, 0, len(propertiesArray))
for _, property := range propertiesArray {
if propertyMap, ok := property.(map[string]interface{}); ok {
// Strip BOMs from property values (may contain links with URLs)
cleanedValue := stripBOMFromInterface(propertyMap["value"])
fieldOverride.Properties = append(fieldOverride.Properties, dashv2alpha1.DashboardDynamicConfigValue{
Id: schemaversion.GetStringValue(propertyMap, "id"),
Value: propertyMap["value"],
Value: cleanedValue,
})
}
}

View File

@@ -249,6 +249,7 @@ const injectedRtkApi = api
permission: queryArg.permission,
sort: queryArg.sort,
limit: queryArg.limit,
ownerReference: queryArg.ownerReference,
explain: queryArg.explain,
},
}),
@@ -676,6 +677,8 @@ export type SearchDashboardsAndFoldersApiArg = {
sort?: string;
/** number of results to return */
limit?: number;
/** filter by owner reference in the format {Group}/{Kind}/{Name} */
ownerReference?: string;
/** add debugging info that may help explain why the result matched */
explain?: boolean;
};

View File

@@ -0,0 +1,88 @@
package auditing
import (
"encoding/json"
"time"
)
type Event struct {
// The namespace the action was performed in.
Namespace string `json:"namespace"`
// When it happened.
ObservedAt time.Time `json:"-"` // see MarshalJSON for why this is omitted
// Who/what performed the action.
SubjectName string `json:"subjectName"`
SubjectUID string `json:"subjectUID"`
// What was performed.
Verb string `json:"verb"`
// The object the action was performed on. For verbs like "list" this will be empty.
Object string `json:"object,omitempty"`
// API information.
APIGroup string `json:"apiGroup,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
// Outcome of the action.
Outcome EventOutcome `json:"outcome"`
// Extra fields to add more context to the event.
Extra map[string]string `json:"extra,omitempty"`
}
func (e Event) Time() time.Time {
return e.ObservedAt
}
func (e Event) MarshalJSON() ([]byte, error) {
type Alias Event
return json.Marshal(&struct {
FormattedTimestamp string `json:"observedAt"`
Alias
}{
FormattedTimestamp: e.ObservedAt.UTC().Format(time.RFC3339Nano),
Alias: (Alias)(e),
})
}
func (e Event) KVPairs() []any {
args := []any{
"audit", true,
"namespace", e.Namespace,
"observedAt", e.ObservedAt.UTC().Format(time.RFC3339Nano),
"subjectName", e.SubjectName,
"subjectUID", e.SubjectUID,
"verb", e.Verb,
"object", e.Object,
"apiGroup", e.APIGroup,
"apiVersion", e.APIVersion,
"kind", e.Kind,
"outcome", e.Outcome,
}
if len(e.Extra) > 0 {
extraArgs := make([]any, 0, len(e.Extra)*2)
for k, v := range e.Extra {
extraArgs = append(extraArgs, "extra_"+k, v)
}
args = append(args, extraArgs...)
}
return args
}
type EventOutcome string
const (
EventOutcomeUnknown EventOutcome = "unknown"
EventOutcomeSuccess EventOutcome = "success"
EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized"
EventOutcomeFailureNotFound EventOutcome = "failure_not_found"
EventOutcomeFailureGeneric EventOutcome = "failure_generic"
)

View File

@@ -0,0 +1,64 @@
package auditing_test
import (
"encoding/json"
"strconv"
"strings"
"testing"
"time"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/stretchr/testify/require"
)
func TestEvent_MarshalJSON(t *testing.T) {
t.Parallel()
t.Run("marshals the event", func(t *testing.T) {
t.Parallel()
now := time.Now()
event := auditing.Event{
ObservedAt: now,
Extra: map[string]string{"k1": "v1", "k2": "v2"},
}
data, err := json.Marshal(event)
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(data, &result))
require.Equal(t, event.Time().UTC().Format(time.RFC3339Nano), result["observedAt"])
require.NotNil(t, result["extra"])
require.Len(t, result["extra"], 2)
})
}
func TestEvent_KVPairs(t *testing.T) {
t.Parallel()
t.Run("records extra fields", func(t *testing.T) {
t.Parallel()
extraFields := 2
extra := make(map[string]string, 0)
for i := 0; i < extraFields; i++ {
extra[strconv.Itoa(i)] = "value"
}
event := auditing.Event{Extra: extra}
kvPairs := event.KVPairs()
extraCount := 0
for i := 0; i < len(kvPairs); i += 2 {
if strings.HasPrefix(kvPairs[i].(string), "extra_") {
extraCount++
}
}
require.Equal(t, extraCount, extraFields)
})
}

View File

@@ -190,6 +190,32 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "ownerReference", // singular
In: "query",
Description: "filter by owner reference in the format {Group}/{Kind}/{Name}",
Required: false,
Schema: spec.StringProperty(),
Examples: map[string]*spec3.Example{
"": {
ExampleProps: spec3.ExampleProps{},
},
"team": {
ExampleProps: spec3.ExampleProps{
Summary: "Team owner reference",
Value: "iam.grafana.app/Team/xyz",
},
},
"user": {
ExampleProps: spec3.ExampleProps{
Summary: "User owner reference",
Value: "iam.grafana.app/User/abc",
},
},
},
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "explain",
@@ -458,6 +484,15 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
})
}
// The ownerReferences filter
if vals, ok := queryParams["ownerReference"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: resource.SEARCH_FIELD_OWNER_REFERENCES,
Operator: "=",
Values: vals,
})
}
// The libraryPanel filter
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{

View File

@@ -129,6 +129,23 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
Version: runtime.APIVersionInternal,
})
// Allow searching by owner reference
gvk := gv.WithKind("Folder")
err := scheme.AddFieldLabelConversionFunc(
gvk,
func(label, value string) (string, string, error) {
if label == "metadata.name" || label == "metadata.namespace" {
return label, value, nil
}
if label == "search.ownerReference" { // TODO: this should become more general
return label, value, nil
}
return "", "", fmt.Errorf("field label not supported for %s: %s", gvk, label)
})
if err != nil {
return err
}
// If multiple versions exist, then register conversions from zz_generated.conversion.go
// if err := playlist.RegisterConversions(scheme); err != nil {
// return err

View File

@@ -11,6 +11,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/storage"
@@ -120,6 +121,22 @@ func toListRequest(k *resourcepb.ResourceKey, opts storage.ListOptions) (*resour
if opts.Predicate.Field != nil && !opts.Predicate.Field.Empty() {
requirements := opts.Predicate.Field.Requirements()
for _, r := range requirements {
// NOTE: requires: scheme.AddFieldLabelConversionFunc(
if r.Field == "search.ownerReference" {
if len(requirements) > 1 {
return nil, predicate, apierrors.NewBadRequest("search.ownerReference only supports one requirement")
}
req.Options.Fields = []*resourcepb.Requirement{{
Key: r.Field,
Operator: string(r.Operator),
Values: []string{r.Value},
}}
// with only one requirement, we do not need to transform the predicate to exclude this pseudo field
predicate.Field = fields.Everything()
break
}
requirement := &resourcepb.Requirement{Key: r.Field, Operator: string(r.Operator)}
if r.Value != "" {
requirement.Values = append(requirement.Values, r.Value)

View File

@@ -101,6 +101,11 @@ type IndexableDocument struct {
// metadata, annotations, or external data linked at index time
Fields map[string]any `json:"fields,omitempty"`
// The list of owner references,
// each value is of the form {group}/{kind}/{name}
// ex: iam.grafana.app/Team/abc-engineering
OwnerReferences []string `json:"ownerReferences,omitempty"`
// Maintain a list of resource references.
// Someday this will likely be part of https://github.com/grafana/gamma
References ResourceReferences `json:"references,omitempty"`
@@ -217,6 +222,10 @@ func NewIndexableDocument(key *resourcepb.ResourceKey, rv int64, obj utils.Grafa
if err != nil && tt != nil {
doc.Updated = tt.UnixMilli()
}
for _, owner := range obj.GetOwnerReferences() {
gv, _ := schema.ParseGroupVersion(owner.APIVersion)
doc.OwnerReferences = append(doc.OwnerReferences, fmt.Sprintf("%s/%s/%s", gv.Group, owner.Kind, owner.Name))
}
return doc.UpdateCopyFields()
}
@@ -295,6 +304,7 @@ const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title b
const SEARCH_FIELD_DESCRIPTION = "description"
const SEARCH_FIELD_TAGS = "tags"
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
const SEARCH_FIELD_OWNER_REFERENCES = "ownerReferences"
const SEARCH_FIELD_FOLDER = "folder"
const SEARCH_FIELD_CREATED = "created"

View File

@@ -48,6 +48,10 @@ func TestStandardDocumentBuilder(t *testing.T) {
"id": "something"
},
"managedBy": "repo:something",
"ownerReferences": [
"iam.grafana.app/Team/engineering",
"iam.grafana.app/User/test"
],
"source": {
"path": "path/in/system.json",
"checksum": "xyz"

View File

@@ -16,10 +16,41 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
for _, v := range req.Options.Fields {
if v.Key == "metadata.name" && v.Operator == `=` {
names = v.Values
continue
}
// TODO: support other field selectors
// Search by owner reference
if v.Key == "search.ownerReference" {
if len(req.Options.Fields) > 1 {
return &resourcepb.ListResponse{
Error: NewBadRequestError("multiple fields found"),
}
}
results, err := s.Search(ctx, &resourcepb.ResourceSearchRequest{
Fields: []string{}, // no extra fields
Options: &resourcepb.ListOptions{
Key: req.Options.Key,
Fields: []*resourcepb.Requirement{{
Key: SEARCH_FIELD_OWNER_REFERENCES,
Operator: v.Operator,
Values: v.Values,
}},
},
})
if err != nil {
return &resourcepb.ListResponse{
Error: AsErrorResult(err),
}
}
if len(results.Results.Rows) < 1 { // nothing found
return &resourcepb.ListResponse{
ResourceVersion: 1, // TODO, search result should include when it was indexed
}
}
for _, res := range results.Results.Rows {
names = append(names, res.Key.Name)
}
}
}
// The required names
@@ -42,9 +73,6 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
Value: found.Value,
ResourceVersion: found.ResourceVersion,
})
if found.ResourceVersion > rsp.ResourceVersion {
rsp.ResourceVersion = found.ResourceVersion
}
}
}
return rsp

View File

@@ -22,7 +22,6 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/dskit/backoff"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apimachinery/validation"
"github.com/grafana/grafana/pkg/infra/log"

View File

@@ -13,7 +13,16 @@
"grafana.app/repoPath": "path/in/system.json",
"grafana.app/repoHash": "xyz",
"grafana.app/updatedTimestamp": "2024-07-01T10:11:12Z"
}
},
"ownerReferences": [{
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "Team",
"name": "engineering"
}, {
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "User",
"name": "test"
}]
},
"spec": {
"title": "Test Playlist from Unified Storage",

View File

@@ -1559,17 +1559,20 @@ var termFields = []string{
// Convert a "requirement" into a bleve query
func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query, *resourcepb.ErrorResult) {
switch selection.Operator(req.Operator) {
case selection.Equals, selection.DoubleEquals:
case selection.Equals:
if len(req.Values) == 0 {
return query.NewMatchAllQuery(), nil
}
// FIXME: special case for login and email to use term query only because those fields are using keyword analyzer
// This should be fixed by using the info from the schema
if (req.Key == "login" || req.Key == "email") && len(req.Values) == 1 {
tq := bleve.NewTermQuery(req.Values[0])
tq.SetField(prefix + req.Key)
return tq, nil
if len(req.Values) == 1 {
switch req.Key {
case "login", "email", resource.SEARCH_FIELD_OWNER_REFERENCES:
tq := bleve.NewTermQuery(req.Values[0])
tq.SetField(prefix + req.Key)
return tq, nil
}
}
if len(req.Values) == 1 {
@@ -1585,11 +1588,6 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
return query.NewConjunctionQuery(conjuncts), nil
case selection.NotEquals:
case selection.DoesNotExist:
case selection.GreaterThan:
case selection.LessThan:
case selection.Exists:
case selection.In:
if len(req.Values) == 0 {
return query.NewMatchAllQuery(), nil
@@ -1622,6 +1620,14 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
boolQuery.AddMust(notEmptyQuery)
return boolQuery, nil
// will fall through to the BadRequestError
case selection.DoubleEquals:
case selection.NotEquals:
case selection.DoesNotExist:
case selection.GreaterThan:
case selection.LessThan:
case selection.Exists:
}
return nil, resource.NewBadRequestError(
fmt.Sprintf("unsupported query operation (%s %s %v)", req.Key, req.Operator, req.Values),

View File

@@ -60,7 +60,7 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
tagsMapping := &mapping.FieldMapping{
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_TAGS,
Type: "text",
Analyzer: keyword.Name,
@@ -69,8 +69,18 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
IncludeTermVectors: false,
IncludeInAll: true,
DocValues: false,
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
})
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_OWNER_REFERENCES, &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_OWNER_REFERENCES,
Type: "text",
Analyzer: keyword.Name,
Store: false,
Index: true,
IncludeTermVectors: false,
IncludeInAll: false,
DocValues: false,
})
folderMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_FOLDER,

View File

@@ -36,6 +36,7 @@ func TestDocumentMapping(t *testing.T) {
Checksum: "ooo",
TimestampMillis: 1234,
},
OwnerReferences: []string{"iam.grafana.app/Team/devops", "iam.grafana.app/User/xyz"},
}
data.UpdateCopyFields()
@@ -49,5 +50,5 @@ func TestDocumentMapping(t *testing.T) {
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
fmt.Printf("DOC: size %d\n", doc.Size())
require.Equal(t, 17, len(doc.Fields))
require.Equal(t, 19, len(doc.Fields))
}

View File

@@ -16,5 +16,9 @@
"kind": "repo",
"id": "MyGIT"
},
"managedBy": "repo:MyGIT"
"managedBy": "repo:MyGIT",
"ownerReferences": [
"iam.grafana.app/Team/engineering",
"iam.grafana.app/User/test"
]
}

View File

@@ -9,7 +9,16 @@
"annotations": {
"grafana.app/createdBy": "user:1",
"grafana.app/repoName": "MyGIT"
}
},
"ownerReferences": [{
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "Team",
"name": "engineering"
}, {
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "User",
"name": "test"
}]
},
"spec": {
"title": "test-aaa"

View File

@@ -2202,3 +2202,79 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T
require.Equal(t, expectedLabels, accessor.GetLabels())
require.Equal(t, expectedAnnotations, accessor.GetAnnotations())
}
// Test finding folders with an owner
func TestIntegrationFolderWithOwner(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
},
"dashboards.dashboard.grafana.app": {
DualWriterMode: grafanarest.Mode5,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagUnifiedStorageSearch,
},
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Without owner
folder := &unstructured.Unstructured{
Object: map[string]any{
"spec": map[string]any{
"title": "Folder without owner",
},
},
}
folder.SetName("folderA")
out, err := client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, folder.GetName(), out.GetName())
// with owner
folder = &unstructured.Unstructured{
Object: map[string]any{
"spec": map[string]any{
"title": "Folder with owner",
},
},
}
folder.SetName("folderB")
folder.SetOwnerReferences([]metav1.OwnerReference{{
APIVersion: "iam.grafana.app/v0alpha1",
Kind: "Team",
Name: "engineering",
UID: "123456", // required by k8s
}})
out, err = client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, folder.GetName(), out.GetName())
// Get everything
results, err := client.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, []string{"folderA", "folderB"}, getNames(results.Items))
// Find results with a specific owner
results, err = client.Resource.List(context.Background(), metav1.ListOptions{
FieldSelector: "search.ownerReference=iam.grafana.app/Team/engineering",
})
require.NoError(t, err)
require.Equal(t, []string{"folderB"}, getNames(results.Items))
}
func getNames(items []unstructured.Unstructured) []string {
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item.GetName())
}
return names
}

View File

@@ -1873,6 +1873,25 @@
"format": "int64"
}
},
{
"name": "ownerReference",
"in": "query",
"description": "filter by owner reference in the format {Group}/{Kind}/{Name}",
"schema": {
"type": "string"
},
"examples": {
"": {},
"team": {
"summary": "Team owner reference",
"value": "iam.grafana.app/Team/xyz"
},
"user": {
"summary": "User owner reference",
"value": "iam.grafana.app/User/abc"
}
}
},
{
"name": "explain",
"in": "query",

View File

@@ -1,23 +1,29 @@
import { isArray, isPlainObject } from 'lodash';
import { isArray, isPlainObject, isString } from 'lodash';
/**
* @returns A deep clone of the object, but with any null value removed.
* @param value - The object to be cloned and cleaned.
* @param convertInfinity - If true, -Infinity or Infinity is converted to 0.
* This is because Infinity is not a valid JSON value, and sometimes we want to convert it to 0 instead of default null.
* @param stripBOMs - If true, strips Byte Order Mark (BOM) characters from all strings.
* BOMs (U+FEFF) can cause CUE validation errors ("illegal byte order mark").
*/
export function sortedDeepCloneWithoutNulls<T>(value: T, convertInfinity?: boolean): T {
export function sortedDeepCloneWithoutNulls<T>(value: T, convertInfinity?: boolean, stripBOMs?: boolean): T {
if (isArray(value)) {
return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity)) as unknown as T;
return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity, stripBOMs)) as unknown as T;
}
if (isPlainObject(value)) {
return Object.keys(value as { [key: string]: any })
.sort()
.reduce((acc: any, key) => {
const v = (value as any)[key];
let v = (value as any)[key];
// Remove null values
if (v != null) {
acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity);
// Strip BOMs from strings
if (stripBOMs && isString(v)) {
v = v.replace(/\ufeff/g, '');
}
acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity, stripBOMs);
}
if (convertInfinity && (v === Infinity || v === -Infinity)) {

View File

@@ -1,10 +1,10 @@
import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data';
import { BackendSrv, config, setBackendSrv } from '@grafana/runtime';
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
import { GroupByVariable, sceneGraph, SceneQueryRunner } from '@grafana/scenes';
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../utils/utils';
import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils';
import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext';
@@ -159,6 +159,23 @@ describe('setDashboardPanelContext', () => {
// Verify existing filter value updated
expect(variable.state.filters[1].operator).toBe('!=');
});
it('Should use existing adhoc filter when panel has no panel-level datasource because queries have all the same datasources (v2 behavior)', () => {
const { scene, context } = buildTestScene({ existingFilterVariable: true, panelDatasourceUndefined: true });
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
variable.setState({ filters: [] });
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
// Should use the existing adhoc filter variable, not create a new one
expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
// Verify no new adhoc variables were created
const variables = sceneGraph.getVariables(scene);
const adhocVars = variables.state.variables.filter((v) => v.state.type === 'adhoc');
expect(adhocVars.length).toBe(1);
});
});
describe('getFiltersBasedOnGrouping', () => {
@@ -312,6 +329,7 @@ interface SceneOptions {
existingFilterVariable?: boolean;
existingGroupByVariable?: boolean;
groupByDatasourceUid?: string;
panelDatasourceUndefined?: boolean;
}
function buildTestScene(options: SceneOptions) {
@@ -385,6 +403,19 @@ function buildTestScene(options: SceneOptions) {
});
const vizPanel = findVizPanelByKey(scene, 'panel-4')!;
// Simulate v2 dashboard behavior where non-mixed panels don't have panel-level datasource
// but the queries have their own datasources
if (options.panelDatasourceUndefined) {
const queryRunner = getQueryRunnerFor(vizPanel);
if (queryRunner instanceof SceneQueryRunner) {
queryRunner.setState({
datasource: undefined,
queries: [{ refId: 'A', datasource: { uid: 'my-ds-uid', type: 'prometheus' } }],
});
}
}
const context: PanelContext = {
eventBus: new EventBusSrv(),
eventsScope: 'global',

View File

@@ -6,7 +6,12 @@ import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { annotationServer } from 'app/features/annotations/api';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import {
getDashboardSceneFor,
getDatasourceFromQueryRunner,
getPanelIdForVizPanel,
getQueryRunnerFor,
} from '../utils/utils';
import { DashboardScene } from './DashboardScene';
@@ -121,7 +126,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
context.eventBus.publish(new AnnotationChangeEvent({ id }));
};
context.onAddAdHocFilter = (newFilter: AdHocFilterItem) => {
context.onAddAdHocFilter = async (newFilter: AdHocFilterItem) => {
const dashboard = getDashboardSceneFor(vizPanel);
const queryRunner = getQueryRunnerFor(vizPanel);
@@ -129,7 +134,19 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return;
}
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
let datasource = getDatasourceFromQueryRunner(queryRunner);
// If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries)
// we need to resolve it to a full datasource
if (datasource && !datasource.uid) {
const datasourceToLoad = await getDataSourceSrv().get(datasource);
datasource = {
uid: datasourceToLoad.uid,
type: datasourceToLoad.type,
};
}
const filterVar = getAdHocFilterVariableFor(dashboard, datasource);
updateAdHocFilterVariable(filterVar, newFilter);
};
@@ -141,7 +158,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return [];
}
const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource);
const datasource = getDatasourceFromQueryRunner(queryRunner);
const groupByVar = getGroupByVariableFor(dashboard, datasource);
if (!groupByVar) {
return [];
@@ -158,7 +176,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
.filter((item) => item !== undefined);
};
context.onAddAdHocFilters = (items: AdHocFilterItem[]) => {
context.onAddAdHocFilters = async (items: AdHocFilterItem[]) => {
const dashboard = getDashboardSceneFor(vizPanel);
const queryRunner = getQueryRunnerFor(vizPanel);
@@ -166,7 +184,18 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return;
}
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
let datasource = getDatasourceFromQueryRunner(queryRunner);
// If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries)
// we need to resolve it to a full datasource
if (datasource && !datasource.uid) {
const datasourceToLoad = await getDataSourceSrv().get(datasource);
datasource = {
uid: datasourceToLoad.uid,
type: datasourceToLoad.type,
};
}
const filterVar = getAdHocFilterVariableFor(dashboard, datasource);
bulkUpdateAdHocFiltersVariable(filterVar, items);
};

View File

@@ -144,7 +144,8 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
try {
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true);
// Strip BOMs from all strings to prevent CUE validation errors ("illegal byte order mark")
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true, true);
}
// should never reach this point, validation should throw an error
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);

View File

@@ -3,6 +3,8 @@ import { getDataSourceSrv } from '@grafana/runtime';
import { AdHocFiltersVariable, GroupByVariable, sceneGraph, SceneObject, SceneQueryRunner } from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { getDatasourceFromQueryRunner } from './utils';
export function verifyDrilldownApplicability(
sourceObject: SceneObject,
queriesDataSource: DataSourceRef | undefined,
@@ -26,7 +28,7 @@ export async function getDrilldownApplicability(
return;
}
const datasource = queryRunner.state.datasource;
const datasource = getDatasourceFromQueryRunner(queryRunner);
const queries = queryRunner.state.data?.request?.targets;
const ds = await getDataSourceSrv().get(datasource?.uid);

View File

@@ -4,7 +4,7 @@ import { sceneGraph, VizPanel } from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { getExploreUrl } from 'app/core/utils/explore';
import { getQueryRunnerFor } from './utils';
import { getDatasourceFromQueryRunner, getQueryRunnerFor } from './utils';
export function getViewPanelUrl(vizPanel: VizPanel) {
return locationUtil.getUrlForPartial(locationService.getLocation(), {
@@ -27,10 +27,11 @@ export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise<string | u
}
const timeRange = sceneGraph.getTimeRange(vizPanel);
const datasource = getDatasourceFromQueryRunner(queryRunner);
return getExploreUrl({
queries: queryRunner.state.queries,
dsRef: queryRunner.state.datasource,
dsRef: datasource,
timeRange: timeRange.state.value,
scopedVars: { __sceneObject: { value: vizPanel } },
adhocFilters: queryRunner.state.data?.request?.filters,

View File

@@ -1,4 +1,4 @@
import { getDataSourceRef, IntervalVariableModel } from '@grafana/data';
import { DataSourceRef, getDataSourceRef, IntervalVariableModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getDataSourceSrv } from '@grafana/runtime';
import {
@@ -237,6 +237,26 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu
return undefined;
}
/**
* Gets the datasource from a query runner.
* When no panel-level datasource is set, it means all queries use the same datasource,
* so we extract the datasource from the first query.
*/
export function getDatasourceFromQueryRunner(queryRunner: SceneQueryRunner): DataSourceRef | null | undefined {
// Panel-level datasource is set for mixed datasource panels
if (queryRunner.state.datasource) {
return queryRunner.state.datasource;
}
// No panel-level datasource means all queries share the same datasource
const firstQuery = queryRunner.state.queries?.[0];
if (firstQuery?.datasource) {
return firstQuery.datasource;
}
return undefined;
}
export function getDashboardSceneFor(sceneObject: SceneObject): DashboardScene {
const root = sceneObject.getRoot();

View File

@@ -1,45 +0,0 @@
import { useState } from 'react';
import { t } from '@grafana/i18n';
import { EmptyState, FilterInput, Stack } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { ConnectionListItem } from './ConnectionListItem';
interface Props {
items: Connection[];
}
export function ConnectionList({ items }: Props) {
const [query, setQuery] = useState('');
const filteredItems = items.filter((item) => {
if (!query) {
return true;
}
const lowerQuery = query.toLowerCase();
const name = item.metadata?.name?.toLowerCase() ?? '';
const providerType = item.spec?.type?.toLowerCase() ?? '';
return name.includes(lowerQuery) || providerType.includes(lowerQuery);
});
return (
<Stack direction={'column'} gap={3}>
<FilterInput
placeholder={t('provisioning.connections.search-placeholder', 'Search connections')}
value={query}
onChange={setQuery}
/>
<Stack direction={'column'} gap={2}>
{filteredItems.length ? (
filteredItems.map((item) => <ConnectionListItem key={item.metadata?.name} connection={item} />)
) : (
<EmptyState
variant="not-found"
message={t('provisioning.connections.no-results', 'No results matching your query')}
/>
)}
</Stack>
</Stack>
);
}

View File

@@ -1,51 +0,0 @@
import { Trans } from '@grafana/i18n';
import { Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { RepoIcon } from '../Shared/RepoIcon';
import { CONNECTIONS_URL } from '../constants';
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
import { DeleteConnectionButton } from './DeleteConnectionButton';
interface Props {
connection: Connection;
}
export function ConnectionListItem({ connection }: Props) {
const { metadata, spec, status } = connection;
const name = metadata?.name ?? '';
const providerType = spec?.type;
const url = spec?.url;
return (
<Card noMargin key={name}>
<Card.Figure>
<RepoIcon type={providerType} />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center">
<Text variant="h3">{name}</Text>
<ConnectionStatusBadge status={status} />
</Stack>
</Card.Heading>
{url && (
<Card.Meta>
<TextLink external href={url}>
{url}
</TextLink>
</Card.Meta>
)}
<Card.Actions>
<Stack gap={1} direction="row">
<LinkButton icon="eye" href={`${CONNECTIONS_URL}/${name}`} variant="primary" size="md">
<Trans i18nKey="provisioning.connections.view">View</Trans>
</LinkButton>
<DeleteConnectionButton name={name} connection={connection} />
</Stack>
</Card.Actions>
</Card>
);
}

View File

@@ -1,50 +0,0 @@
import { t } from '@grafana/i18n';
import { Badge, IconName } from '@grafana/ui';
import { ConnectionStatus } from 'app/api/clients/provisioning/v0alpha1';
interface Props {
status?: ConnectionStatus;
}
interface BadgeConfig {
color: 'green' | 'red' | 'darkgrey';
text: string;
icon: IconName;
}
function getBadgeConfig(status?: ConnectionStatus): BadgeConfig {
if (!status) {
return {
color: 'darkgrey',
text: t('provisioning.connections.status-unknown', 'Unknown'),
icon: 'question-circle',
};
}
switch (status.state) {
case 'connected':
return {
color: 'green',
text: t('provisioning.connections.status-connected', 'Connected'),
icon: 'check',
};
case 'disconnected':
return {
color: 'red',
text: t('provisioning.connections.status-disconnected', 'Disconnected'),
icon: 'times-circle',
};
default:
return {
color: 'darkgrey',
text: t('provisioning.connections.status-unknown', 'Unknown'),
icon: 'question-circle',
};
}
}
export function ConnectionStatusBadge({ status }: Props) {
const config = getBadgeConfig(status);
return <Badge color={config.color} text={config.text} icon={config.icon} />;
}

View File

@@ -1,57 +0,0 @@
import { t, Trans } from '@grafana/i18n';
import { Alert, Button, EmptyState, Stack, Text } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useConnectionList } from '../hooks/useConnectionList';
import { getErrorMessage } from '../utils/httpUtils';
import { ConnectionList } from './ConnectionList';
export default function ConnectionsPage() {
const [items, isLoading] = useConnectionList();
const hasError = !isLoading && !items;
const hasNoConnections = !isLoading && items?.length === 0;
return (
<Page
navId="provisioning"
subTitle={t('provisioning.connections.page-subtitle', 'View and manage your app connections')}
actions={
<Button
variant="primary"
disabled
tooltip={t('provisioning.connections.create-tooltip', 'Connection creation coming soon')}
>
<Trans i18nKey="provisioning.connections.add-connection">Add connection</Trans>
</Button>
}
>
<Page.Contents isLoading={isLoading}>
<Stack direction={'column'} gap={3}>
{hasError && (
<Alert severity="error" title={t('provisioning.connections.error-loading', 'Failed to load connections')}>
{getErrorMessage(hasError)}
</Alert>
)}
{hasNoConnections && (
<EmptyState
variant="call-to-action"
message={t('provisioning.connections.no-connections', 'No connections configured')}
>
<Text element="p">
{t(
'provisioning.connections.no-connections-message',
'Add a connection to authenticate with external providers'
)}
</Text>
</EmptyState>
)}
{items && items.length > 0 && <ConnectionList items={items} />}
</Stack>
</Page.Contents>
</Page>
);
}

View File

@@ -1,47 +0,0 @@
import { useCallback, useState } from 'react';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal } from '@grafana/ui';
import { Connection, useDeleteConnectionMutation } from 'app/api/clients/provisioning/v0alpha1';
interface Props {
name: string;
connection: Connection;
}
export function DeleteConnectionButton({ name, connection }: Props) {
const [deleteConnection, deleteRequest] = useDeleteConnectionMutation();
const [showModal, setShowModal] = useState(false);
const onConfirm = useCallback(async () => {
reportInteraction('grafana_provisioning_connection_deleted', {
connectionName: name,
connectionType: connection?.spec?.type ?? 'unknown',
});
await deleteConnection({ name });
setShowModal(false);
}, [deleteConnection, name, connection]);
const isLoading = deleteRequest.isLoading;
return (
<>
<Button variant="destructive" size="md" disabled={isLoading} onClick={() => setShowModal(true)}>
<Trans i18nKey="provisioning.connections.delete">Delete</Trans>
</Button>
<ConfirmModal
isOpen={showModal}
title={t('provisioning.connections.delete-title', 'Delete connection')}
body={t(
'provisioning.connections.delete-confirm',
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmText={t('provisioning.connections.delete', 'Delete')}
onConfirm={onConfirm}
onDismiss={() => setShowModal(false)}
/>
</>
);
}

View File

@@ -1,5 +1,4 @@
export const PROVISIONING_URL = '/admin/provisioning';
export const CONNECTIONS_URL = `${PROVISIONING_URL}/connections`;
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
export const UPGRADE_URL = 'https://grafana.com/profile/org/subscription';

View File

@@ -1,19 +0,0 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { ListConnectionApiArg, Connection, useListConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
// Sort connections alphabetically by name
export function useConnectionList(
options: ListConnectionApiArg | typeof skipToken = {}
): [Connection[] | undefined, boolean] {
const query = useListConnectionQuery(options);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const nameA = a.metadata?.name ?? '';
const nameB = b.metadata?.name ?? '';
return collator.compare(nameA, nameB);
});
return [sortedItems, query.isLoading];
}

View File

@@ -3,7 +3,7 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { DashboardRoutes } from 'app/types/dashboard';
import { checkRequiredFeatures } from '../GettingStarted/features';
import { PROVISIONING_URL, CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
export function getProvisioningRoutes(): RouteDescriptor[] {
if (!checkRequiredFeatures()) {
@@ -36,12 +36,6 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
)
),
},
{
path: CONNECTIONS_URL,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ConnectionsPage"*/ 'app/features/provisioning/Connection/ConnectionsPage')
),
},
{
path: `${CONNECT_URL}/:type`,
component: SafeDynamicImport(

View File

@@ -11797,23 +11797,6 @@
"free-tier-limit-tooltip": "Free-tier accounts are restricted to one connection",
"instance-fully-managed-tooltip": "Configuration is disabled because this instance is fully managed"
},
"connections": {
"add-connection": "Add connection",
"create-tooltip": "Connection creation coming soon",
"delete": "Delete",
"delete-confirm": "Are you sure you want to delete this connection? This action cannot be undone.",
"delete-title": "Delete connection",
"error-loading": "Failed to load connections",
"no-connections": "No connections configured",
"no-connections-message": "Add a connection to authenticate with external providers",
"no-results": "No results matching your query",
"page-subtitle": "View and manage your app connections",
"search-placeholder": "Search connections",
"status-connected": "Connected",
"status-disconnected": "Disconnected",
"status-unknown": "Unknown",
"view": "View"
},
"delete-repository-button": {
"button-delete": "Delete",
"confirm-delete-keep-resources": "Are you sure you want to delete the repository configuration but keep its resources?",