Compare 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 AlbaandHaris Rozajac 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
@@ -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"]
}
}
}
@@ -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}"
}
]
}
@@ -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}"
}
]
}
@@ -2051,4 +2051,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -2691,4 +2691,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -2764,4 +2764,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1173,4 +1173,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1618,4 +1618,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1670,4 +1670,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -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"
}
}
}
@@ -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"
}
}
}
@@ -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"
}
}
}
@@ -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,
})
}
}
@@ -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;
};
+88
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"
)
+64
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)
})
}
+35
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{
+17
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
+17
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)
+10
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"
@@ -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"
@@ -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
-1
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"
@@ -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",
+16 -10
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),
+13 -3
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,
@@ -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))
}
@@ -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"
]
}
@@ -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"
+76
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
}
@@ -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",
+11 -5
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)) {
@@ -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',
@@ -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);
};
@@ -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);
@@ -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);
@@ -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,
@@ -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();
@@ -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>
);
}
@@ -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>
);
}
@@ -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} />;
}
@@ -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>
);
}
@@ -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)}
/>
</>
);
}
@@ -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';
@@ -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];
}
@@ -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(
-17
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?",