Compare commits

..

3 Commits

Author SHA1 Message Date
Larissa Wandzura
2789c17062 changed pq messages to pgx library format 2026-01-09 11:07:19 -06:00
Larissa Wandzura
f6f7f681c7 ran prettier 2026-01-07 21:11:49 -06:00
Larissa Wandzura
1d44841640 created the new troubleshooting guide 2025-12-12 15:36:17 -06:00
102 changed files with 1161 additions and 5486 deletions

View File

@@ -12,7 +12,6 @@ on:
permissions:
id-token: write
contents: read
statuses: write
# Since this is run on a pull request, we want to apply the patches intended for the
# target branch onto the source branch, to verify compatibility before merging.

View File

@@ -29,10 +29,6 @@ permissions:
# target branch onto the source branch, to verify compatibility before merging.
jobs:
dispatch-job:
# If the source is not from a fork then dispatch the job to the workflow.
# This will fail on forks when trying to broker a token, so instead, forks will create the required status and mark
# it as a success
if: ${{ ! github.event.pull_request.head.repo.fork }}
env:
HEAD_REF: ${{ inputs.head_ref }}
BASE_REF: ${{ github.base_ref }}
@@ -80,20 +76,3 @@ jobs:
triggering_github_handle: SENDER
}
})
dispatch-job-fork:
# If the source is from a fork then use the built-in workflow token to create the same status and unconditionally
# mark it as a success.
if: ${{ github.event.pull_request.head.repo.fork }}
permissions:
statuses: write
runs-on: ubuntu-latest
steps:
- name: Create status
uses: myrotvorets/set-commit-status-action@6d6905c99cd24a4a2cbccc720b62dc6ca5587141
with:
token: ${{ github.token }}
sha: ${{ inputs.pr_commit_sha }}
repo: ${{ inputs.repo }}
status: success
context: "Test Patches (event)"
description: "Test Patches (event) on a fork"

View File

@@ -1,603 +0,0 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "value-mapping-test",
"namespace": "default",
"uid": "value-mapping-test",
"resourceVersion": "1765384157199094",
"generation": 2,
"creationTimestamp": "2025-11-19T20:09:28Z",
"labels": {
"grafana.app/deprecatedInternalID": "646372978987008"
},
"annotations": {},
"managedFields": []
},
"spec": {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Test dashboard for all value mapping types and override matcher types",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with ValueMap mapping type - maps specific text values to colors and display text",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"critical": {
"color": "red",
"index": 0,
"text": "Critical!"
},
"warning": {
"color": "orange",
"index": 1,
"text": "Warning"
},
"ok": {
"color": "green",
"index": 2,
"text": "OK"
}
},
"type": "value"
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 100
},
{
"id": "custom.align",
"value": "center"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"targets": [
{
"expr": "up",
"refId": "A"
}
],
"title": "ValueMap Example",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"from": 0,
"to": 50,
"result": {
"color": "green",
"index": 0,
"text": "Low"
}
},
"type": "range"
},
{
"options": {
"from": 50,
"to": 80,
"result": {
"color": "orange",
"index": 1,
"text": "Medium"
}
},
"type": "range"
},
{
"options": {
"from": 80,
"to": 100,
"result": {
"color": "red",
"index": 2,
"text": "High"
}
},
"type": "range"
}
]
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "/^cpu_/"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "decimals",
"value": 2
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"targets": [
{
"expr": "cpu_usage_percent",
"refId": "A"
}
],
"title": "RangeMap Example",
"type": "gauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"pattern": "/^error.*/",
"result": {
"color": "red",
"index": 0,
"text": "Error"
}
},
"type": "regex"
},
{
"options": {
"pattern": "/^warn.*/",
"result": {
"color": "orange",
"index": 1,
"text": "Warning"
}
},
"type": "regex"
},
{
"options": {
"pattern": "/^info.*/",
"result": {
"color": "blue",
"index": 2,
"text": "Info"
}
},
"type": "regex"
}
]
},
"overrides": [
{
"matcher": {
"id": "byType",
"options": "string"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"targets": [
{
"expr": "log_level",
"refId": "A"
}
],
"title": "RegexMap Example",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"match": "null",
"result": {
"color": "gray",
"index": 0,
"text": "No Data"
}
},
"type": "special"
},
{
"options": {
"match": "nan",
"result": {
"color": "gray",
"index": 1,
"text": "Not a Number"
}
},
"type": "special"
},
{
"options": {
"match": "null+nan",
"result": {
"color": "gray",
"index": 2,
"text": "N/A"
}
},
"type": "special"
},
{
"options": {
"match": "true",
"result": {
"color": "green",
"index": 3,
"text": "Yes"
}
},
"type": "special"
},
{
"options": {
"match": "false",
"result": {
"color": "red",
"index": 4,
"text": "No"
}
},
"type": "special"
},
{
"options": {
"match": "empty",
"result": {
"color": "gray",
"index": 5,
"text": "Empty"
}
},
"type": "special"
}
]
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "A"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "blue"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"targets": [
{
"expr": "some_metric",
"refId": "A"
}
],
"title": "SpecialValueMap Example",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"success": {
"color": "green",
"index": 0,
"text": "Success"
},
"failure": {
"color": "red",
"index": 1,
"text": "Failure"
}
},
"type": "value"
},
{
"options": {
"from": 0,
"to": 100,
"result": {
"color": "blue",
"index": 2,
"text": "In Range"
}
},
"type": "range"
},
{
"options": {
"pattern": "/^[A-Z]{3}-\\d+$/",
"result": {
"color": "purple",
"index": 3,
"text": "ID Format"
}
},
"type": "regex"
},
{
"options": {
"match": "null",
"result": {
"color": "gray",
"index": 4,
"text": "Missing"
}
},
"type": "special"
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 120
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": "/^value_/"
},
"properties": [
{
"id": "unit",
"value": "short"
},
{
"id": "min",
"value": 0
},
{
"id": "max",
"value": 100
}
]
},
{
"matcher": {
"id": "byType",
"options": "number"
},
"properties": [
{
"id": "decimals",
"value": 2
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 50
},
{
"color": "red",
"value": 80
}
]
}
}
]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "displayName",
"value": "Secondary Query"
}
]
},
{
"matcher": {
"id": "byValue",
"options": {
"reducer": "allIsNull",
"op": "gte",
"value": 0
}
},
"properties": [
{
"id": "custom.hidden",
"value": true
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"targets": [
{
"expr": "combined_metric",
"refId": "A"
},
{
"expr": "secondary_metric",
"refId": "B"
}
],
"title": "Combined Mappings and Overrides Example",
"type": "table"
}
],
"schemaVersion": 42,
"tags": [
"value-mapping",
"overrides",
"test"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Value Mapping and Overrides Test",
"weekStart": ""
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v0alpha1"
}
},
"access": {
"slug": "value-mapping-test",
"url": "/d/value-mapping-test/value-mapping-and-overrides-test",
"canSave": true,
"canEdit": true,
"canAdmin": true,
"canStar": true,
"canDelete": true,
"annotationsPermissions": {
"dashboard": {
"canAdd": true,
"canEdit": true,
"canDelete": true
},
"organization": {
"canAdd": true,
"canEdit": true,
"canDelete": true
}
}
}
}

View File

@@ -1,580 +0,0 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "value-mapping-test",
"namespace": "default",
"uid": "value-mapping-test",
"resourceVersion": "1765384157199094",
"generation": 2,
"creationTimestamp": "2025-11-19T20:09:28Z",
"labels": {
"grafana.app/deprecatedInternalID": "646372978987008"
}
},
"spec": {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"description": "Test dashboard for all value mapping types and override matcher types",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with ValueMap mapping type - maps specific text values to colors and display text",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"critical": {
"color": "red",
"index": 0,
"text": "Critical!"
},
"ok": {
"color": "green",
"index": 2,
"text": "OK"
},
"warning": {
"color": "orange",
"index": 1,
"text": "Warning"
}
},
"type": "value"
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 100
},
{
"id": "custom.align",
"value": "center"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"targets": [
{
"expr": "up",
"refId": "A"
}
],
"title": "ValueMap Example",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"from": 0,
"result": {
"color": "green",
"index": 0,
"text": "Low"
},
"to": 50
},
"type": "range"
},
{
"options": {
"from": 50,
"result": {
"color": "orange",
"index": 1,
"text": "Medium"
},
"to": 80
},
"type": "range"
},
{
"options": {
"from": 80,
"result": {
"color": "red",
"index": 2,
"text": "High"
},
"to": 100
},
"type": "range"
}
]
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "/^cpu_/"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "decimals",
"value": 2
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"targets": [
{
"expr": "cpu_usage_percent",
"refId": "A"
}
],
"title": "RangeMap Example",
"type": "gauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"pattern": "/^error.*/",
"result": {
"color": "red",
"index": 0,
"text": "Error"
}
},
"type": "regex"
},
{
"options": {
"pattern": "/^warn.*/",
"result": {
"color": "orange",
"index": 1,
"text": "Warning"
}
},
"type": "regex"
},
{
"options": {
"pattern": "/^info.*/",
"result": {
"color": "blue",
"index": 2,
"text": "Info"
}
},
"type": "regex"
}
]
},
"overrides": [
{
"matcher": {
"id": "byType",
"options": "string"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"targets": [
{
"expr": "log_level",
"refId": "A"
}
],
"title": "RegexMap Example",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"match": "null",
"result": {
"color": "gray",
"index": 0,
"text": "No Data"
}
},
"type": "special"
},
{
"options": {
"match": "nan",
"result": {
"color": "gray",
"index": 1,
"text": "Not a Number"
}
},
"type": "special"
},
{
"options": {
"match": "null+nan",
"result": {
"color": "gray",
"index": 2,
"text": "N/A"
}
},
"type": "special"
},
{
"options": {
"match": "true",
"result": {
"color": "green",
"index": 3,
"text": "Yes"
}
},
"type": "special"
},
{
"options": {
"match": "false",
"result": {
"color": "red",
"index": 4,
"text": "No"
}
},
"type": "special"
},
{
"options": {
"match": "empty",
"result": {
"color": "gray",
"index": 5,
"text": "Empty"
}
},
"type": "special"
}
]
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "A"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "blue",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"targets": [
{
"expr": "some_metric",
"refId": "A"
}
],
"title": "SpecialValueMap Example",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers",
"fieldConfig": {
"defaults": {
"mappings": [
{
"options": {
"failure": {
"color": "red",
"index": 1,
"text": "Failure"
},
"success": {
"color": "green",
"index": 0,
"text": "Success"
}
},
"type": "value"
},
{
"options": {
"from": 0,
"result": {
"color": "blue",
"index": 2,
"text": "In Range"
},
"to": 100
},
"type": "range"
},
{
"options": {
"pattern": "/^[A-Z]{3}-\\d+$/",
"result": {
"color": "purple",
"index": 3,
"text": "ID Format"
}
},
"type": "regex"
},
{
"options": {
"match": "null",
"result": {
"color": "gray",
"index": 4,
"text": "Missing"
}
},
"type": "special"
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 120
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": "/^value_/"
},
"properties": [
{
"id": "unit",
"value": "short"
},
{
"id": "min",
"value": 0
},
{
"id": "max",
"value": 100
}
]
},
{
"matcher": {
"id": "byType",
"options": "number"
},
"properties": [
{
"id": "decimals",
"value": 2
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 50
},
{
"color": "red",
"value": 80
}
]
}
}
]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "displayName",
"value": "Secondary Query"
}
]
},
{
"matcher": {
"id": "byValue",
"options": {
"op": "gte",
"reducer": "allIsNull",
"value": 0
}
},
"properties": [
{
"id": "custom.hidden",
"value": true
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"targets": [
{
"expr": "combined_metric",
"refId": "A"
},
{
"expr": "secondary_metric",
"refId": "B"
}
],
"title": "Combined Mappings and Overrides Example",
"type": "table"
}
],
"schemaVersion": 42,
"tags": [
"value-mapping",
"overrides",
"test"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Value Mapping and Overrides Test",
"weekStart": ""
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -1,783 +0,0 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "value-mapping-test",
"namespace": "default",
"uid": "value-mapping-test",
"resourceVersion": "1765384157199094",
"generation": 2,
"creationTimestamp": "2025-11-19T20:09:28Z",
"labels": {
"grafana.app/deprecatedInternalID": "646372978987008"
}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"query": {
"kind": "grafana",
"spec": {}
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"builtIn": true,
"legacyOptions": {
"type": "dashboard"
}
}
}
],
"cursorSync": "Off",
"description": "Test dashboard for all value mapping types and override matcher types",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "ValueMap Example",
"description": "Panel with ValueMap mapping type - maps specific text values to colors and display text",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {
"expr": "up"
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"critical": {
"text": "Critical!",
"color": "red",
"index": 0
},
"ok": {
"text": "OK",
"color": "green",
"index": 2
},
"warning": {
"text": "Warning",
"color": "orange",
"index": 1
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 100
},
{
"id": "custom.align",
"value": "center"
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "RangeMap Example",
"description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {
"expr": "cpu_usage_percent"
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "gauge",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "range",
"options": {
"from": 0,
"to": 50,
"result": {
"text": "Low",
"color": "green",
"index": 0
}
}
},
{
"type": "range",
"options": {
"from": 50,
"to": 80,
"result": {
"text": "Medium",
"color": "orange",
"index": 1
}
}
},
{
"type": "range",
"options": {
"from": 80,
"to": 100,
"result": {
"text": "High",
"color": "red",
"index": 2
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "/^cpu_/"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "decimals",
"value": 2
}
]
}
]
}
}
}
}
},
"panel-3": {
"kind": "Panel",
"spec": {
"id": 3,
"title": "RegexMap Example",
"description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {
"expr": "log_level"
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "regex",
"options": {
"pattern": "/^error.*/",
"result": {
"text": "Error",
"color": "red",
"index": 0
}
}
},
{
"type": "regex",
"options": {
"pattern": "/^warn.*/",
"result": {
"text": "Warning",
"color": "orange",
"index": 1
}
}
},
{
"type": "regex",
"options": {
"pattern": "/^info.*/",
"result": {
"text": "Info",
"color": "blue",
"index": 2
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byType",
"options": "string"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
}
]
}
]
}
}
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
"id": 4,
"title": "SpecialValueMap Example",
"description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {
"expr": "some_metric"
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "special",
"options": {
"match": "null",
"result": {
"text": "No Data",
"color": "gray",
"index": 0
}
}
},
{
"type": "special",
"options": {
"match": "nan",
"result": {
"text": "Not a Number",
"color": "gray",
"index": 1
}
}
},
{
"type": "special",
"options": {
"match": "null+nan",
"result": {
"text": "N/A",
"color": "gray",
"index": 2
}
}
},
{
"type": "special",
"options": {
"match": "true",
"result": {
"text": "Yes",
"color": "green",
"index": 3
}
}
},
{
"type": "special",
"options": {
"match": "false",
"result": {
"text": "No",
"color": "red",
"index": 4
}
}
},
{
"type": "special",
"options": {
"match": "empty",
"result": {
"text": "Empty",
"color": "gray",
"index": 5
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "A"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "blue",
"mode": "fixed"
}
}
]
}
]
}
}
}
}
},
"panel-5": {
"kind": "Panel",
"spec": {
"id": 5,
"title": "Combined Mappings and Overrides Example",
"description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {
"expr": "combined_metric"
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {
"expr": "secondary_metric"
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus-uid"
},
"refId": "B",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "table",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"failure": {
"text": "Failure",
"color": "red",
"index": 1
},
"success": {
"text": "Success",
"color": "green",
"index": 0
}
}
},
{
"type": "range",
"options": {
"from": 0,
"to": 100,
"result": {
"text": "In Range",
"color": "blue",
"index": 2
}
}
},
{
"type": "regex",
"options": {
"pattern": "/^[A-Z]{3}-\\d+$/",
"result": {
"text": "ID Format",
"color": "purple",
"index": 3
}
}
},
{
"type": "special",
"options": {
"match": "null",
"result": {
"text": "Missing",
"color": "gray",
"index": 4
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 120
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": "/^value_/"
},
"properties": [
{
"id": "unit",
"value": "short"
},
{
"id": "min",
"value": 0
},
{
"id": "max",
"value": 100
}
]
},
{
"matcher": {
"id": "byType",
"options": "number"
},
"properties": [
{
"id": "decimals",
"value": 2
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 50
},
{
"color": "red",
"value": 80
}
]
}
}
]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "displayName",
"value": "Secondary Query"
}
]
},
{
"matcher": {
"id": "byValue",
"options": {
"op": "gte",
"reducer": "allIsNull",
"value": 0
}
},
"properties": [
{
"id": "custom.hidden",
"value": true
}
]
}
]
}
}
}
}
}
},
"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"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 16,
"width": 24,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-5"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [
"value-mapping",
"overrides",
"test"
],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Value Mapping and Overrides Test",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -1,795 +0,0 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "value-mapping-test",
"namespace": "default",
"uid": "value-mapping-test",
"resourceVersion": "1765384157199094",
"generation": 2,
"creationTimestamp": "2025-11-19T20:09:28Z",
"labels": {
"grafana.app/deprecatedInternalID": "646372978987008"
}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "grafana",
"version": "v0",
"datasource": {
"name": "-- Grafana --"
},
"spec": {}
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"builtIn": true,
"legacyOptions": {
"type": "dashboard"
}
}
}
],
"cursorSync": "Off",
"description": "Test dashboard for all value mapping types and override matcher types",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "ValueMap Example",
"description": "Panel with ValueMap mapping type - maps specific text values to colors and display text",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "prometheus-uid"
},
"spec": {
"expr": "up"
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"critical": {
"text": "Critical!",
"color": "red",
"index": 0
},
"ok": {
"text": "OK",
"color": "green",
"index": 2
},
"warning": {
"text": "Warning",
"color": "orange",
"index": 1
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 100
},
{
"id": "custom.align",
"value": "center"
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "RangeMap Example",
"description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "prometheus-uid"
},
"spec": {
"expr": "cpu_usage_percent"
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "gauge",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "range",
"options": {
"from": 0,
"to": 50,
"result": {
"text": "Low",
"color": "green",
"index": 0
}
}
},
{
"type": "range",
"options": {
"from": 50,
"to": 80,
"result": {
"text": "Medium",
"color": "orange",
"index": 1
}
}
},
{
"type": "range",
"options": {
"from": 80,
"to": 100,
"result": {
"text": "High",
"color": "red",
"index": 2
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "/^cpu_/"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "decimals",
"value": 2
}
]
}
]
}
}
}
}
},
"panel-3": {
"kind": "Panel",
"spec": {
"id": 3,
"title": "RegexMap Example",
"description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "prometheus-uid"
},
"spec": {
"expr": "log_level"
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "regex",
"options": {
"pattern": "/^error.*/",
"result": {
"text": "Error",
"color": "red",
"index": 0
}
}
},
{
"type": "regex",
"options": {
"pattern": "/^warn.*/",
"result": {
"text": "Warning",
"color": "orange",
"index": 1
}
}
},
{
"type": "regex",
"options": {
"pattern": "/^info.*/",
"result": {
"text": "Info",
"color": "blue",
"index": 2
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byType",
"options": "string"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
}
]
}
]
}
}
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
"id": 4,
"title": "SpecialValueMap Example",
"description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "prometheus-uid"
},
"spec": {
"expr": "some_metric"
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "special",
"options": {
"match": "null",
"result": {
"text": "No Data",
"color": "gray",
"index": 0
}
}
},
{
"type": "special",
"options": {
"match": "nan",
"result": {
"text": "Not a Number",
"color": "gray",
"index": 1
}
}
},
{
"type": "special",
"options": {
"match": "null+nan",
"result": {
"text": "N/A",
"color": "gray",
"index": 2
}
}
},
{
"type": "special",
"options": {
"match": "true",
"result": {
"text": "Yes",
"color": "green",
"index": 3
}
}
},
{
"type": "special",
"options": {
"match": "false",
"result": {
"text": "No",
"color": "red",
"index": 4
}
}
},
{
"type": "special",
"options": {
"match": "empty",
"result": {
"text": "Empty",
"color": "gray",
"index": 5
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "A"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "blue",
"mode": "fixed"
}
}
]
}
]
}
}
}
}
},
"panel-5": {
"kind": "Panel",
"spec": {
"id": 5,
"title": "Combined Mappings and Overrides Example",
"description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "prometheus-uid"
},
"spec": {
"expr": "combined_metric"
}
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "prometheus-uid"
},
"spec": {
"expr": "secondary_metric"
}
},
"refId": "B",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "table",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"mappings": [
{
"type": "value",
"options": {
"failure": {
"text": "Failure",
"color": "red",
"index": 1
},
"success": {
"text": "Success",
"color": "green",
"index": 0
}
}
},
{
"type": "range",
"options": {
"from": 0,
"to": 100,
"result": {
"text": "In Range",
"color": "blue",
"index": 2
}
}
},
{
"type": "regex",
"options": {
"pattern": "/^[A-Z]{3}-\\d+$/",
"result": {
"text": "ID Format",
"color": "purple",
"index": 3
}
}
},
{
"type": "special",
"options": {
"match": "null",
"result": {
"text": "Missing",
"color": "gray",
"index": 4
}
}
}
]
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 120
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": "/^value_/"
},
"properties": [
{
"id": "unit",
"value": "short"
},
{
"id": "min",
"value": 0
},
{
"id": "max",
"value": 100
}
]
},
{
"matcher": {
"id": "byType",
"options": "number"
},
"properties": [
{
"id": "decimals",
"value": 2
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 50
},
{
"color": "red",
"value": 80
}
]
}
}
]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "displayName",
"value": "Secondary Query"
}
]
},
{
"matcher": {
"id": "byValue",
"options": {
"op": "gte",
"reducer": "allIsNull",
"value": 0
}
},
"properties": [
{
"id": "custom.hidden",
"value": true
}
]
}
]
}
}
}
}
}
},
"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"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 16,
"width": 24,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-5"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [
"value-mapping",
"overrides",
"test"
],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Value Mapping and Overrides Test",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -2022,9 +2022,6 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{},
func transformSingleQuery(ctx context.Context, targetMap map[string]interface{}, panelDatasource *dashv2alpha1.DashboardDataSourceRef, dsIndexProvider schemaversion.DataSourceIndexProvider) dashv2alpha1.DashboardPanelQueryKind {
refId := schemaversion.GetStringValue(targetMap, "refId", "A")
if refId == "" {
refId = "A"
}
hidden := getBoolField(targetMap, "hide", false)
// Extract datasource from query or use panel datasource
@@ -2521,15 +2518,22 @@ func buildRegexMap(mappingMap map[string]interface{}) *dashv2alpha1.DashboardReg
regexMap := &dashv2alpha1.DashboardRegexMap{}
regexMap.Type = dashv2alpha1.DashboardMappingTypeRegex
optMap, ok := mappingMap["options"].(map[string]interface{})
opts, ok := mappingMap["options"].([]interface{})
if !ok || len(opts) == 0 {
return nil
}
optMap, ok := opts[0].(map[string]interface{})
if !ok {
return nil
}
r := dashv2alpha1.DashboardV2alpha1RegexMapOptions{}
if pattern, ok := optMap["pattern"].(string); ok {
if pattern, ok := optMap["regex"].(string); ok {
r.Pattern = pattern
}
// Result is a DashboardValueMappingResult
if resMap, ok := optMap["result"].(map[string]interface{}); ok {
r.Result = buildValueMappingResult(resMap)
}

View File

@@ -211,12 +211,6 @@ type ScopeNavigationSpec struct {
Scope string `json:"scope"`
// Used to navigate to a sub-scope of the main scope. URL will not be used if this is set.
SubScope string `json:"subScope,omitempty"`
// Preload the subscope children, as soon as the ScopeNavigation is loaded.
PreLoadSubScopeChildren bool `json:"preLoadSubScopeChildren,omitempty"`
// Expands to display the subscope children when the ScopeNavigation is loaded.
ExpandOnLoad bool `json:"expandOnLoad,omitempty"`
// Makes the subscope not selectable, only serving as a way to build the tree.
DisableSubScopeSelection bool `json:"disableSubScopeSelection,omitempty"`
}
// Type of the item.

View File

@@ -642,27 +642,6 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCall
Format: "",
},
},
"preLoadSubScopeChildren": {
SchemaProps: spec.SchemaProps{
Description: "Preload the subscope children, as soon as the ScopeNavigation is loaded.",
Type: []string{"boolean"},
Format: "",
},
},
"expandOnLoad": {
SchemaProps: spec.SchemaProps{
Description: "Expands to display the subscope children when the ScopeNavigation is loaded.",
Type: []string{"boolean"},
Format: "",
},
},
"disableSubScopeSelection": {
SchemaProps: spec.SchemaProps{
Description: "Makes the subscope not selectable, only serving as a way to build the tree.",
Type: []string{"boolean"},
Format: "",
},
},
},
Required: []string{"url", "scope"},
},

View File

@@ -210,7 +210,6 @@ navigationTree:
url: /d/UTv--wqMk
scope: shoe-org
subScope: apparel
disableSubScopeSelection: true
children:
- name: apparel-product-overview
title: Product Overview

View File

@@ -77,24 +77,22 @@ type TreeNode struct {
}
type NavigationConfig struct {
URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore)
Scope string `yaml:"scope"` // Required scope
SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation
Title string `yaml:"title"` // Display title
Groups []string `yaml:"groups"` // Optional groups for categorization
DisableSubScopeSelection bool `yaml:"disableSubScopeSelection"` // Makes the subscope not selectable
URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore)
Scope string `yaml:"scope"` // Required scope
SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation
Title string `yaml:"title"` // Display title
Groups []string `yaml:"groups"` // Optional groups for categorization
}
// NavigationTreeNode represents a node in the navigation tree structure
type NavigationTreeNode struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
URL string `yaml:"url"`
Scope string `yaml:"scope"`
SubScope string `yaml:"subScope,omitempty"`
Groups []string `yaml:"groups,omitempty"`
DisableSubScopeSelection bool `yaml:"disableSubScopeSelection,omitempty"`
Children []NavigationTreeNode `yaml:"children,omitempty"`
Name string `yaml:"name"`
Title string `yaml:"title"`
URL string `yaml:"url"`
Scope string `yaml:"scope"`
SubScope string `yaml:"subScope,omitempty"`
Groups []string `yaml:"groups,omitempty"`
Children []NavigationTreeNode `yaml:"children,omitempty"`
}
// Helper function to convert ScopeFilterConfig to v0alpha1.ScopeFilter
@@ -315,9 +313,8 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error
prefixedScope := prefix + "-" + nav.Scope
spec := v0alpha1.ScopeNavigationSpec{
URL: nav.URL,
Scope: prefixedScope,
DisableSubScopeSelection: nav.DisableSubScopeSelection,
URL: nav.URL,
Scope: prefixedScope,
}
if nav.SubScope != "" {
@@ -407,10 +404,9 @@ func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCo
// Create navigation for this node
nav := NavigationConfig{
URL: url,
Scope: node.Scope,
Title: node.Title,
DisableSubScopeSelection: node.DisableSubScopeSelection,
URL: url,
Scope: node.Scope,
Title: node.Title,
}
if node.SubScope != "" {
nav.SubScope = node.SubScope

View File

@@ -21,28 +21,11 @@ weight: 120
# Install a plugin
{{< admonition type="note" >}}
Installing plugins from the Grafana website into a Grafana Cloud instance will be removed in February 2026.
If you're a Grafana Cloud user, follow [Install a plugin through the Grafana UI](#install-a-plugin-through-the-grafana-uiinstall-a-plugin-through-the-grafana-ui) instead.
{{< /admonition >}}
## Install a plugin through the Grafana UI
The most common way to install a plugin is through the Grafana UI.
1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view all plugins.
1. Browse and find a plugin.
1. Click the plugin's logo.
1. Click **Install**.
You can use use the following alternative methods to install a plugin depending on your environment or setup.
Besides the UI, you can use alternative methods to install a plugin depending on your environment or set-up.
## Install a plugin using Grafana CLI
The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/cli/#plugins-commands).
The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](/docs/grafana/<GRAFANA_VERSION>/cli/#plugins-commands).
## Install a plugin from a ZIP file

View File

@@ -24,7 +24,7 @@ Before you begin, you should have the following available:
- Administrator permissions in your Grafana instance; for more information on assigning Grafana RBAC roles, refer to [Assign RBAC roles](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/assign-rbac-roles/).
{{< admonition type="note" >}}
Save all of the following Terraform configuration files in the same directory.
All of the following Terraform configuration files should be saved in the same directory.
{{< /admonition >}}
## Configure the Grafana provider

View File

@@ -60,6 +60,7 @@ The following documents will help you get started with the PostgreSQL data sourc
- [Configure the PostgreSQL data source](ref:configure-postgres-data-source)
- [PostgreSQL query editor](ref:postgres-query-editor)
- [Troubleshooting](troubleshooting/)
After you have configured the data source you can:

View File

@@ -0,0 +1,380 @@
---
aliases:
- ../../data-sources/postgres/troubleshooting/
description: Troubleshooting the PostgreSQL data source in Grafana
keywords:
- grafana
- postgresql
- troubleshooting
- errors
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Troubleshooting
title: Troubleshoot PostgreSQL data source issues
weight: 600
---
# Troubleshoot PostgreSQL data source issues
This document provides troubleshooting information for common errors you may encounter when using the PostgreSQL data source in Grafana.
## Connection errors
The following errors occur when Grafana cannot establish or maintain a connection to PostgreSQL.
### Failed to connect to PostgreSQL
**Error message:** `failed to connect to ... : connect: connection refused` or `dial tcp: connect: connection refused`
**Cause:** Grafana cannot establish a network connection to the PostgreSQL server.
**Solution:**
1. Verify that the Host URL is correct in the data source configuration.
1. Check that PostgreSQL is running and accessible from the Grafana server.
1. Verify the port is correct (the PostgreSQL default port is `5432`).
1. Ensure there are no firewall rules blocking the connection.
1. Check that PostgreSQL is configured to accept connections from the Grafana server in `pg_hba.conf`.
1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your PostgreSQL instance is not publicly accessible.
### Request timed out
**Error message:** "context deadline exceeded" or "i/o timeout"
**Cause:** The connection to PostgreSQL timed out before receiving a response.
**Solution:**
1. Check the network latency between Grafana and PostgreSQL.
1. Verify that PostgreSQL is not overloaded or experiencing performance issues.
1. Increase the **Max lifetime** setting in the data source configuration under **Connection limits**.
1. Reduce the time range or complexity of your query.
1. Check if any network devices (load balancers, proxies) are timing out the connection.
### Host not found
**Error message:** `failed to connect to ... : hostname resolving error` or `lookup hostname: no such host`
**Cause:** The hostname specified in the data source configuration cannot be resolved.
**Solution:**
1. Verify the hostname is spelled correctly.
1. Check that DNS resolution is working on the Grafana server.
1. Try using an IP address instead of a hostname.
1. Ensure the PostgreSQL server is accessible from the Grafana server's network.
## Authentication errors
The following errors occur when there are issues with authentication credentials or permissions.
### Password authentication failed
**Error message:** `failed to connect to ... : server error: FATAL: password authentication failed for user "username" (SQLSTATE 28P01)`
**Cause:** The username or password is incorrect.
**Solution:**
1. Verify that the username and password are correct in the data source configuration.
1. Check that the user exists in PostgreSQL.
1. Verify the password has not expired.
1. If no password is specified, ensure a [PostgreSQL password file](https://www.postgresql.org/docs/current/static/libpq-pgpass.html) is configured.
### Permission denied
**Error message:** `ERROR: permission denied for table table_name (SQLSTATE 42501)` or `ERROR: permission denied for schema schema_name (SQLSTATE 42501)`
**Cause:** The database user does not have permission to access the requested table or schema.
**Solution:**
1. Verify the user has `SELECT` permissions on the required tables.
1. Grant the necessary permissions:
```sql
GRANT USAGE ON SCHEMA schema_name TO grafanareader;
GRANT SELECT ON schema_name.table_name TO grafanareader;
```
1. Check that the user has access to the correct database.
1. Verify the search path includes the schema containing your tables.
### No pg_hba.conf entry
**Error message:** `failed to connect to ... : server error: FATAL: no pg_hba.conf entry for host "ip_address", user "username", database "database_name" (SQLSTATE 28000)`
**Cause:** PostgreSQL is not configured to accept connections from the Grafana server.
**Solution:**
1. Edit the `pg_hba.conf` file on the PostgreSQL server.
1. Add an entry to allow connections from the Grafana server:
```text
host database_name username grafana_ip/32 md5
```
1. Reload PostgreSQL configuration: `SELECT pg_reload_conf();`
1. If using SSL, ensure the correct authentication method is specified (for example, `hostssl` instead of `host`).
## TLS and certificate errors
The following errors occur when there are issues with TLS configuration.
### Certificate verification failed
**Error message:** "x509: certificate signed by unknown authority" or "certificate verify failed"
**Cause:** Grafana cannot verify the TLS certificate presented by PostgreSQL.
**Solution:**
1. Set the **TLS/SSL Mode** to the appropriate level (`require`, `verify-ca`, or `verify-full`).
1. If using a self-signed certificate, add the CA certificate in **TLS/SSL Auth Details**.
1. Verify the certificate chain is complete and valid.
1. Ensure the certificate has not expired.
1. For testing only, set **TLS/SSL Mode** to `disable` (not recommended for production).
### SSL not supported
**Error message:** `failed to connect to ... : server refused TLS connection` or `server does not support SSL`
**Cause:** The PostgreSQL server is not configured for SSL connections, but the data source requires SSL.
**Solution:**
1. Set **TLS/SSL Mode** to `disable` if SSL is not required.
1. Alternatively, enable SSL on the PostgreSQL server by configuring `ssl = on` in `postgresql.conf`.
1. Ensure the server has valid SSL certificates configured.
### Client certificate error
**Error message:** "TLS: failed to find any PEM data in certificate input" or "could not load client certificate"
**Cause:** The client certificate or key is invalid or incorrectly formatted.
**Solution:**
1. Verify the certificate and key are in PEM format.
1. Ensure the certificate file path is correct and readable by the Grafana process.
1. Check that the certificate and key match (belong to the same key pair).
1. If using certificate content, ensure you've pasted the complete certificate including headers.
## Database errors
The following errors occur when there are issues with the database configuration.
### Database does not exist
**Error message:** `failed to connect to ... : server error: FATAL: database "database_name" does not exist (SQLSTATE 3D000)`
**Cause:** The specified database name is incorrect or the database doesn't exist.
**Solution:**
1. Verify the database name in the data source configuration.
1. Check that the database exists: `\l` in psql or `SELECT datname FROM pg_database;`
1. Ensure the database name is case-sensitive and matches exactly.
1. Verify the user has permission to connect to the database.
### Relation does not exist
**Error message:** `ERROR: relation "table_name" does not exist (SQLSTATE 42P01)`
**Cause:** The specified table or view does not exist, or the user cannot access it.
**Solution:**
1. Verify the table name is correct and exists in the database.
1. Check the schema name if the table is not in the public schema.
1. Use fully qualified names: `schema_name.table_name`.
1. Verify the user has `SELECT` permission on the table.
1. Check the search path: `SHOW search_path;`
## Query errors
The following errors occur when there are issues with SQL syntax or query execution.
### Query syntax error
**Error message:** `ERROR: syntax error at or near "keyword" (SQLSTATE 42601)`
**Cause:** The SQL query contains invalid syntax.
**Solution:**
1. Check your query syntax for typos or invalid keywords.
1. Verify column and table names are correctly quoted if they contain special characters or are reserved words.
1. Use double quotes for identifiers: `"column_name"`.
1. Test the query directly in a PostgreSQL client (psql, pgAdmin).
### Column does not exist
**Error message:** `ERROR: column "column_name" does not exist (SQLSTATE 42703)`
**Cause:** The specified column name is incorrect or doesn't exist in the table.
**Solution:**
1. Verify the column name is spelled correctly.
1. Check that column names are case-sensitive in PostgreSQL when quoted.
1. Use the correct quoting for column names: `"Column_Name"` for case-sensitive names.
1. Verify the column exists in the table: `\d table_name` in psql.
### No time column found
**Error message:** "no time column found" or time series visualization shows no data
**Cause:** The query result does not include a properly formatted time column.
**Solution:**
1. Ensure your query includes a column named `time` that returns a timestamp or epoch value.
1. Use an alias to rename your time column: `SELECT created_at AS time`.
1. Ensure the time column is of type `timestamp`, `timestamptz`, or a numeric epoch value.
1. Order results by the time column: `ORDER BY time ASC`.
### Macro expansion error
**Error message:** "macro '$\_\_timeFilter' not found" or incorrect query results with macros
**Cause:** Grafana macros are not being properly expanded.
**Solution:**
1. Verify the macro syntax is correct, for example `$__timeFilter(time_column)`.
1. Ensure the column name passed to the macro exists in your table.
1. Use the **Preview** toggle in Builder mode to see the expanded query.
1. For time-based macros, ensure the column contains timestamp data.
## Performance issues
The following issues relate to slow query execution or resource constraints.
### Query timeout
**Error message:** "canceling statement due to statement timeout" or "query timeout"
**Cause:** The query took longer than the configured timeout.
**Solution:**
1. Reduce the time range of your query.
1. Add indexes to columns used in WHERE clauses and joins.
1. Use the `$__timeFilter` macro to limit data to the dashboard time range.
1. Increase the statement timeout in PostgreSQL if you have admin access.
1. Optimize your query to reduce complexity.
### Too many connections
**Error message:** `failed to connect to ... : server error: FATAL: too many connections for role "username" (SQLSTATE 53300)` or `connection pool exhausted`
**Cause:** The maximum number of connections to PostgreSQL has been reached.
**Solution:**
1. Reduce the **Max open** connections setting in the data source configuration.
1. Increase `max_connections` in PostgreSQL's `postgresql.conf` if you have admin access.
1. Check for connection leaks in other applications connecting to the same database.
1. Enable **Auto max idle** to automatically manage idle connections.
### Slow query performance
**Cause:** Queries take a long time to execute.
**Solution:**
1. Reduce the time range of your query.
1. Add appropriate indexes to your tables.
1. Use the `$__timeFilter` macro to limit the data scanned.
1. Increase the **Min time interval** setting to reduce the number of data points.
1. Use `EXPLAIN ANALYZE` in PostgreSQL to identify query bottlenecks.
1. Consider using materialized views for complex aggregations.
## Provisioning errors
The following errors occur when provisioning the data source via YAML.
### Invalid provisioning configuration
**Error message:** "metric request error" or data source test fails after provisioning
**Cause:** The provisioning YAML file contains incorrect configuration.
**Solution:**
1. Ensure parameter names match the expected format exactly.
1. Verify the database name is **not** included in the URL.
1. Use the correct format for the URL: `hostname:port`.
1. Check that string values are properly quoted in the YAML file.
1. Refer to the [provisioning example](../configure/#provision-the-data-source) for the correct format.
Example correct configuration:
```yaml
datasources:
- name: Postgres
type: postgres
url: localhost:5432
user: grafana
secureJsonData:
password: 'Password!'
jsonData:
database: grafana
sslmode: 'disable'
```
## Other common issues
The following issues don't produce specific error messages but are commonly encountered.
### Empty query results
**Cause:** The query returns no data.
**Solution:**
1. Verify the time range includes data in your database.
1. Check that table and column names are correct.
1. Test the query directly in PostgreSQL.
1. Ensure filters are not excluding all data.
1. Verify the `$__timeFilter` macro is using the correct time column.
### TimescaleDB functions not available
**Cause:** TimescaleDB-specific functions like `time_bucket` are not available in the query builder.
**Solution:**
1. Enable the **TimescaleDB** toggle in the data source configuration under **PostgreSQL Options**.
1. Verify TimescaleDB is installed and enabled in your PostgreSQL database.
1. Check that the `timescaledb` extension is created: `CREATE EXTENSION IF NOT EXISTS timescaledb;`
### Data appears delayed or missing recent points
**Cause:** The visualization doesn't show the most recent data.
**Solution:**
1. Check the dashboard time range and refresh settings.
1. Verify the **Min time interval** is not set too high.
1. Ensure data has been committed to the database (not in an uncommitted transaction).
1. Check for clock synchronization issues between Grafana and PostgreSQL.
## Get additional help
If you continue to experience issues after following this troubleshooting guide:
1. Check the [PostgreSQL documentation](https://www.postgresql.org/docs/) for database-specific guidance.
1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues.
1. Contact Grafana Support if you are a Cloud Pro, Cloud Contracted, or Enterprise user.
1. When reporting issues, include:
- Grafana version
- PostgreSQL version
- Error messages (redact sensitive information)
- Steps to reproduce
- Relevant configuration such as data source settings, TLS mode, and connection limits (redact passwords and other credentials)

View File

@@ -107,8 +107,8 @@ Here is an overview of version support through 2026:
| 12.0.x | May 5, 2025 | February 5, 2026 | Patch Support |
| 12.1.x | July 22, 2025 | April 22, 2026 | Patch Support |
| 12.2.x | September 23, 2025 | June 23, 2026 | Patch Support |
| 12.3.x | November 19, 2025 | August 19, 2026 | Patch Support |
| 12.4.x (Last minor of 12) | February 24, 2026 | May 24, 2027 | Yet to be released |
| 12.3.x | November 18, 2025 | August 18, 2026 | Yet to be released |
| 12.4.x (Last minor of 12) | February 24, 2026 | November 24, 2026 | Yet to be released |
| 13.0.0 | TBD | TBD | Yet to be released |
## How are these versions supported?

View File

@@ -149,10 +149,7 @@ To add a new annotation query to a dashboard, follow these steps:
You can also click **Open advanced data source picker** to see more options, including adding a data source (Admins only).
1. If you don't want to use the annotation query right away, clear the **Enabled** checkbox.
1. Select one of the following options in the **Show annotation controls in** drop-down list to control where annotations are displayed:
- **Above dashboard** - The annotation toggle is displayed above the dashboard. This is the default.
- **Controls menu** - The annotation toggle is displayed in the dashboard controls menu instead of above the dashboard. The dashboard controls menu appears as a button in the dashboard toolbar.
- **Hidden** - The annotation toggle is not displayed on the dashboard.
1. If you don't want the annotation query toggle to be displayed in the dashboard, select the **Hidden** checkbox.
1. Select a color for the event markers.
1. In the **Show in** drop-down, choose one of the following options:
- **All panels** - The annotations are displayed on all panels that support annotations.

View File

@@ -245,12 +245,11 @@ To configure repeats, follow these steps:
1. Click **Save**.
1. Toggle off the edit mode switch.
### Repeating rows and tabs and the Dashboard special data source
### Repeating rows and the Dashboard special data source
<!-- is this next section still true? -->
If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)&mdash;the data source that uses a result set from another panel in the same dashboard&mdash;then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows.
The same behavior applies to tabs.
For example, in a dashboard:

View File

@@ -223,25 +223,17 @@ To export a dashboard in its current state as a PDF, follow these steps:
1. Click the **X** at the top-right corner to close the share drawer.
### Export a dashboard as code
### Export a dashboard as JSON
Export a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. To export a JSON file, follow these steps:
1. Click **Dashboards** in the main menu.
1. Open the dashboard you want to export.
1. Click the **Export** drop-down list in the top-right corner and select **Export as code**.
1. Click the **Export** drop-down list in the top-right corner and select **Export as JSON**.
The **Export dashboard** drawer opens.
1. Select the dashboard JSON model that you to export:
- **Classic** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/view-dashboard-json-model/).
- **V1 Resource** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/view-dashboard-json-model/) wrapped in the `spec` property of the [V1 Kubernetes-style resource](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2alpha1). Choose between **JSON** and **YAML** format.
- **V2 Resource** - Export dashboards created using the [V2 Resource schema](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2beta1). Choose between **JSON** and **YAML** format.
1. Do one of the following:
- Toggle the **Export for sharing externally** switch to generate the JSON with a different data source UID.
- Toggle the **Remove deployment details** switch to make the dashboard externally shareable.
The **Export dashboard JSON** drawer opens.
1. Toggle the **Export the dashboard to use in another instance** switch to generate the JSON with a different data source UID.
1. Click **Download file** or **Copy to clipboard**.
1. Click the **X** at the top-right corner to close the share drawer.

View File

@@ -343,33 +343,6 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
// TODO -- saving for another day.
});
test('Tests nested table expansion', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '4' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Nested tables'))
).toBeVisible();
await waitForTableLoad(page);
await expect(page.locator('[role="row"]')).toHaveCount(3); // header + 2 rows
const firstRowExpander = dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.RowExpander)
.first();
await firstRowExpander.click();
await expect(page.locator('[role="row"]')).not.toHaveCount(3); // more rows are present now, it is dynamic tho.
// TODO: test sorting
await firstRowExpander.click();
await expect(page.locator('[role="row"]')).toHaveCount(3); // back to original state
});
test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,

View File

@@ -804,6 +804,11 @@
"count": 2
}
},
"packages/grafana-ui/src/components/Table/TableNG/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"packages/grafana-ui/src/components/Table/TableRT/Filter.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -1830,6 +1835,11 @@
"count": 1
}
},
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2

View File

@@ -526,8 +526,6 @@ github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g=
github.com/centrifugal/centrifuge v0.37.2/go.mod h1:aj4iRJGhzi3SlL8iUtVezxway1Xf8g+hmNQkLLO7sS8=
github.com/centrifugal/protocol v0.16.2/go.mod h1:Q7OpS/8HMXDnL7f9DpNx24IhG96MP88WPpVTTCdrokI=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
@@ -1371,7 +1369,6 @@ github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc
github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/rueidis v1.0.64/go.mod h1:Lkhr2QTgcoYBhxARU7kJRO8SyVlgUuEkcJO1Y8MCluA=
github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU=
github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/richardartoul/molecule v1.0.0 h1:+LFA9cT7fn8KF39zy4dhOnwcOwRoqKiBkPqKqya+8+U=

View File

@@ -658,6 +658,10 @@ const injectedRtkApi = api
query: (queryArg) => ({ url: `/dashboards/db`, method: 'POST', body: queryArg.saveDashboardCommand }),
invalidatesTags: ['dashboards'],
}),
getHomeDashboard: build.query<GetHomeDashboardApiResponse, GetHomeDashboardApiArg>({
query: () => ({ url: `/dashboards/home` }),
providesTags: ['dashboards'],
}),
importDashboard: build.mutation<ImportDashboardApiResponse, ImportDashboardApiArg>({
query: (queryArg) => ({ url: `/dashboards/import`, method: 'POST', body: queryArg.importDashboardRequest }),
invalidatesTags: ['dashboards'],
@@ -2570,6 +2574,8 @@ export type PostDashboardApiResponse = /** status 200 (empty) */ {
export type PostDashboardApiArg = {
saveDashboardCommand: SaveDashboardCommand;
};
export type GetHomeDashboardApiResponse = /** status 200 (empty) */ GetHomeDashboardResponse;
export type GetHomeDashboardApiArg = void;
export type ImportDashboardApiResponse =
/** status 200 (empty) */ ImportDashboardResponseResponseObjectReturnedWhenImportingADashboard;
export type ImportDashboardApiArg = {
@@ -4393,6 +4399,51 @@ export type SaveDashboardCommand = {
overwrite?: boolean;
userId?: number;
};
export type AnnotationActions = {
canAdd?: boolean;
canDelete?: boolean;
canEdit?: boolean;
};
export type AnnotationPermission = {
dashboard?: AnnotationActions;
organization?: AnnotationActions;
};
export type DashboardMeta = {
annotationsPermissions?: AnnotationPermission;
apiVersion?: string;
canAdmin?: boolean;
canDelete?: boolean;
canEdit?: boolean;
canSave?: boolean;
canStar?: boolean;
created?: string;
createdBy?: string;
expires?: string;
/** Deprecated: use FolderUID instead */
folderId?: number;
folderTitle?: string;
folderUid?: string;
folderUrl?: string;
hasAcl?: boolean;
isFolder?: boolean;
isSnapshot?: boolean;
isStarred?: boolean;
provisioned?: boolean;
provisionedExternalId?: string;
publicDashboardEnabled?: boolean;
slug?: string;
type?: string;
updated?: string;
updatedBy?: string;
url?: string;
version?: number;
};
export type GetHomeDashboardResponse = {
dashboard?: Json;
meta?: DashboardMeta;
} & {
redirectUri?: string;
};
export type ImportDashboardResponseResponseObjectReturnedWhenImportingADashboard = {
dashboardId?: number;
description?: string;
@@ -4484,45 +4535,6 @@ export type PublicDashboardDto = {
timeSelectionEnabled?: boolean;
uid?: string;
};
export type AnnotationActions = {
canAdd?: boolean;
canDelete?: boolean;
canEdit?: boolean;
};
export type AnnotationPermission = {
dashboard?: AnnotationActions;
organization?: AnnotationActions;
};
export type DashboardMeta = {
annotationsPermissions?: AnnotationPermission;
apiVersion?: string;
canAdmin?: boolean;
canDelete?: boolean;
canEdit?: boolean;
canSave?: boolean;
canStar?: boolean;
created?: string;
createdBy?: string;
expires?: string;
/** Deprecated: use FolderUID instead */
folderId?: number;
folderTitle?: string;
folderUid?: string;
folderUrl?: string;
hasAcl?: boolean;
isFolder?: boolean;
isSnapshot?: boolean;
isStarred?: boolean;
provisioned?: boolean;
provisionedExternalId?: string;
publicDashboardEnabled?: boolean;
slug?: string;
type?: string;
updated?: string;
updatedBy?: string;
url?: string;
version?: number;
};
export type DashboardFullWithMeta = {
dashboard?: Json;
meta?: DashboardMeta;
@@ -6607,6 +6619,8 @@ export const {
useSearchDashboardSnapshotsQuery,
useLazySearchDashboardSnapshotsQuery,
usePostDashboardMutation,
useGetHomeDashboardQuery,
useLazyGetHomeDashboardQuery,
useImportDashboardMutation,
useInterpolateDashboardMutation,
useListPublicDashboardsQuery,

View File

@@ -499,9 +499,6 @@ export const versionedComponents = {
},
},
TableNG: {
RowExpander: {
'12.4.0': 'data-testid tableng row expander',
},
Filters: {
HeaderButton: {
'12.1.0': 'data-testid tableng header filter',

View File

@@ -16,22 +16,17 @@ interface Props {
title?: string;
offset?: number;
dragClass?: string;
onDragStart?: (event: React.PointerEvent<HTMLDivElement>) => void;
onOpenMenu?: () => void;
}
export function HoverWidget({ menu, title, dragClass, children, offset = -32, onOpenMenu, onDragStart }: Props) {
export function HoverWidget({ menu, title, dragClass, children, offset = -32, onOpenMenu }: Props) {
const styles = useStyles2(getStyles);
const draggableRef = useRef<HTMLDivElement>(null);
const selectors = e2eSelectors.components.Panels.Panel.HoverWidget;
// Capture the pointer to keep the widget visible while dragging
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.setPointerCapture(e.pointerId);
onDragStart?.(e);
},
[onDragStart]
);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.setPointerCapture(e.pointerId);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.releasePointerCapture(e.pointerId);

View File

@@ -384,7 +384,6 @@ export function PanelChrome({
menu={menu}
title={typeof title === 'string' ? title : undefined}
dragClass={dragClass}
onDragStart={onDragStart}
offset={hoverHeaderOffset}
onOpenMenu={onOpenMenu}
>

View File

@@ -154,18 +154,8 @@ export function TableNG(props: TableNGProps) {
const resizeHandler = useColumnResize(onColumnResize);
const rows = useMemo(() => frameToRecords(data), [data]);
const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]);
const nestedFramesFieldName = useMemo(() => {
if (!hasNestedFrames) {
return;
}
const firstNestedField = data.fields.find((f) => f.type === FieldType.nestedFrames);
if (!firstNestedField) {
return;
}
return getDisplayName(firstNestedField);
}, [data, hasNestedFrames]);
const rows = useMemo(() => frameToRecords(data, nestedFramesFieldName), [data, nestedFramesFieldName]);
const getTextColorForBackground = useMemo(() => memoize(_getTextColorForBackground, { maxSize: 1000 }), []);
const {
@@ -384,11 +374,7 @@ export function TableNG(props: TableNGProps) {
return null;
}
const expandedRecords = applySort(
frameToRecords(nestedData, nestedFramesFieldName),
nestedData.fields,
sortColumns
);
const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns);
if (!expandedRecords.length) {
return (
<div className={styles.noDataNested}>
@@ -412,7 +398,7 @@ export function TableNG(props: TableNGProps) {
width: COLUMN.EXPANDER_WIDTH,
minWidth: COLUMN.EXPANDER_WIDTH,
}),
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles, nestedFramesFieldName]
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles]
);
const fromFields = useCallback(

View File

@@ -1,7 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2 } from '../../../../themes/ThemeContext';
@@ -17,21 +16,13 @@ export function RowExpander({ onCellExpand, isExpanded }: RowExpanderNGProps) {
}
}
return (
<div
role="button"
tabIndex={0}
className={styles.expanderCell}
onClick={onCellExpand}
onKeyDown={handleKeyDown}
data-testid={selectors.components.Panels.Visualization.TableNG.RowExpander}
>
<div role="button" tabIndex={0} className={styles.expanderCell} onClick={onCellExpand} onKeyDown={handleKeyDown}>
<Icon
aria-label={
isExpanded
? t('grafana-ui.row-expander-ng.aria-label-collapse', 'Collapse row')
: t('grafana-ui.row-expander.aria-label-expand', 'Expand row')
}
aria-expanded={isExpanded}
name={isExpanded ? 'angle-down' : 'angle-right'}
size="lg"
/>

View File

@@ -79,6 +79,7 @@ export interface TableRow {
// Nested table properties
data?: DataFrame;
__nestedFrames?: DataFrame[];
__expanded?: boolean; // For row expansion state
// Generic typing for column values
@@ -261,7 +262,7 @@ export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOpti
export type Comparator = (a: TableCellValue, b: TableCellValue) => number;
// Type for converting a DataFrame into an array of TableRows
export type FrameToRowsConverter = (frame: DataFrame, nestedFramesFieldName?: string) => TableRow[];
export type FrameToRowsConverter = (frame: DataFrame) => TableRow[];
// Type for mapping column names to their field types
export type ColumnTypes = Record<string, FieldType>;

View File

@@ -675,12 +675,10 @@ export function applySort(
/**
* @internal
*/
export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string): TableRow[] => {
export const frameToRecords = (frame: DataFrame): TableRow[] => {
const fnBody = `
const rows = Array(frame.length);
const values = frame.fields.map(f => f.values);
const hasNestedFrames = '${nestedFramesFieldName ?? ''}'.length > 0;
let rowCount = 0;
for (let i = 0; i < frame.length; i++) {
rows[rowCount] = {
@@ -688,14 +686,11 @@ export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string)
__index: i,
${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')}
};
rowCount++;
if (hasNestedFrames) {
const childFrame = rows[rowCount-1][${JSON.stringify(nestedFramesFieldName)}];
if (childFrame){
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
rowCount++;
}
rowCount += 1;
if (rows[rowCount-1]['__nestedFrames']){
const childFrame = rows[rowCount-1]['__nestedFrames'];
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
rowCount += 1;
}
}
return rows;
@@ -703,9 +698,8 @@ export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string)
// Creates a function that converts a DataFrame into an array of TableRows
// Uses new Function() for performance as it's faster than creating rows using loops
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const convert = new Function('frame', 'nestedFramesFieldName', fnBody) as FrameToRowsConverter;
return convert(frame, nestedFramesFieldName);
const convert = new Function('frame', fnBody) as FrameToRowsConverter;
return convert(frame);
};
/* ----------------------------- Data grid comparator ---------------------------- */

View File

@@ -493,9 +493,7 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
// swagger:route GET /dashboards/home dashboards getHomeDashboard
//
// NOTE: the home dashboard is configured in preferences. This API will be removed in G13
//
// Deprecated: true
// Get home dashboard.
//
// Responses:
// 200: getHomeDashboardResponse

View File

@@ -40,7 +40,7 @@ func NewResourcePermissionsAuthorizer(
return &ResourcePermissionsAuthorizer{
accessClient: accessClient,
parentProvider: parentProvider,
logger: log.New("iam.authorizer.resource-permissions"),
logger: log.New("iam.resource-permissions-authorizer"),
}
}
@@ -216,7 +216,8 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run
// Skip item on error fetching parent
r.logger.Warn("filter list: error fetching parent, skipping item",
"error", err.Error(),
"namespace", item.Namespace,
"namespace",
item.Namespace,
"group", target.ApiGroup,
"resource", target.Resource,
"name", target.Name,

View File

@@ -21,7 +21,6 @@ import (
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/authlib/authn"
"github.com/grafana/authlib/types"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
@@ -143,8 +142,6 @@ func NewAPIService(
features featuremgmt.FeatureToggles,
zClient zanzana.Client,
reg prometheus.Registerer,
tokenExchanger authn.TokenExchanger,
authorizerDialConfigs map[schema.GroupResource]iamauthorizer.DialConfig,
) *IdentityAccessManagementAPIBuilder {
store := legacy.NewLegacySQLStores(dbProvider)
resourcePermissionsStorage := resourcepermission.ProvideStorageBackend(dbProvider)
@@ -153,8 +150,9 @@ func NewAPIService(
resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient)
coreRoleAuthorizer := iamauthorizer.NewCoreRoleAuthorizer(accessClient)
// TODO: in a follow up PR, make this configurable
resourceParentProvider := iamauthorizer.NewApiParentProvider(
iamauthorizer.NewRemoteConfigProvider(authorizerDialConfigs, tokenExchanger),
iamauthorizer.NewRemoteConfigProvider(map[schema.GroupResource]iamauthorizer.DialConfig{}, nil),
iamauthorizer.Versions,
)

View File

@@ -105,8 +105,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
return
}
folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree())
authorizer := resources.NewRepositoryAuthorizer(repo.Config(), c.access)
dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, authorizer)
dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access)
query := r.URL.Query()
opts := resources.DualWriteOptions{
Ref: query.Get("ref"),

View File

@@ -154,12 +154,9 @@ func TestJobProgressRecorderWarningStatus(t *testing.T) {
// Verify the final status includes warnings
require.NotNil(t, finalStatus.Warnings)
assert.Len(t, finalStatus.Warnings, 3)
expectedWarnings := []string{
"deprecated API used (file: dashboards/test.json, name: test-resource, action: updated)",
"missing optional field (file: dashboards/test2.json, name: test-resource-2, action: created)",
"validation warning (file: datasources/test.yaml, name: test-resource-3, action: created)",
}
assert.ElementsMatch(t, finalStatus.Warnings, expectedWarnings)
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
assert.Contains(t, finalStatus.Warnings[1], "missing optional field")
assert.Contains(t, finalStatus.Warnings[2], "validation warning")
// Verify the state is set to Warning
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)

View File

@@ -3,13 +3,12 @@ package resources
import (
"context"
"fmt"
"net/http"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
@@ -21,11 +20,18 @@ import (
// DualReadWriter is a wrapper around a repository that can read from and write resources
// into both the Git repository as well as in Grafana. It isn't a dual writer in the sense of what unistore handling calls dual writing.
// Standard provisioning Authorizer has already run by the time DualReadWriter is called
// for incoming requests from actors, external or internal. However, since it is the files
// connector that redirects here, the external resources such as dashboards
// end up requiring additional authorization checks which the DualReadWriter performs here.
// TODO: it does not support folders yet
type DualReadWriter struct {
repo repository.ReaderWriter
parser Parser
folders *FolderManager
authorizer Authorizer
repo repository.ReaderWriter
parser Parser
folders *FolderManager
access authlib.AccessChecker
}
type DualWriteOptions struct {
@@ -41,8 +47,8 @@ type DualWriteOptions struct {
Branch string // Configured default branch
}
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, authorizer Authorizer) *DualReadWriter {
return &DualReadWriter{repo: repo, parser: parser, folders: folders, authorizer: authorizer}
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access authlib.AccessChecker) *DualReadWriter {
return &DualReadWriter{repo: repo, parser: parser, folders: folders, access: access}
}
func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*ParsedResource, error) {
@@ -70,7 +76,8 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa
return nil, fmt.Errorf("error running dryRun: %w", err)
}
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbGet); err != nil {
// Authorize based on the existing resource
if err = r.authorize(ctx, parsed, utils.VerbGet); err != nil {
return nil, err
}
@@ -78,7 +85,7 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa
}
func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -104,7 +111,7 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return nil, fmt.Errorf("parse file: %w", err)
}
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbDelete); err != nil {
if err = r.authorize(ctx, parsed, utils.VerbDelete); err != nil {
return nil, err
}
@@ -136,7 +143,7 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
// CreateFolder creates a new folder in the repository
// FIXME: fix signature to return ParsedResource
func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions) (*provisioning.ResourceWrapper, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -144,12 +151,9 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions
return nil, fmt.Errorf("not a folder path")
}
// For create operations, use empty name to check parent folder permissions
folderParsed := folderParsedResource(opts.Path, opts.Ref, r.repo.Config(), "")
if err := r.authorizer.AuthorizeResource(ctx, folderParsed, utils.VerbCreate); err != nil {
if err := r.authorizeCreateFolder(ctx, opts.Path); err != nil {
return nil, err
}
// TODO: authorized to create folders under first existing ancestor folder
// Now actually create the folder
if err := r.repo.Create(ctx, opts.Path, opts.Ref, nil, opts.Message); err != nil {
@@ -197,7 +201,17 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions
// CreateResource creates a new resource in the repository
func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
return r.createOrUpdate(ctx, true, opts)
}
// UpdateResource updates a resource in the repository
func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
return r.createOrUpdate(ctx, false, opts)
}
// Create or updates a resource in the repository
func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts DualWriteOptions) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -212,8 +226,6 @@ func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptio
return nil, err
}
// TODO: check if the resource does not exist in the database.
// Make sure the value is valid
if !opts.SkipDryRun {
if err := parsed.DryRun(ctx); err != nil {
@@ -229,96 +241,12 @@ func (r *DualReadWriter) CreateResource(ctx context.Context, opts DualWriteOptio
return nil, fmt.Errorf("errors while parsing file [%v]", parsed.Errors)
}
// TODO: is this the right way?
// Check if resource already exists - create should fail if it does
if err = r.ensureExisting(ctx, parsed); err != nil {
return nil, err
// Verify that we can create (or update) the referenced resource
verb := utils.VerbUpdate
if parsed.Action == provisioning.ResourceActionCreate {
verb = utils.VerbCreate
}
if parsed.Existing != nil {
return nil, apierrors.NewConflict(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
fmt.Errorf("resource already exists"))
}
// Authorization check: Check if we can create the resource in the folder from the file
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbCreate); err != nil {
return nil, err
}
// TODO: authorized to create folders under first existing ancestor folder
data, err := parsed.ToSaveBytes()
if err != nil {
return nil, err
}
// Always use the provisioning identity when writing
ctx, _, err = identity.WithProvisioningIdentity(ctx, parsed.Obj.GetNamespace())
if err != nil {
return nil, fmt.Errorf("unable to use provisioning identity %w", err)
}
// TODO: handle the error repository.ErrFileAlreadyExists
err = r.repo.Create(ctx, opts.Path, opts.Ref, data, opts.Message)
if err != nil {
return nil, err // raw error is useful
}
// Directly update the grafana database
// Behaves the same running sync after writing
// FIXME: to make sure if behaves in the same way as in sync, we should
// we should refactor the code to use the same function.
if r.shouldUpdateGrafanaDB(opts, parsed) {
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure folder path exists: %w", err)
}
err = parsed.Run(ctx)
}
return parsed, err
}
// UpdateResource updates a resource in the repository
func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
return nil, err
}
info := &repository.FileInfo{
Data: opts.Data,
Path: opts.Path,
Ref: opts.Ref,
}
parsed, err := r.parser.Parse(ctx, info)
if err != nil {
return nil, err
}
// Make sure the value is valid
if !opts.SkipDryRun {
if err := parsed.DryRun(ctx); err != nil {
logger := logging.FromContext(ctx).With("path", opts.Path, "name", parsed.Obj.GetName(), "ref", opts.Ref)
logger.Warn("failed to dry run resource on update", "error", err)
return nil, fmt.Errorf("error running dryRun: %w", err)
}
}
if len(parsed.Errors) > 0 {
// Now returns BadRequest (400) for validation errors
return nil, fmt.Errorf("errors while parsing file [%v]", parsed.Errors)
}
// Populate existing resource to check permissions in the correct folder
if err = r.ensureExisting(ctx, parsed); err != nil {
return nil, err
}
// TODO: what to do with a name or kind change?
// Authorization check: Check if we can update the existing resource in its current folder
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbUpdate); err != nil {
if err = r.authorize(ctx, parsed, verb); err != nil {
return nil, err
}
@@ -333,7 +261,12 @@ func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptio
return nil, fmt.Errorf("unable to use provisioning identity %w", err)
}
err = r.repo.Update(ctx, opts.Path, opts.Ref, data, opts.Message)
// Create or update
if create {
err = r.repo.Create(ctx, opts.Path, opts.Ref, data, opts.Message)
} else {
err = r.repo.Update(ctx, opts.Path, opts.Ref, data, opts.Message)
}
if err != nil {
return nil, err // raw error is useful
}
@@ -355,7 +288,7 @@ func (r *DualReadWriter) UpdateResource(ctx context.Context, opts DualWriteOptio
// MoveResource moves a resource from one path to another in the repository
func (r *DualReadWriter) MoveResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := r.authorizer.AuthorizeWrite(ctx, opts.Ref); err != nil {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
@@ -382,32 +315,7 @@ func (r *DualReadWriter) MoveResource(ctx context.Context, opts DualWriteOptions
}
func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// Reject directory move operations for configured branch - use bulk operations instead
if r.isConfiguredBranch(opts) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusMethodNotAllowed,
Reason: metav1.StatusReasonMethodNotAllowed,
Message: "directory move operations are not available for configured branch. Use bulk move operations via the jobs API instead",
},
}
}
// Check permissions to delete the original folder
originalFolderID := ParseFolder(opts.OriginalPath, r.repo.Config().Name).ID
originalFolderParsed := folderParsedResource(opts.OriginalPath, opts.Ref, r.repo.Config(), originalFolderID)
if err := r.authorizer.AuthorizeResource(ctx, originalFolderParsed, utils.VerbDelete); err != nil {
return nil, fmt.Errorf("not authorized to move from original folder: %w", err)
}
// Check permissions to create at the new folder location (empty name for create)
newFolderParsed := folderParsedResource(opts.Path, opts.Ref, r.repo.Config(), "")
if err := r.authorizer.AuthorizeResource(ctx, newFolderParsed, utils.VerbCreate); err != nil {
return nil, fmt.Errorf("not authorized to move to new folder: %w", err)
}
// For branch operations, we just perform the repository move without updating Grafana DB
// For directory moves, we just perform the repository move without parsing
// Always use the provisioning identity when writing
ctx, _, err := identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
@@ -441,6 +349,35 @@ func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOption
},
}
// Handle folder management for main branch
if r.shouldUpdateGrafanaDB(opts, nil) {
// Ensure destination folder path exists
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure destination folder path exists: %w", err)
}
// Try to delete the old folder structure from grafana (if it exists)
// This handles cleanup when folders are moved to new locations
oldFolderName, err := r.folders.EnsureFolderPathExist(ctx, opts.OriginalPath)
if err != nil {
return nil, fmt.Errorf("ensure original folder path exists: %w", err)
}
if oldFolderName != "" {
oldFolder, err := r.folders.GetFolder(ctx, oldFolderName)
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("get old folder for cleanup: %w", err)
}
if err == nil {
err = r.folders.Client().Delete(ctx, oldFolder.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("delete old folder from storage: %w", err)
}
}
}
}
return parsed, nil
}
@@ -457,13 +394,8 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
return nil, fmt.Errorf("parse original file: %w", err)
}
// Populate existing resource to check delete permission in the correct folder
if err = r.ensureExisting(ctx, parsed); err != nil {
return nil, err
}
// Authorize delete on the original path (checks existing resource's folder if it exists)
if err = r.authorizer.AuthorizeResource(ctx, parsed, utils.VerbDelete); err != nil {
// Authorize delete on the original path
if err = r.authorize(ctx, parsed, utils.VerbDelete); err != nil {
return nil, fmt.Errorf("not authorized to delete original file: %w", err)
}
@@ -501,20 +433,13 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
return nil, fmt.Errorf("errors while parsing moved file [%v]", newParsed.Errors)
}
// Populate existing resource at destination to check if we're overwriting something
if err = r.ensureExisting(ctx, newParsed); err != nil {
return nil, err
// Authorize create on the new path
verb := utils.VerbCreate
if newParsed.Action == provisioning.ResourceActionUpdate {
verb = utils.VerbUpdate
}
// Authorize for the target resource
// - If resource exists at destination: Check if we can update it in its folder
// - If no resource at destination: Check if we can create in the new folder
verb := utils.VerbUpdate
if newParsed.Existing == nil {
verb = utils.VerbCreate
}
if err = r.authorizer.AuthorizeResource(ctx, newParsed, verb); err != nil {
return nil, fmt.Errorf("not authorized for destination: %w", err)
if err = r.authorize(ctx, newParsed, verb); err != nil {
return nil, fmt.Errorf("not authorized to create new file: %w", err)
}
data, err := newParsed.ToSaveBytes()
@@ -572,51 +497,95 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
return newParsed, nil
}
// ensureExisting populates parsed.Existing if a resource with the given name exists in storage.
// Returns nil if no resource exists, if Client is nil, or if Existing is already populated.
// This is used before authorization checks to ensure we validate permissions against the actual
// existing resource's folder, not just the folder specified in the file.
func (r *DualReadWriter) ensureExisting(ctx context.Context, parsed *ParsedResource) error {
if parsed.Client == nil || parsed.Existing != nil {
return nil // Already populated or can't check
}
existing, err := parsed.Client.Get(ctx, parsed.Obj.GetName(), metav1.GetOptions{})
func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error {
id, err := identity.GetRequester(ctx)
if err != nil {
if apierrors.IsNotFound(err) {
return nil // No existing resource
}
return fmt.Errorf("failed to check for existing resource: %w", err)
return apierrors.NewUnauthorized(err.Error())
}
parsed.Existing = existing
return nil
var name string
if parsed.Existing != nil {
name = parsed.Existing.GetName()
} else {
name = parsed.Obj.GetName()
}
rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{
Group: parsed.GVR.Group,
Resource: parsed.GVR.Resource,
Namespace: id.GetNamespace(),
Name: name,
Verb: verb,
}, parsed.Meta.GetFolder())
if err != nil || !rsp.Allowed {
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
fmt.Errorf("no access to read the embedded file"))
}
idType, _, err := authlib.ParseTypeID(id.GetID())
if err != nil {
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), fmt.Errorf("could not determine identity type to check access"))
}
// only apply role based access if identity is not of type access policy
if idType == authlib.TypeAccessPolicy || id.GetOrgRole().Includes(identity.RoleEditor) {
return nil
}
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
fmt.Errorf("must be admin or editor to access files from provisioning"))
}
func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) error {
id, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized(err.Error())
}
// Simple role based access for now
if id.GetOrgRole().Includes(identity.RoleEditor) {
return nil
}
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
fmt.Errorf("must be admin or editor to access folders with provisioning"))
}
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// Reject directory delete operations for configured branch - use bulk operations instead
if r.isConfiguredBranch(opts) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusMethodNotAllowed,
Reason: metav1.StatusReasonMethodNotAllowed,
Message: "directory delete operations are not available for configured branch. Use bulk delete operations via the jobs API instead",
},
// if the ref is set, it is not the active branch, so just delete the files from the branch
// and do not delete the items from grafana itself
if !r.shouldUpdateGrafanaDB(opts, nil) {
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("error deleting folder from repository: %w", err)
}
return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
// Check permissions to delete the folder
folderID := ParseFolder(opts.Path, r.repo.Config().Name).ID
folderParsed := folderParsedResource(opts.Path, opts.Ref, r.repo.Config(), folderID)
if err := r.authorizer.AuthorizeResource(ctx, folderParsed, utils.VerbDelete); err != nil {
// before deleting from the repo, first get all children resources to delete from grafana afterwards
treeEntries, err := r.repo.ReadTree(ctx, "")
if err != nil {
return nil, fmt.Errorf("read repository tree: %w", err)
}
// note: parsedFolders will include the folder itself
parsedResources, parsedFolders, err := r.getChildren(ctx, opts.Path, treeEntries)
if err != nil {
return nil, fmt.Errorf("parse resources in folder: %w", err)
}
// delete from the repo
err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("delete folder from repository: %w", err)
}
// delete from grafana
ctx, _, err = identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
return nil, err
}
// For branch operations, just delete from the repository without updating Grafana DB
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("error deleting folder from repository: %w", err)
if err := r.deleteChildren(ctx, parsedResources, parsedFolders); err != nil {
return nil, fmt.Errorf("delete folder from grafana: %w", err)
}
return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
@@ -641,54 +610,6 @@ func getPathType(isDir bool) string {
return "file (no trailing '/')"
}
// folderParsedResource creates a ParsedResource for a folder path.
// This is used for authorization checks on folder operations.
// For create operations, name should be empty string to check parent permissions.
// For other operations, name should be the folder ID derived from the path.
func folderParsedResource(path, ref string, repo *provisioning.Repository, name string) *ParsedResource {
folderObj := &unstructured.Unstructured{}
folderObj.SetName(name)
folderObj.SetNamespace(repo.Namespace)
// TODO: which parent? top existing ancestor.
meta, _ := utils.MetaAccessor(folderObj)
if meta != nil {
// Set parent folder for folder operations
parentFolder := ""
if path != "" {
parentPath := safepath.Dir(path)
if parentPath != "" {
parentFolder = ParseFolder(parentPath, repo.Name).ID
} else {
parentFolder = RootFolder(repo)
}
}
meta.SetFolder(parentFolder)
}
return &ParsedResource{
Info: &repository.FileInfo{
Path: path,
Ref: ref,
},
Obj: folderObj,
Meta: meta,
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: repo.Spec.Type,
Namespace: repo.Namespace,
Name: repo.Name,
Title: repo.Spec.Title,
},
}
}
func folderDeleteResponse(ctx context.Context, path, ref string, repo repository.Repository) (*ParsedResource, error) {
urls, err := getFolderURLs(ctx, path, ref, repo)
if err != nil {
@@ -719,11 +640,60 @@ func folderDeleteResponse(ctx context.Context, path, ref string, repo repository
return parsed, nil
}
// isConfiguredBranch returns true if the ref targets the configured branch
// (empty ref means configured branch, or ref explicitly matches configured branch)
func (r *DualReadWriter) isConfiguredBranch(opts DualWriteOptions) bool {
configuredBranch := r.repo.Config().Branch()
return opts.Ref == "" || opts.Ref == configuredBranch
func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) {
var resourcesInFolder []repository.FileTreeEntry
var foldersInFolder []Folder
for _, entry := range treeEntries {
// make sure the path is supported (i.e. not ignored by git sync) and that the path is the folder itself or a child of the folder
if IsPathSupported(entry.Path) != nil || !safepath.InDir(entry.Path, folderPath) {
continue
}
// folders cannot be parsed as resources, so handle them separately
if entry.Blob {
resourcesInFolder = append(resourcesInFolder, entry)
} else {
folder := ParseFolder(entry.Path, r.repo.Config().Name)
foldersInFolder = append(foldersInFolder, folder)
}
}
parsedResources := make([]*ParsedResource, len(resourcesInFolder))
for i, entry := range resourcesInFolder {
fileInfo, err := r.repo.Read(ctx, entry.Path, "")
if err != nil && !apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf("could not find resource in repository: %w", err)
}
parsed, err := r.parser.Parse(ctx, fileInfo)
if err != nil {
return nil, nil, fmt.Errorf("could not parse resource: %w", err)
}
parsedResources[i] = parsed
}
return parsedResources, foldersInFolder, nil
}
func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources []*ParsedResource, folders []Folder) error {
for _, parsed := range childrenResources {
err := parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete nested resource from grafana: %w", err)
}
}
// we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it
safepath.SortByDepth(folders, func(f Folder) string { return f.Path }, false)
for _, f := range folders {
err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("failed to delete folder from grafana: %w", err)
}
}
return nil
}
// shouldUpdateGrafanaDB returns true if we have an empty ref (targeting the configured branch)
@@ -733,5 +703,9 @@ func (r *DualReadWriter) shouldUpdateGrafanaDB(opts DualWriteOptions, parsed *Pa
return false
}
return r.isConfiguredBranch(opts)
if opts.Ref != "" && opts.Ref != opts.Branch {
return false
}
return true
}

View File

@@ -274,11 +274,6 @@ func (s *Service) listDashboardVersionsThroughK8s(
continueToken = tempOut.GetContinue()
}
// Update the continue token on the response to reflect the actual position after all fetched items.
// Without this, the response would return the token from the first fetch, causing duplicate items
// on subsequent pages when multiple fetches were needed to fill the requested limit.
out.SetContinue(continueToken)
return out, nil
}

View File

@@ -268,58 +268,6 @@ func TestListDashboardVersions(t *testing.T) {
}}}, res)
})
t.Run("List returns continue token when first fetch satisfies limit with more pages", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42, Limit: 2}
mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil)
firstPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "11",
"generation": int64(4),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(5),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
firstMeta, err := meta.ListAccessor(firstPage)
require.NoError(t, err)
firstMeta.SetContinue("t1") // More pages exist
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 2, len(res.Versions))
require.Equal(t, "t1", res.ContinueToken) // Token from first fetch when limit is satisfied
mockCli.AssertNumberOfCalls(t, "List", 1) // Only one fetch needed
})
t.Run("List returns correct continue token across multiple pages", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
@@ -385,79 +333,7 @@ func TestListDashboardVersions(t *testing.T) {
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 3, len(res.Versions))
require.Equal(t, "", res.ContinueToken) // Should return token from last fetch (empty = no more pages)
mockCli.AssertNumberOfCalls(t, "List", 2)
})
t.Run("List returns continue token from last fetch when more pages exist", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42, Limit: 3}
mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil)
firstPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "11",
"generation": int64(4),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(5),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
firstMeta, err := meta.ListAccessor(firstPage)
require.NoError(t, err)
firstMeta.SetContinue("t1")
secondPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "13",
"generation": int64(6),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
secondMeta, err := meta.ListAccessor(secondPage)
require.NoError(t, err)
secondMeta.SetContinue("t2") // More pages exist
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once()
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(secondPage, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 3, len(res.Versions))
require.Equal(t, "t2", res.ContinueToken) // Must return token from LAST fetch, not first
require.Equal(t, "t1", res.ContinueToken) // Implementation returns continue token from first page
mockCli.AssertNumberOfCalls(t, "List", 2)
})

View File

@@ -618,7 +618,6 @@ type Cfg struct {
EnableSearch bool
OverridesFilePath string
OverridesReloadInterval time.Duration
EnableSQLKVBackend bool
// Secrets Management
SecretsManagement SecretsManagerSettings

View File

@@ -100,9 +100,6 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.OverridesFilePath = section.Key("overrides_path").String()
cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second)
// use sqlkv (resource/sqlkv) instead of the sql backend (sql/backend) as the StorageServer
cfg.EnableSQLKVBackend = section.Key("enable_sqlkv_backend").MustBool(false)
cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0)
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
}

View File

@@ -9,9 +9,6 @@ import (
"testing"
"github.com/bwmarrin/snowflake"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
"github.com/stretchr/testify/require"
)
@@ -27,16 +24,6 @@ func TestNewDataStore(t *testing.T) {
require.NotNil(t, ds)
}
// nolint:unused
func setupTestDataStoreSqlKv(t *testing.T) *dataStore {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
kv, err := NewSQLKV(eDB)
require.NoError(t, err)
return newDataStore(kv)
}
func TestDataKey_String(t *testing.T) {
rv := int64(1934555792099250176)
tests := []struct {
@@ -692,21 +679,10 @@ func TestParseKey(t *testing.T) {
}
}
func runDataStoreTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) *dataStore, testFn func(*testing.T, context.Context, *dataStore)) {
t.Run(storeName, func(t *testing.T) {
ctx := context.Background()
store := newStoreFn(t)
testFn(t, ctx, store)
})
}
func TestDataStore_Save_And_Get(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreSaveAndGet)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreSaveAndGet)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreSaveAndGet(t *testing.T, ctx context.Context, ds *dataStore) {
rv := node.Generate()
testKey := DataKey{
@@ -768,12 +744,9 @@ func testDataStoreSaveAndGet(t *testing.T, ctx context.Context, ds *dataStore) {
}
func TestDataStore_Delete(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreDelete)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreDelete)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreDelete(t *testing.T, ctx context.Context, ds *dataStore) {
rv := node.Generate()
testKey := DataKey{
@@ -822,12 +795,9 @@ func testDataStoreDelete(t *testing.T, ctx context.Context, ds *dataStore) {
}
func TestDataStore_List(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreList)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreList)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreList(t *testing.T, ctx context.Context, ds *dataStore) {
resourceKey := ListRequestKey{
Namespace: "test-namespace",
Group: "test-group",
@@ -949,12 +919,9 @@ func testDataStoreList(t *testing.T, ctx context.Context, ds *dataStore) {
}
func TestDataStore_Integration(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreIntegration)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreIntegration)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreIntegration(t *testing.T, ctx context.Context, ds *dataStore) {
t.Run("full lifecycle test", func(t *testing.T) {
resourceKey := ListRequestKey{
Namespace: "integration-ns",
@@ -1040,12 +1007,9 @@ func testDataStoreIntegration(t *testing.T, ctx context.Context, ds *dataStore)
}
func TestDataStore_Keys(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreKeys)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreKeys)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreKeys(t *testing.T, ctx context.Context, ds *dataStore) {
resourceKey := ListRequestKey{
Namespace: "test-namespace",
Group: "test-group",
@@ -1190,12 +1154,9 @@ func testDataStoreKeys(t *testing.T, ctx context.Context, ds *dataStore) {
}
func TestDataStore_ValidationEnforced(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreValidationEnforced)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreValidationEnforced)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreValidationEnforced(t *testing.T, ctx context.Context, ds *dataStore) {
// Create an invalid key
invalidKey := DataKey{
Namespace: "Invalid-Namespace-$$$",
@@ -1522,12 +1483,9 @@ func TestListRequestKey_Prefix(t *testing.T) {
}
func TestDataStore_LastResourceVersion(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreLastResourceVersion)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreLastResourceVersion)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreLastResourceVersion(t *testing.T, ctx context.Context, ds *dataStore) {
t.Run("returns last resource version for existing data", func(t *testing.T) {
resourceKey := ListRequestKey{
Namespace: "test-namespace",
@@ -1627,12 +1585,9 @@ func testDataStoreLastResourceVersion(t *testing.T, ctx context.Context, ds *dat
}
func TestDataStore_GetLatestResourceKey(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKey)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKey)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetLatestResourceKey(t *testing.T, ctx context.Context, ds *dataStore) {
key := GetRequestKey{
Group: "apps",
Resource: "resources",
@@ -1693,12 +1648,9 @@ func testDataStoreGetLatestResourceKey(t *testing.T, ctx context.Context, ds *da
}
func TestDataStore_GetLatestResourceKey_Deleted(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKeyDeleted)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKeyDeleted)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetLatestResourceKeyDeleted(t *testing.T, ctx context.Context, ds *dataStore) {
key := GetRequestKey{
Group: "apps",
Resource: "resources",
@@ -1724,12 +1676,9 @@ func testDataStoreGetLatestResourceKeyDeleted(t *testing.T, ctx context.Context,
}
func TestDataStore_GetLatestResourceKey_NotFound(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKeyNotFound)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKeyNotFound)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetLatestResourceKeyNotFound(t *testing.T, ctx context.Context, ds *dataStore) {
key := GetRequestKey{
Group: "apps",
Resource: "resources",
@@ -1742,12 +1691,9 @@ func testDataStoreGetLatestResourceKeyNotFound(t *testing.T, ctx context.Context
}
func TestDataStore_GetResourceKeyAtRevision(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetResourceKeyAtRevision)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetResourceKeyAtRevision)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetResourceKeyAtRevision(t *testing.T, ctx context.Context, ds *dataStore) {
key := GetRequestKey{
Group: "apps",
Resource: "resources",
@@ -1820,12 +1766,9 @@ func testDataStoreGetResourceKeyAtRevision(t *testing.T, ctx context.Context, ds
}
func TestDataStore_ListLatestResourceKeys(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeys)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeys)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListLatestResourceKeys(t *testing.T, ctx context.Context, ds *dataStore) {
listKey := ListRequestKey{
Group: "apps",
Resource: "resources",
@@ -1876,12 +1819,9 @@ func testDataStoreListLatestResourceKeys(t *testing.T, ctx context.Context, ds *
}
func TestDataStore_ListLatestResourceKeys_Deleted(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeysDeleted)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeysDeleted)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListLatestResourceKeysDeleted(t *testing.T, ctx context.Context, ds *dataStore) {
listKey := ListRequestKey{
Group: "apps",
Resource: "resources",
@@ -1929,12 +1869,9 @@ func testDataStoreListLatestResourceKeysDeleted(t *testing.T, ctx context.Contex
}
func TestDataStore_ListLatestResourceKeys_Multiple(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeysMultiple)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeysMultiple)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListLatestResourceKeysMultiple(t *testing.T, ctx context.Context, ds *dataStore) {
listKey := ListRequestKey{
Group: "apps",
Resource: "resources",
@@ -2003,12 +1940,9 @@ func testDataStoreListLatestResourceKeysMultiple(t *testing.T, ctx context.Conte
}
func TestDataStore_ListResourceKeysAtRevision(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevision)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevision)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListResourceKeysAtRevision(t *testing.T, ctx context.Context, ds *dataStore) {
// Create multiple resources with different versions
rv1 := node.Generate().Int64()
rv2 := node.Generate().Int64()
@@ -2218,12 +2152,9 @@ func testDataStoreListResourceKeysAtRevision(t *testing.T, ctx context.Context,
}
func TestDataStore_ListResourceKeysAtRevision_ValidationErrors(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionValidationErrors)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionValidationErrors)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListResourceKeysAtRevisionValidationErrors(t *testing.T, ctx context.Context, ds *dataStore) {
tests := []struct {
name string
key ListRequestKey
@@ -2263,12 +2194,9 @@ func testDataStoreListResourceKeysAtRevisionValidationErrors(t *testing.T, ctx c
}
func TestDataStore_ListResourceKeysAtRevision_EmptyResults(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionEmptyResults)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionEmptyResults)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListResourceKeysAtRevisionEmptyResults(t *testing.T, ctx context.Context, ds *dataStore) {
listKey := ListRequestKey{
Group: "apps",
Resource: "resources",
@@ -2285,12 +2213,9 @@ func testDataStoreListResourceKeysAtRevisionEmptyResults(t *testing.T, ctx conte
}
func TestDataStore_ListResourceKeysAtRevision_ResourcesNewerThanRevision(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision(t *testing.T, ctx context.Context, ds *dataStore) {
// Create a resource with a high resource version
rv := node.Generate().Int64()
key := DataKey{
@@ -2756,12 +2681,9 @@ func TestGetRequestKey_Prefix(t *testing.T) {
}
func TestDataStore_GetResourceStats_Comprehensive(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetResourceStatsComprehensive)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetResourceStatsComprehensive)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetResourceStatsComprehensive(t *testing.T, ctx context.Context, ds *dataStore) {
// Test setup: 3 namespaces × 3 groups × 3 resources × 3 names × 3 versions = 243 total entries
// But each name will have only 1 latest version that counts, so 3 × 3 × 3 × 3 = 81 non-deleted resources
namespaces := []string{"ns1", "ns2", "ns3"}
@@ -2966,12 +2888,9 @@ func testDataStoreGetResourceStatsComprehensive(t *testing.T, ctx context.Contex
}
func TestDataStore_getGroupResources(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetGroupResources)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetGroupResources)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetGroupResources(t *testing.T, ctx context.Context, ds *dataStore) {
// Create test data with multiple group/resource combinations
testData := []struct {
group string
@@ -3032,12 +2951,9 @@ func testDataStoreGetGroupResources(t *testing.T, ctx context.Context, ds *dataS
}
func TestDataStore_BatchDelete(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreBatchDelete)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreBatchDelete)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreBatchDelete(t *testing.T, ctx context.Context, ds *dataStore) {
keys := make([]DataKey, 95)
for i := 0; i < 95; i++ {
rv := node.Generate().Int64()
@@ -3071,12 +2987,9 @@ func testDataStoreBatchDelete(t *testing.T, ctx context.Context, ds *dataStore)
}
func TestDataStore_BatchGet(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreBatchGet)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreBatchGet)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreBatchGet(t *testing.T, ctx context.Context, ds *dataStore) {
t.Run("batch get multiple existing keys", func(t *testing.T) {
// Create test data
keys := make([]DataKey, 5)
@@ -3219,12 +3132,9 @@ func testDataStoreBatchGet(t *testing.T, ctx context.Context, ds *dataStore) {
}
func TestDataStore_GetLatestAndPredecessor(t *testing.T) {
runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestAndPredecessor)
// enable this when sqlkv is ready
// runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestAndPredecessor)
}
ds := setupTestDataStore(t)
ctx := context.Background()
func testDataStoreGetLatestAndPredecessor(t *testing.T, ctx context.Context, ds *dataStore) {
resourceKey := ListRequestKey{
Namespace: "test-namespace",
Group: "test-group",

View File

@@ -7,10 +7,6 @@ import (
"time"
"github.com/bwmarrin/snowflake"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -25,20 +21,6 @@ func setupTestEventStore(t *testing.T) *eventStore {
return newEventStore(kv)
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// nolint:unused
func setupTestEventStoreSqlKv(t *testing.T) *eventStore {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
kv, err := NewSQLKV(eDB)
require.NoError(t, err)
return newEventStore(kv)
}
func TestNewEventStore(t *testing.T) {
store := setupTestEventStore(t)
assert.NotNil(t, store.kv)
@@ -198,21 +180,10 @@ func TestEventStore_ParseEventKey(t *testing.T) {
assert.Equal(t, originalKey, parsedKey)
}
func runEventStoreTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) *eventStore, testFn func(*testing.T, context.Context, *eventStore)) {
t.Run(storeName, func(t *testing.T) {
ctx := context.Background()
store := newStoreFn(t)
testFn(t, ctx, store)
})
}
func TestEventStore_Save_Get(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreSaveGet)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreSaveGet)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreSaveGet(t *testing.T, ctx context.Context, store *eventStore) {
event := Event{
Namespace: "default",
Group: "apps",
@@ -245,12 +216,9 @@ func testEventStoreSaveGet(t *testing.T, ctx context.Context, store *eventStore)
}
func TestEventStore_Get_NotFound(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreGetNotFound)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreGetNotFound)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreGetNotFound(t *testing.T, ctx context.Context, store *eventStore) {
nonExistentKey := EventKey{
Namespace: "default",
Group: "apps",
@@ -265,12 +233,9 @@ func testEventStoreGetNotFound(t *testing.T, ctx context.Context, store *eventSt
}
func TestEventStore_LastEventKey(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreLastEventKey)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreLastEventKey)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreLastEventKey(t *testing.T, ctx context.Context, store *eventStore) {
// Test when no events exist
_, err := store.LastEventKey(ctx)
assert.Error(t, err)
@@ -327,12 +292,9 @@ func testEventStoreLastEventKey(t *testing.T, ctx context.Context, store *eventS
}
func TestEventStore_ListKeysSince(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListKeysSince)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListKeysSince)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreListKeysSince(t *testing.T, ctx context.Context, store *eventStore) {
// Add events with different resource versions
events := []Event{
{
@@ -387,12 +349,9 @@ func testEventStoreListKeysSince(t *testing.T, ctx context.Context, store *event
}
func TestEventStore_ListSince(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListSince)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListSince)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreListSince(t *testing.T, ctx context.Context, store *eventStore) {
// Add events with different resource versions
events := []Event{
{
@@ -445,12 +404,9 @@ func testEventStoreListSince(t *testing.T, ctx context.Context, store *eventStor
}
func TestEventStore_ListSince_Empty(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListSinceEmpty)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListSinceEmpty)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreListSinceEmpty(t *testing.T, ctx context.Context, store *eventStore) {
// List events when store is empty
retrievedEvents := make([]Event, 0)
for event, err := range store.ListSince(ctx, 0) {
@@ -503,12 +459,9 @@ func TestEventKey_Struct(t *testing.T) {
}
func TestEventStore_Save_InvalidJSON(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreSaveInvalidJSON)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreSaveInvalidJSON)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreSaveInvalidJSON(t *testing.T, ctx context.Context, store *eventStore) {
// This should work fine as the Event struct should be serializable
event := Event{
Namespace: "default",
@@ -524,12 +477,9 @@ func testEventStoreSaveInvalidJSON(t *testing.T, ctx context.Context, store *eve
}
func TestEventStore_CleanupOldEvents(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEvents)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEvents)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreCleanupOldEvents(t *testing.T, ctx context.Context, store *eventStore) {
now := time.Now()
oldRV := snowflakeFromTime(now.Add(-48 * time.Hour)) // 48 hours ago
recentRV := snowflakeFromTime(now.Add(-1 * time.Hour)) // 1 hour ago
@@ -615,12 +565,9 @@ func testEventStoreCleanupOldEvents(t *testing.T, ctx context.Context, store *ev
}
func TestEventStore_CleanupOldEvents_NoOldEvents(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEventsNoOldEvents)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEventsNoOldEvents)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreCleanupOldEventsNoOldEvents(t *testing.T, ctx context.Context, store *eventStore) {
// Create an event 1 hour old
rv := snowflakeFromTime(time.Now().Add(-1 * time.Hour))
event := Event{
@@ -656,12 +603,9 @@ func testEventStoreCleanupOldEventsNoOldEvents(t *testing.T, ctx context.Context
}
func TestEventStore_CleanupOldEvents_EmptyStore(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEventsEmptyStore)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEventsEmptyStore)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreCleanupOldEventsEmptyStore(t *testing.T, ctx context.Context, store *eventStore) {
// Clean up events from empty store
deletedCount, err := store.CleanupOldEvents(ctx, time.Now().Add(-24*time.Hour))
require.NoError(t, err)
@@ -669,12 +613,9 @@ func testEventStoreCleanupOldEventsEmptyStore(t *testing.T, ctx context.Context,
}
func TestEventStore_BatchDelete(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreBatchDelete)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreBatchDelete)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testEventStoreBatchDelete(t *testing.T, ctx context.Context, store *eventStore) {
// Create multiple events (more than batch size to test batching)
eventKeys := make([]string, 75)
for i := 0; i < 75; i++ {
@@ -781,12 +722,9 @@ func TestSnowflakeFromTime(t *testing.T) {
}
func TestListKeysSince_WithSnowflakeTime(t *testing.T) {
runEventStoreTestWith(t, "badger", setupTestEventStore, testListKeysSinceWithSnowflakeTime)
// enable this when sqlkv is ready
// runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testListKeysSinceWithSnowflakeTime)
}
ctx := context.Background()
store := setupTestEventStore(t)
func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store *eventStore) {
// Create events with snowflake-based resource versions at different times
now := time.Now()
events := []Event{

View File

@@ -6,9 +6,6 @@ import (
"time"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -25,18 +22,6 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
return notifier, eventStore
}
// nolint:unused
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
kv, err := NewSQLKV(eDB)
require.NoError(t, err)
eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore
}
func TestNewNotifier(t *testing.T) {
notifier, _ := setupTestNotifier(t)
@@ -50,21 +35,10 @@ func TestDefaultWatchOptions(t *testing.T) {
assert.Equal(t, defaultBufferSize, opts.BufferSize)
}
func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) {
t.Run(storeName, func(t *testing.T) {
ctx := context.Background()
notifier, eventStore := newStoreFn(t)
testFn(t, ctx, notifier, eventStore)
})
}
func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
}
ctx := context.Background()
notifier, eventStore := setupTestNotifier(t)
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
// Test with no events
rv, err := notifier.lastEventResourceVersion(ctx)
assert.Error(t, err)
@@ -111,12 +85,8 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not
}
func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
}
notifier, _ := setupTestNotifier(t)
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
tests := []struct {
name string
event Event
@@ -166,15 +136,11 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier,
}
func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
}
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
notifier, eventStore := setupTestNotifier(t)
// Add at least one event so that lastEventResourceVersion doesn't return ErrNotFound
initialEvent := Event{
Namespace: "default",
@@ -208,15 +174,11 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti
}
func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
}
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
notifier, eventStore := setupTestNotifier(t)
// Save some initial events
initialEvents := []Event{
{
@@ -283,15 +245,11 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti
}
func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
}
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
notifier, eventStore := setupTestNotifier(t)
// Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound
initialEvent := Event{
Namespace: "default",
@@ -350,13 +308,9 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti
}
func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
}
ctx, cancel := context.WithCancel(context.Background())
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
ctx, cancel := context.WithCancel(ctx)
notifier, eventStore := setupTestNotifier(t)
// Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound
initialEvent := Event{
@@ -397,14 +351,10 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not
}
func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
}
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
notifier, eventStore := setupTestNotifier(t)
rv := time.Now().UnixNano()
// Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound
initialEvent := Event{

View File

@@ -1,70 +0,0 @@
package resource
import (
"context"
"fmt"
"io"
"iter"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
)
var _ KV = &sqlKV{}
type sqlKV struct {
dbProvider db.DBProvider
db db.DB
}
func NewSQLKV(dbProvider db.DBProvider) (KV, error) {
if dbProvider == nil {
return nil, fmt.Errorf("dbProvider is required")
}
ctx := context.Background()
dbConn, err := dbProvider.Init(ctx)
if err != nil {
return nil, fmt.Errorf("error initializing DB: %w", err)
}
return &sqlKV{
dbProvider: dbProvider,
db: dbConn,
}, nil
}
func (k *sqlKV) Ping(ctx context.Context) error {
return k.db.PingContext(ctx)
}
func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
panic("not implemented!")
}
}
func (k *sqlKV) Get(ctx context.Context, section string, key string) (io.ReadCloser, error) {
panic("not implemented!")
}
func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) iter.Seq2[KeyValue, error] {
return func(yield func(KeyValue, error) bool) {
panic("not implemented!")
}
}
func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) {
panic("not implemented!")
}
func (k *sqlKV) Delete(ctx context.Context, section string, key string) error {
panic("not implemented!")
}
func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) error {
panic("not implemented!")
}
func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) {
panic("not implemented!")
}

View File

@@ -70,12 +70,7 @@ type kvStorageBackend struct {
//reg prometheus.Registerer
}
var _ KVBackend = &kvStorageBackend{}
type KVBackend interface {
StorageBackend
resourcepb.DiagnosticsServer
}
var _ StorageBackend = &kvStorageBackend{}
type KVBackendOptions struct {
KvStore KV
@@ -87,7 +82,7 @@ type KVBackendOptions struct {
Reg prometheus.Registerer // TODO add metrics
}
func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) {
ctx := context.Background()
kv := opts.KvStore
@@ -131,18 +126,6 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
return backend, nil
}
func (k *kvStorageBackend) IsHealthy(ctx context.Context, _ *resourcepb.HealthCheckRequest) (*resourcepb.HealthCheckResponse, error) {
type pinger interface {
Ping(context.Context) error
}
if p, ok := k.kv.(pinger); ok {
if err := p.Ping(ctx); err != nil {
return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_NOT_SERVING}, fmt.Errorf("KV store health check failed: %w", err)
}
}
return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_SERVING}, nil
}
// runCleanupOldEvents starts a background goroutine that periodically cleans up old events
func (k *kvStorageBackend) runCleanupOldEvents(ctx context.Context) {
// Run cleanup every hour

View File

@@ -97,41 +97,22 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
return nil, err
}
if opts.Cfg.EnableSQLKVBackend {
sqlkv, err := resource.NewSQLKV(eDB)
if err != nil {
return nil, fmt.Errorf("error creating sqlkv: %s", err)
}
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
KvStore: sqlkv,
Tracer: opts.Tracer,
Reg: opts.Reg,
})
if err != nil {
return nil, fmt.Errorf("error creating kv backend: %s", err)
}
serverOptions.Backend = kvBackend
serverOptions.Diagnostics = kvBackend
} else {
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
backend, err := NewBackend(BackendOptions{
DBProvider: eDB,
Reg: opts.Reg,
IsHA: isHA,
storageMetrics: opts.StorageMetrics,
LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age.
})
if err != nil {
return nil, err
}
serverOptions.Backend = backend
serverOptions.Diagnostics = backend
serverOptions.Lifecycle = backend
backend, err := NewBackend(BackendOptions{
DBProvider: eDB,
Reg: opts.Reg,
IsHA: isHA,
storageMetrics: opts.StorageMetrics,
LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age.
})
if err != nil {
return nil, err
}
serverOptions.Backend = backend
serverOptions.Diagnostics = backend
serverOptions.Lifecycle = backend
}
serverOptions.Search = opts.SearchOptions

View File

@@ -35,8 +35,7 @@ type NewKVFunc func(ctx context.Context) resource.KV
// KVTestOptions configures which tests to run
type KVTestOptions struct {
SkipTests map[string]bool
NSPrefix string // namespace prefix for isolation
NSPrefix string // namespace prefix for isolation
}
// GenerateRandomKVPrefix creates a random namespace prefix for test isolation
@@ -73,11 +72,6 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) {
}
for _, tc := range cases {
if shouldSkip := opts.SkipTests[tc.name]; shouldSkip {
t.Logf("Skipping test: %s", tc.name)
continue
}
t.Run(tc.name, func(t *testing.T) {
tc.fn(t, newKV(context.Background()), opts.NSPrefix)
})

View File

@@ -7,11 +7,7 @@ import (
badger "github.com/dgraph-io/badger/v4"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestBadgerKV(t *testing.T) {
@@ -30,33 +26,3 @@ func TestBadgerKV(t *testing.T) {
NSPrefix: "badger-kv-test",
})
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestSQLKV(t *testing.T) {
RunKVTest(t, func(ctx context.Context) resource.KV {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
kv, err := resource.NewSQLKV(eDB)
require.NoError(t, err)
return kv
}, &KVTestOptions{
NSPrefix: "sql-kv-test",
SkipTests: map[string]bool{
TestKVGet: true,
TestKVSave: true,
TestKVDelete: true,
TestKVKeys: true,
TestKVKeysWithLimits: true,
TestKVKeysWithSort: true,
TestKVConcurrent: true,
TestKVUnixTimestamp: true,
TestKVBatchGet: true,
TestKVBatchDelete: true,
},
})
}

View File

@@ -7,11 +7,7 @@ import (
badger "github.com/dgraph-io/badger/v4"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
)
func TestBadgerKVStorageBackend(t *testing.T) {
@@ -29,7 +25,7 @@ func TestBadgerKVStorageBackend(t *testing.T) {
require.NoError(t, err)
return backend
}, &TestOptions{
NSPrefix: "badgerkvstorage-test",
NSPrefix: "kvstorage-test",
SkipTests: map[string]bool{
// TODO: fix these tests and remove this skip
TestBlobSupport: true,
@@ -39,50 +35,3 @@ func TestBadgerKVStorageBackend(t *testing.T) {
},
})
}
func TestSQLKVStorageBackend(t *testing.T) {
newBackendFunc := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
kv, err := resource.NewSQLKV(eDB)
require.NoError(t, err)
kvOpts := resource.KVBackendOptions{
KvStore: kv,
}
backend, err := resource.NewKVStorageBackend(kvOpts)
require.NoError(t, err)
db, err := eDB.Init(ctx)
require.NoError(t, err)
return backend, db
}
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := newBackendFunc(ctx)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-test",
SkipTests: map[string]bool{
TestHappyPath: true,
TestWatchWriteEvents: true,
TestList: true,
TestBlobSupport: true,
TestGetResourceStats: true,
TestListHistory: true,
TestListHistoryErrorReporting: true,
TestListModifiedSince: true,
TestListTrash: true,
TestCreateNewResource: true,
TestGetResourceLastImportTime: true,
TestOptimisticLocking: true,
TestKeyPathGeneration: true,
},
})
RunSQLStorageBackendCompatibilityTest(t, newBackendFunc, &TestOptions{
NSPrefix: "sqlkvstorage-compatibility-test",
SkipTests: map[string]bool{
TestKeyPathGeneration: true,
},
})
}

View File

@@ -10,7 +10,6 @@ import (
"sync"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/assert"
@@ -69,45 +68,22 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
helper.validateManagedDashboardsFolderMetadata(t, ctx, repo, dashboards.Items)
t.Run("delete individual dashboard file on configured branch should succeed", func(t *testing.T) {
t.Run("delete individual dashboard file, should delete from repo and grafana", func(t *testing.T) {
result := helper.AdminREST.Delete().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "dashboard1.json").
Do(ctx)
require.NoError(t, result.Error(), "delete file on configured branch should succeed")
// Verify the dashboard is removed from Grafana
const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json
_, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
require.Error(t, err, "dashboard should be deleted from Grafana")
require.True(t, apierrors.IsNotFound(err), "should return NotFound for deleted dashboard")
require.NoError(t, result.Error())
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json")
require.Error(t, err)
dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, 2, len(dashboards.Items))
})
t.Run("delete individual dashboard file on branch should succeed", func(t *testing.T) {
// Create a branch first by creating a file on a branch
branchRef := "test-branch-delete"
helper.CopyToProvisioningPath(t, "testdata/text-options.json", "branch-test-delete.json")
// Delete on branch should work
result := helper.AdminREST.Delete().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "branch-test-delete.json").
Param("ref", branchRef).
Do(ctx)
// Note: This might fail if branch doesn't exist, but the important thing is it doesn't return MethodNotAllowed
if result.Error() != nil {
var statusErr *apierrors.StatusError
if errors.As(result.Error(), &statusErr) {
require.NotEqual(t, int32(http.StatusMethodNotAllowed), statusErr.ErrStatus.Code, "should not return MethodNotAllowed for branch delete")
}
}
})
t.Run("delete folder on configured branch should return MethodNotAllowed", func(t *testing.T) {
t.Run("delete folder, should delete from repo and grafana all nested resources too", func(t *testing.T) {
// need to delete directly through the url, because the k8s client doesn't support `/` in a subresource
// but that is needed by gitsync to know that it is a folder
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
@@ -118,11 +94,27 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "should return MethodNotAllowed for configured branch folder delete")
require.Equal(t, http.StatusOK, resp.StatusCode)
// Verify a file inside the folder still exists (operation was rejected)
// should be deleted from the repo
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder")
require.Error(t, err)
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json")
require.NoError(t, err, "file inside folder should still exist after rejected delete")
require.Error(t, err)
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested")
require.Error(t, err)
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested", "dashboard3.json")
require.Error(t, err)
// all should be deleted from grafana
for _, d := range dashboards.Items {
_, err = helper.DashboardsV1.Resource.Get(ctx, d.GetName(), metav1.GetOptions{})
require.Error(t, err)
}
for _, f := range folders.Items {
_, err = helper.Folders.Resource.Get(ctx, f.GetName(), metav1.GetOptions{})
require.Error(t, err)
}
})
t.Run("deleting a non-existent file should fail", func(t *testing.T) {
@@ -166,10 +158,10 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
require.NoError(t, err, "original dashboard should exist in Grafana")
require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity])
t.Run("move file without content change on configured branch should succeed", func(t *testing.T) {
t.Run("move file without content change", func(t *testing.T) {
const targetPath = "moved/simple-move.json"
// Perform the move operation using helper function (no ref = configured branch)
// Perform the move operation using helper function
resp := helper.postFilesRequest(t, repo, filesPostOptions{
targetPath: targetPath,
originalPath: "all-panels.json",
@@ -177,52 +169,32 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
})
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "move operation on configured branch should succeed")
require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed")
// Verify file was moved - read from new location
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json")
require.NoError(t, err, "file should exist at new location")
// Verify the file moved in the repository
movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json")
require.NoError(t, err, "moved file should exist in repository")
// Verify file no longer exists at old location
// Check the content is preserved (verify it's still the all-panels dashboard)
resource, _, err := unstructured.NestedMap(movedObj.Object, "resource")
require.NoError(t, err)
dryRun, _, err := unstructured.NestedMap(resource, "dryRun")
require.NoError(t, err)
title, _, err := unstructured.NestedString(dryRun, "spec", "title")
require.NoError(t, err)
require.Equal(t, "Panel tests - All panels", title, "content should be preserved")
// Verify original file no longer exists
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json")
require.Error(t, err, "file should not exist at old location")
require.Error(t, err, "original file should no longer exist")
// Verify dashboard still exists in Grafana with same content but may have updated path references
helper.SyncAndWait(t, repo, nil)
_, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
require.NoError(t, err, "dashboard should still exist in Grafana after move")
})
t.Run("move file without content change on branch should succeed", func(t *testing.T) {
const targetPath = "moved/simple-move-branch.json"
branchRef := "test-branch-move"
// Perform the move operation using helper function with ref parameter
resp := helper.postFilesRequest(t, repo, filesPostOptions{
targetPath: targetPath,
originalPath: "all-panels.json",
message: "move file without content change",
ref: branchRef,
})
// nolint:errcheck
defer resp.Body.Close()
// Note: This might fail if branch doesn't exist, but the important thing is it doesn't return MethodNotAllowed
if resp.StatusCode == http.StatusMethodNotAllowed {
t.Fatal("should not return MethodNotAllowed for branch move")
}
// If move succeeded (not MethodNotAllowed), verify the file moved in the repository
if resp.StatusCode == http.StatusOK {
movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move-branch.json")
require.NoError(t, err, "moved file should exist in repository")
// Check the content is preserved (verify it's still the all-panels dashboard)
resource, _, err := unstructured.NestedMap(movedObj.Object, "resource")
require.NoError(t, err)
dryRun, _, err := unstructured.NestedMap(resource, "dryRun")
require.NoError(t, err)
title, _, err := unstructured.NestedString(dryRun, "spec", "title")
require.NoError(t, err)
require.Equal(t, "Panel tests - All panels", title, "content should be preserved")
}
})
t.Run("move file to nested path on configured branch should succeed", func(t *testing.T) {
t.Run("move file to nested path without ref", func(t *testing.T) {
// Test a different scenario: Move a file that was never synced to Grafana
// This might reveal the issue if dashboard creation fails during move
const sourceFile = "never-synced.json"
@@ -231,7 +203,7 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
// DO NOT sync - move the file immediately without it ever being in Grafana
const targetPath = "deep/nested/timeline.json"
// Perform the move operation without the file ever being synced to Grafana (no ref = configured branch)
// Perform the move operation without the file ever being synced to Grafana
resp := helper.postFilesRequest(t, repo, filesPostOptions{
targetPath: targetPath,
originalPath: sourceFile,
@@ -239,25 +211,70 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
})
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "move operation on configured branch should succeed")
require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed")
// File should exist at new location
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json")
require.NoError(t, err, "file should exist at new nested location")
// Check folders were created and validate hierarchy
folderList, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "should be able to list folders")
// File should not exist at original location
// Build a map of folder names to their objects for easier lookup
folders := make(map[string]*unstructured.Unstructured)
for _, folder := range folderList.Items {
title, _, _ := unstructured.NestedString(folder.Object, "spec", "title")
folders[title] = &folder
parent, _, _ := unstructured.NestedString(folder.Object, "metadata", "annotations", "grafana.app/folder")
t.Logf(" - %s: %s (parent: %s)", folder.GetName(), title, parent)
}
// Validate expected folders exist with proper hierarchy
// Expected structure: deep -> deep/nested
deepFolderTitle := "deep"
nestedFolderTitle := "nested"
// Validate "deep" folder exists and has no parent (is top-level)
require.Contains(t, folders, deepFolderTitle, "deep folder should exist")
f := folders[deepFolderTitle]
deepFolderName := f.GetName()
title, _, _ := unstructured.NestedString(f.Object, "spec", "title")
require.Equal(t, deepFolderTitle, title, "deep folder should have correct title")
parent, found, _ := unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder")
require.True(t, !found || parent == "", "deep folder should be top-level (no parent)")
// Validate "deep/nested" folder exists and has "deep" as parent
require.Contains(t, folders, nestedFolderTitle, "nested folder should exist")
f = folders[nestedFolderTitle]
nestedFolderName := f.GetName()
title, _, _ = unstructured.NestedString(f.Object, "spec", "title")
require.Equal(t, nestedFolderTitle, title, "nested folder should have correct title")
parent, _, _ = unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder")
require.Equal(t, deepFolderName, parent, "nested folder should have deep folder as parent")
// The key test: Check if dashboard was created in Grafana during move
const timelineUID = "mIJjFy8Kz"
dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{})
require.NoError(t, err, "dashboard should exist in Grafana after moving never-synced file")
dashboardFolder, _, _ := unstructured.NestedString(dashboard.Object, "metadata", "annotations", "grafana.app/folder")
// Validate dashboard is in the correct nested folder
require.Equal(t, nestedFolderName, dashboardFolder, "dashboard should be in the nested folder")
// Verify the file moved in the repository
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json")
require.NoError(t, err, "moved file should exist in nested repository path")
// Verify the original file no longer exists in the repository
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourceFile)
require.Error(t, err, "file should not exist at original location after move")
require.Error(t, err, "original file should no longer exist in repository")
})
t.Run("move file with content update on configured branch should succeed", func(t *testing.T) {
const sourcePath = "moved/simple-move.json" // Use the file we moved earlier
t.Run("move file with content update", func(t *testing.T) {
const sourcePath = "moved/simple-move.json" // Use the file from previous test
const targetPath = "updated/content-updated.json"
// Use text-options.json content for the update
updatedContent := helper.LoadFile("testdata/text-options.json")
// Perform move with content update using helper function (no ref = configured branch)
// Perform move with content update using helper function
resp := helper.postFilesRequest(t, repo, filesPostOptions{
targetPath: targetPath,
originalPath: sourcePath,
@@ -266,27 +283,51 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
})
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update on configured branch should succeed")
require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update should succeed")
// File should exist at new location with updated content
// Verify the moved file has updated content (should now be text-options dashboard)
movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "updated", "content-updated.json")
require.NoError(t, err, "file should exist at new location")
require.NoError(t, err, "moved file should exist in repository")
// Verify content was updated (should be text-options dashboard now)
resource, _, err := unstructured.NestedMap(movedObj.Object, "resource")
require.NoError(t, err)
dryRun, _, err := unstructured.NestedMap(resource, "dryRun")
require.NoError(t, err)
title, _, err := unstructured.NestedString(dryRun, "spec", "title")
require.NoError(t, err)
require.Equal(t, "Text options", title, "content should be updated")
require.Equal(t, "Text options", title, "content should be updated to text-options dashboard")
// Source file should not exist anymore
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourcePath)
require.Error(t, err, "source file should not exist after move")
// Check it has the expected UID from text-options.json
name, _, err := unstructured.NestedString(dryRun, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "WZ7AhQiVz", name, "should have the UID from text-options.json")
// Verify source file no longer exists
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json")
require.Error(t, err, "source file should no longer exist")
// Sync and verify the updated dashboard exists in Grafana
helper.SyncAndWait(t, repo, nil)
const textOptionsUID = "WZ7AhQiVz" // UID from text-options.json
updatedDashboard, err := helper.DashboardsV1.Resource.Get(ctx, textOptionsUID, metav1.GetOptions{})
require.NoError(t, err, "updated dashboard should exist in Grafana")
// Verify the original dashboard was deleted from Grafana
_, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
require.Error(t, err, "original dashboard should be deleted from Grafana")
require.True(t, apierrors.IsNotFound(err))
// Verify the new dashboard has the updated content
updatedTitle, _, err := unstructured.NestedString(updatedDashboard.Object, "spec", "title")
require.NoError(t, err)
require.Equal(t, "Text options", updatedTitle)
})
t.Run("move directory on configured branch should return MethodNotAllowed", func(t *testing.T) {
t.Run("move directory", func(t *testing.T) {
t.Skip("Skip as implementation is broken and leaves dashboards behind in the move")
// FIXME: https://github.com/grafana/git-ui-sync-project/issues/379
// The current implementation of moving directories is flawed.
// It will be deprecated in favor of queuing a move job
// Create some files in a directory first using existing testdata files
helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "source-dir/timeline-demo.json")
helper.CopyToProvisioningPath(t, "testdata/text-options.json", "source-dir/text-options.json")
@@ -297,7 +338,7 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
const sourceDir = "source-dir/"
const targetDir = "moved-dir/"
// Move directory using helper function (no ref = configured branch)
// Move directory using helper function
resp := helper.postFilesRequest(t, repo, filesPostOptions{
targetPath: targetDir,
originalPath: sourceDir,
@@ -305,11 +346,20 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
})
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "directory move on configured branch should return MethodNotAllowed")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err, "should read response body")
t.Logf("Response Body: %s", string(body))
require.Equal(t, http.StatusOK, resp.StatusCode, "directory move should succeed")
// Verify files in source directory still exist (operation was rejected)
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir", "timeline-demo.json")
require.NoError(t, err, "file in source directory should still exist after rejected move")
// Verify source directory no longer exists
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir")
require.Error(t, err, "source directory should no longer exist")
// Verify target directory and files exist
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "timeline-demo.json")
require.NoError(t, err, "moved timeline-demo.json should exist")
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "text-options.json")
require.NoError(t, err, "moved text-options.json should exist")
})
t.Run("error cases", func(t *testing.T) {
@@ -516,7 +566,7 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
})
t.Run("DELETE resource owned by different repository - should fail", func(t *testing.T) {
// Create a file manually in the second repo which has UID from first repo
// Create a file manually in the second repo which is already in first one
helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "repo2/conflicting-delete.json")
printFileTree(t, helper.ProvisioningPath)
@@ -540,7 +590,10 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
}
// Verify it returns BadRequest (400) for ownership conflicts
require.True(t, apierrors.IsBadRequest(err), "Expected BadRequest error but got: %T - %v", err, err)
if !apierrors.IsBadRequest(err) {
t.Errorf("Expected BadRequest error but got: %T - %v", err, err)
return
}
// Check error message contains ownership conflict information
errorMsg := err.Error()
@@ -554,7 +607,7 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
targetPath: "moved-dashboard.json",
originalPath: path.Join("dashboard2.json"),
message: "attempt to move file from different repository",
body: string(helper.LoadFile("testdata/all-panels.json")), // Content with the conflicting UID
body: string(helper.LoadFile("testdata/all-panels.json")), // Content to move with the conflicting UID
})
// nolint:errcheck
defer resp.Body.Close()
@@ -591,160 +644,3 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
require.Equal(t, repo2, dashboard2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo2's dashboard should still be owned by repo2")
})
}
// TestIntegrationProvisioning_FilesAuthorization verifies that authorization
// works correctly for file operations with the access checker
func TestIntegrationProvisioning_FilesAuthorization(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
ctx := context.Background()
// Create a repository with a dashboard
const repo = "authz-test-repo"
helper.CreateRepo(t, TestRepo{
Name: repo,
Path: helper.ProvisioningPath,
Target: "instance",
SkipResourceAssertions: true, // We validate authorization, not resource creation
Copies: map[string]string{
"testdata/all-panels.json": "dashboard1.json",
},
})
// Note: GET file tests are skipped due to test environment setup issues
// Authorization for GET operations works correctly in production, but test environment
// has issues with folder permissions that cause these tests to fail
t.Run("POST file (create) - Admin role should succeed", func(t *testing.T) {
dashboardContent := helper.LoadFile("testdata/timeline-demo.json")
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "new-dashboard.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "admin should be able to create files")
// Verify the dashboard was created
var wrapper provisioning.ResourceWrapper
require.NoError(t, result.Into(&wrapper))
require.NotEmpty(t, wrapper.Resource.Upsert.Object, "should have created resource")
})
t.Run("POST file (create) - Editor role should succeed", func(t *testing.T) {
dashboardContent := helper.LoadFile("testdata/text-options.json")
result := helper.EditorREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "editor-dashboard.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "editor should be able to create files via access checker")
// Verify the dashboard was created
var wrapper provisioning.ResourceWrapper
require.NoError(t, result.Into(&wrapper))
require.NotEmpty(t, wrapper.Resource.Upsert.Object, "should have created resource")
})
t.Run("POST file (create) - Viewer role should fail", func(t *testing.T) {
dashboardContent := helper.LoadFile("testdata/text-options.json")
result := helper.ViewerREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "viewer-dashboard.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.Error(t, result.Error(), "viewer should not be able to create files")
require.True(t, apierrors.IsForbidden(result.Error()), "should return Forbidden error")
})
// Note: PUT file (update) tests are skipped due to test environment setup issues
// These tests fail due to issues reading files before updating them
t.Run("PUT file (update) - Viewer role should fail", func(t *testing.T) {
// Try to update without reading first
dashboardContent := helper.LoadFile("testdata/all-panels.json")
result := helper.ViewerREST.Put().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "dashboard1.json").
Body(dashboardContent).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.Error(t, result.Error(), "viewer should not be able to update files")
require.True(t, apierrors.IsForbidden(result.Error()), "should return Forbidden error")
})
// Note: DELETE operations on configured branch are not allowed for single files (returns MethodNotAllowed)
// Testing DELETE on branches would require a different repository type that supports branches
// Folder Authorization Tests
t.Run("POST folder (create) - Admin role should succeed", func(t *testing.T) {
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/test-folder/", addr, repo)
req, err := http.NewRequest(http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "admin should be able to create folders")
})
t.Run("POST folder (create) - Editor role should succeed", func(t *testing.T) {
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
url := fmt.Sprintf("http://editor:editor@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/editor-folder/", addr, repo)
req, err := http.NewRequest(http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "editor should be able to create folders via access checker")
})
t.Run("POST folder (create) - Viewer role should fail", func(t *testing.T) {
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
url := fmt.Sprintf("http://viewer:viewer@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/viewer-folder/", addr, repo)
req, err := http.NewRequest(http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// nolint:errcheck
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "viewer should not be able to create folders")
})
// Note: DELETE folder operations on configured branch are not allowed (returns MethodNotAllowed)
// Note: MOVE operations require branches which are not supported by local repositories in tests
// These operations are tested in the existing TestIntegrationProvisioning_DeleteResources and
// TestIntegrationProvisioning_MoveResources tests
}
// NOTE: Granular folder-level permission tests are complex to set up correctly
// and are out of scope for this authorization refactoring PR.
// The authorization logic is thoroughly tested by:
// - TestIntegrationProvisioning_FilesAuthorization (role-based tests)
// - TestIntegrationProvisioning_DeleteResources
// - TestIntegrationProvisioning_MoveResources
// - TestIntegrationProvisioning_FilesOwnershipProtection
// These tests verify that authorization checks folders correctly and denies unauthorized operations.

View File

@@ -786,7 +786,7 @@ func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T
v, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyUpdatedBy)
require.Equal(t, "access-policy:provisioning", v)
// Should be able to directly delete the managed resource
// Should not be able to directly delete the managed resource
err = helper.DashboardsV1.Resource.Delete(ctx, allPanels, metav1.DeleteOptions{})
require.NoError(t, err, "user can delete")

View File

@@ -280,15 +280,7 @@ func (s *Service) handleTagValues(rw http.ResponseWriter, req *http.Request) {
return
}
// escape tag
tag, err := url.PathUnescape(encodedTag)
if err != nil {
s.logger.Error("Failed to unescape", "error", err, "tag", encodedTag)
http.Error(rw, "Invalid 'tag' parameter", http.StatusBadRequest)
return
}
tempoPath := fmt.Sprintf("api/v2/search/tag/%s/values", tag)
tempoPath := fmt.Sprintf("api/v2/search/tag/%s/values", encodedTag)
s.proxyToTempo(rw, req, tempoPath)
}

View File

@@ -3402,12 +3402,11 @@
},
"/dashboards/home": {
"get": {
"description": "NOTE: the home dashboard is configured in preferences. This API will be removed in G13",
"tags": [
"dashboards"
],
"summary": "Get home dashboard.",
"operationId": "getHomeDashboard",
"deprecated": true,
"responses": {
"200": {
"$ref": "#/responses/getHomeDashboardResponse"

View File

@@ -76,12 +76,12 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro
data-testid={selectors.pages.Dashboard.Sidebar.optionsButton}
active={selectedObject === dashboard ? true : false}
/>
{/* <Sidebar.Button
<Sidebar.Button
tooltip={t('dashboard.sidebar.edit-schema.tooltip', 'Edit as code')}
title={t('dashboard.sidebar.edit-schema.title', 'Code')}
icon="brackets-curly"
onClick={() => dashboard.openV2SchemaEditor()}
/> */}
/>
<Sidebar.Divider />
</>
)}

View File

@@ -51,16 +51,11 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const visibleChildren = useMemo(() => {
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
return isEditing
? children
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
}, [editableElement, isEditing]);
const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -79,10 +74,6 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
setIsCollapsed(!isCollapsed);
};
if (elementInfo.isHidden && !isEditing) {
return null;
}
return (
// todo: add proper keyboard navigation
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
@@ -139,8 +130,8 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
{isContainer && !isCollapsed && (
<ul className={styles.nodeChildren} role="group">
{visibleChildren.length > 0 ? (
visibleChildren.map((child, i) => (
{children.length > 0 ? (
children.map((child, i) => (
<DashboardOutlineNode
key={child.state.key}
sceneObject={child}

View File

@@ -190,7 +190,7 @@ describe('InspectJsonTab', () => {
expect(obj.kind).toEqual('Panel');
expect(obj.spec.id).toEqual(12);
expect(obj.spec.data.kind).toEqual('QueryGroup');
expect(tab.isEditable()).toBe(true);
expect(tab.isEditable()).toBe(false);
});
});

View File

@@ -17,7 +17,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import { LibraryPanel } from '@grafana/schema/';
import { Alert, Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui';
import { Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
@@ -27,7 +27,6 @@ import { getPrettyJSON } from 'app/features/inspector/utils/utils';
import { reportPanelInspectInteraction } from 'app/features/search/page/reporting';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { buildVizPanel } from '../serialization/layoutSerializers/utils';
import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene';
import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { vizPanelToSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
@@ -38,7 +37,6 @@ import {
getQueryRunnerFor,
isLibraryPanel,
} from '../utils/utils';
import { isPanelKindV2 } from '../v2schema/validation';
export type ShowContent = 'panel-json' | 'panel-data' | 'data-frames';
@@ -47,7 +45,6 @@ export interface InspectJsonTabState extends SceneObjectState {
source: ShowContent;
jsonText: string;
onClose: () => void;
error?: string;
}
export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
@@ -105,77 +102,38 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
}
public onChangeSource = (value: SelectableValue<ShowContent>) => {
this.setState({
source: value.value!,
jsonText: getJsonText(value.value!, this.state.panelRef.resolve()),
error: undefined,
});
this.setState({ source: value.value!, jsonText: getJsonText(value.value!, this.state.panelRef.resolve()) });
};
public onApplyChange = () => {
const panel = this.state.panelRef.resolve();
const dashboard = getDashboardSceneFor(panel);
let jsonObj: unknown;
try {
jsonObj = JSON.parse(this.state.jsonText);
} catch (e) {
this.setState({
error: t('dashboard-scene.inspect-json-tab.error-invalid-json', 'Invalid JSON'),
});
const jsonObj = JSON.parse(this.state.jsonText);
const panelModel = new PanelModel(jsonObj);
const gridItem = buildGridItemForPanel(panelModel);
const newState = sceneUtils.cloneSceneObjectState(gridItem.state);
if (!(panel.parent instanceof DashboardGridItem)) {
console.error('Cannot update state of panel', panel, gridItem);
return;
}
if (isDashboardV2Spec(dashboard.getSaveModel())) {
if (!isPanelKindV2(jsonObj)) {
this.setState({
error: t(
'dashboard-scene.inspect-json-tab.error-invalid-v2-panel',
'Panel JSON did not pass validation. Please check the JSON and try again.'
),
});
return;
}
const vizPanel = buildVizPanel(jsonObj, jsonObj.spec.id);
this.state.onClose();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== jsonObj.spec.vizConfig.group,
panel_id_changed: getPanelIdForVizPanel(panel) !== jsonObj.spec.id,
panel_grid_pos_changed: false, // Grid cant be edited from inspect in v2 panels.
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(vizPanel.state.$data)),
});
panel.setState(vizPanel.state);
this.state.onClose();
} else {
const panelModel = new PanelModel(jsonObj);
const gridItem = buildGridItemForPanel(panelModel);
const newState = sceneUtils.cloneSceneObjectState(gridItem.state);
if (!(panel.parent instanceof DashboardGridItem)) {
console.error('Cannot update state of panel', panel, gridItem);
return;
}
this.state.onClose();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
panel.parent.setState(newState);
//Report relevant updates
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== panelModel.type,
panel_id_changed: getPanelIdForVizPanel(panel) !== panelModel.id,
panel_grid_pos_changed: hasGridPosChanged(panel.parent.state, newState),
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(newState.$data)),
});
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
panel.parent.setState(newState);
//Report relevant updates
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== panelModel.type,
panel_id_changed: getPanelIdForVizPanel(panel) !== panelModel.id,
panel_grid_pos_changed: hasGridPosChanged(panel.parent.state, newState),
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(newState.$data)),
});
};
public onCodeEditorBlur = (value: string) => {
@@ -194,6 +152,11 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
return false;
}
// V2 dashboard panels are not editable from the inspect
if (isDashboardV2Spec(getDashboardSceneFor(panel).getSaveModel())) {
return false;
}
// Only support normal grid items for now and not repeated items
if (panel.parent instanceof DashboardGridItem && panel.parent.isRepeated()) {
return false;
@@ -207,14 +170,14 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
}
function InspectJsonTabComponent({ model }: SceneComponentProps<InspectJsonTab>) {
const { source: show, jsonText, error } = model.useState();
const { source: show, jsonText } = model.useState();
const styles = useStyles2(getPanelInspectorStyles2);
const options = model.getOptions();
return (
<div className={styles.wrap}>
<div className={styles.toolbar} data-testid={selectors.components.PanelInspector.Json.content}>
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1" noMargin>
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1">
<Select
inputId="select-source-dropdown"
options={options}
@@ -229,12 +192,6 @@ function InspectJsonTabComponent({ model }: SceneComponentProps<InspectJsonTab>)
)}
</div>
{error && (
<Alert severity="error" title={t('dashboard-scene.inspect-json-tab.validation-error', 'Validation error')}>
<p>{error}</p>
</Alert>
)}
<div className={styles.content}>
<AutoSizer disableWidth>
{({ height }) => (

View File

@@ -25,17 +25,10 @@ import { DashboardDataDTO } from 'app/types/dashboard';
import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer';
import { PanelTimeRange, PanelTimeRangeState } from '../../scene/panel-timerange/PanelTimeRange';
import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager';
import { transformSaveModelSchemaV2ToScene } from '../../serialization/transformSaveModelSchemaV2ToScene';
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../../utils/utils';
import { buildPanelEditScene } from '../PanelEditor';
import {
testDashboard,
panelWithTransformations,
panelWithQueriesOnly,
testDashboardV2,
} from '../testfiles/testDashboard';
import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard';
import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab';
@@ -831,78 +824,6 @@ describe('PanelDataQueriesTab', () => {
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
});
describe('V2 schema behavior - panel datasource undefined but queries have datasource', () => {
it('should load datasource from first query for V2 panel with prometheus datasource', async () => {
// panel-1 has a query with prometheus datasource
const { queriesTab } = await setupV2Scene('panel-1');
// V2 panels have undefined panel-level datasource for non-mixed panels
expect(queriesTab.queryRunner.state.datasource).toBeUndefined();
// But the query has its own datasource
expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
});
// Should load the datasource from the first query
expect(queriesTab.state.datasource?.uid).toBe('gdev-prometheus');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-prometheus');
});
it('should load datasource from first query for V2 panel with testdata datasource', async () => {
// panel-2 has a query with testdata datasource
const { queriesTab } = await setupV2Scene('panel-2');
// V2 panels have undefined panel-level datasource for non-mixed panels
expect(queriesTab.queryRunner.state.datasource).toBeUndefined();
// But the query has its own datasource
expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({
type: 'grafana-testdata-datasource',
uid: 'gdev-testdata',
});
// Should load the datasource from the first query
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
it('should fall back to last used datasource when V2 query has no explicit datasource', async () => {
store.exists.mockReturnValue(true);
store.getObject.mockImplementation((key: string, def: unknown) => {
if (key === PANEL_EDIT_LAST_USED_DATASOURCE) {
return {
dashboardUid: 'v2-dashboard-uid',
datasourceUid: 'gdev-testdata',
};
}
return def;
});
// panel-3 has a query with NO explicit datasource (datasource.name is undefined)
const { queriesTab } = await setupV2Scene('panel-3');
// V2 panel with no explicit datasource on query should fall back to last used
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
it('should use panel-level datasource when available (V1 behavior preserved)', async () => {
const { queriesTab } = await setupScene('panel-1');
// V1 panels have panel-level datasource set
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
// Should use the panel-level datasource
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
});
});
});
@@ -923,24 +844,3 @@ async function setupScene(panelId: string) {
return { panel, scene: dashboard, queriesTab };
}
// Setup V2 scene - uses transformSaveModelSchemaV2ToScene
async function setupV2Scene(panelKey: string) {
const dashboard = transformSaveModelSchemaV2ToScene(testDashboardV2);
const vizPanels = (dashboard.state.body as DashboardLayoutManager).getVizPanels();
const panel = vizPanels.find((p) => p.state.key === panelKey)!;
const panelEditor = buildPanelEditScene(panel);
dashboard.setState({ editPanel: panelEditor });
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
deactivators.push(queriesTab.activate());
await Promise.resolve();
return { panel, scene: dashboard, queriesTab };
}

View File

@@ -86,17 +86,6 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
let datasource: DataSourceApi | undefined;
let dsSettings: DataSourceInstanceSettings | undefined;
// If no panel-level datasource (V2 schema non-mixed case), infer from first query
// This also improves the V1 behavior because it doesn't make sense to rely on last used
// if underlying queries have different datasources
if (!datasourceToLoad) {
const queries = this.queryRunner.state.queries;
const firstQueryDs = queries[0]?.datasource;
if (firstQueryDs) {
datasourceToLoad = firstQueryDs;
}
}
if (!datasourceToLoad) {
const dashboardScene = getDashboardSceneFor(this);
const dashboardUid = dashboardScene.state.uid ?? '';

View File

@@ -1,6 +1,3 @@
import { Spec as DashboardV2Spec, defaultDataQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
export const panelWithQueriesOnly = {
datasource: {
type: 'grafana-testdata-datasource',
@@ -754,223 +751,3 @@ export const testDashboard = {
version: 6,
weekStart: '',
};
// V2 Dashboard fixture - panels have queries with datasources but NO panel-level datasource
export const testDashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = {
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'v2-dashboard-uid',
namespace: 'default',
labels: {},
generation: 1,
resourceVersion: '1',
creationTimestamp: new Date().toISOString(),
},
spec: {
title: 'V2 Test Dashboard',
description: 'Test dashboard for V2 schema',
tags: [],
cursorSync: 'Off',
liveNow: false,
editable: true,
preload: false,
links: [],
variables: [],
annotations: [],
timeSettings: {
from: 'now-6h',
to: 'now',
autoRefresh: '',
autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
fiscalYearStartMonth: 0,
hideTimepicker: false,
timezone: '',
weekStart: undefined,
quickRanges: [],
},
elements: {
'panel-1': {
kind: 'Panel',
spec: {
id: 1,
title: 'Panel with Prometheus datasource',
description: '',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana-prometheus-datasource',
datasource: {
name: 'gdev-prometheus',
},
spec: {
expr: 'up',
},
},
},
},
],
transformations: [],
queryOptions: {},
},
},
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
},
},
},
},
'panel-2': {
kind: 'Panel',
spec: {
id: 2,
title: 'Panel with TestData datasource',
description: '',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana-testdata-datasource',
datasource: {
name: 'gdev-testdata',
},
spec: {
scenarioId: 'random_walk',
},
},
},
},
],
transformations: [],
queryOptions: {},
},
},
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
},
},
},
},
'panel-3': {
kind: 'Panel',
spec: {
id: 3,
title: 'Panel with no datasource on query',
description: '',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana-testdata-datasource',
// No datasource.name - simulates panel with no explicit datasource
spec: {},
},
},
},
],
transformations: [],
queryOptions: {},
},
},
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: {
defaults: {},
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' },
},
},
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 8,
width: 12,
height: 8,
element: { kind: 'ElementReference', name: 'panel-3' },
},
},
],
},
},
},
access: {
url: '/d/v2-dashboard-uid',
slug: 'v2-test-dashboard',
},
apiVersion: 'v2',
};

View File

@@ -19,7 +19,6 @@ import {
} from '@grafana/scenes';
import { Box, Button, useStyles2 } from '@grafana/ui';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { ContextualNavigationPaneToggle } from 'app/features/scopes/dashboards/ContextualNavigationPaneToggle';
import { PanelEditControls } from '../panel-edit/PanelEditControls';
import { getDashboardSceneFor } from '../utils/utils';
@@ -173,9 +172,6 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
</div>
)}
</div>
{config.featureToggles.scopeFilters && !editPanel && (
<ContextualNavigationPaneToggle className={styles.contextualNavToggle} hideWhenOpen={true} />
)}
{!hideVariableControls && (
<>
<VariableControls dashboard={dashboard} />
@@ -291,9 +287,5 @@ function getStyles(theme: GrafanaTheme2) {
flexWrap: 'wrap',
marginLeft: 'auto',
}),
contextualNavToggle: css({
display: 'inline-flex',
margin: theme.spacing(0, 1, 1, 0),
}),
};
}

View File

@@ -5,19 +5,7 @@ import { CoreApp, GrafanaTheme2, PanelPlugin, PanelProps } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { sceneUtils } from '@grafana/scenes';
import {
Box,
Button,
ButtonGroup,
Dropdown,
EmptyState,
Icon,
Menu,
Stack,
Text,
usePanelContext,
useStyles2,
} from '@grafana/ui';
import { Box, Button, ButtonGroup, Dropdown, Icon, Menu, Stack, Text, usePanelContext, useStyles2 } from '@grafana/ui';
import { NEW_PANEL_TITLE } from '../../dashboard/utils/dashboard';
import { DashboardInteractions } from '../utils/interactions';
@@ -104,30 +92,20 @@ function UnconfiguredPanelComp(props: PanelProps) {
);
}
const { isEditing } = dashboard.state;
return (
<Stack direction={'row'} alignItems={'center'} height={'100%'} justifyContent={'center'}>
<Box paddingBottom={2}>
{isEditing ? (
<ButtonGroup>
<Button icon="sliders-v-alt" onClick={onConfigure}>
<Trans i18nKey="dashboard.new-panel.configure-button">Configure</Trans>
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button
aria-label={t('dashboard.new-panel.configure-button-menu', 'Toggle menu')}
icon={isOpen ? 'angle-up' : 'angle-down'}
/>
</Dropdown>
</ButtonGroup>
) : (
<EmptyState
variant="call-to-action"
message={t('dashboard.new-panel.missing-config', 'Missing panel configuration')}
hideImage
/>
)}
<ButtonGroup>
<Button icon="sliders-v-alt" onClick={onConfigure}>
<Trans i18nKey="dashboard.new-panel.configure-button">Configure</Trans>
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button
aria-label={t('dashboard.new-panel.configure-button-menu', 'Toggle menu')}
icon={isOpen ? 'angle-up' : 'angle-down'}
/>
</Dropdown>
</ButtonGroup>
</Box>
</Stack>
);

View File

@@ -91,12 +91,10 @@ export class RowItem
}
public getEditableElementInfo(): EditableDashboardElementInfo {
const isHidden = !this.state.conditionalRendering?.state.result;
return {
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'list-ul',
isHidden,
};
}

View File

@@ -18,8 +18,7 @@ import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { RowItem } from './RowItem';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, collapse, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
const isCollapsed = collapse && !isHeaderHidden; // never allow a row without a header to be collapsed
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
const isClone = isRepeatCloneOrChildOf(model);
const { isEditing } = useDashboardState(model);
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(
@@ -238,7 +237,6 @@ function getStyles(theme: GrafanaTheme2) {
}),
dragging: css({
cursor: 'move',
backgroundColor: theme.colors.background.canvas,
}),
wrapperGrow: css({
flexGrow: 1,

View File

@@ -89,12 +89,10 @@ export class TabItem
}
public getEditableElementInfo(): EditableDashboardElementInfo {
const isHidden = !this.state.conditionalRendering?.state.result;
return {
typeName: t('dashboard.edit-pane.elements.tab', 'Tab'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'layers',
isHidden,
};
}

View File

@@ -135,7 +135,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) {
<TextLink
external
href={
'https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/#repeating-rows-and-tabs-and-the-dashboard-special-data-source'
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-tabs'
}
>
<Trans i18nKey="dashboard.tabs-layout.tab.repeat.learn-more">Learn more</Trans>

View File

@@ -1,60 +0,0 @@
import {
defaultPanelKind,
defaultQueryGroupKind,
defaultPanelQueryKind,
defaultVizConfigKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { isPanelKindV2 } from './validation';
describe('v2schema validation', () => {
it('isPanelKindV2 returns true for a minimal valid PanelKind', () => {
const panel = defaultPanelKind();
// Ensure minimal required properties exist (defaults should be fine)
panel.spec.vizConfig = defaultVizConfigKind();
panel.spec.data = defaultQueryGroupKind();
expect(isPanelKindV2(panel)).toBe(true);
});
it('returns false when kind is not "Panel"', () => {
const panel = defaultPanelKind();
// @ts-expect-error intentional invalid kind for test
panel.kind = 'NotAPanel';
expect(isPanelKindV2(panel)).toBe(false);
});
it('returns false when data kind is wrong', () => {
const panel = defaultPanelKind();
// @ts-expect-error intentional invalid kind for test
panel.spec.data = { kind: 'Wrong', spec: {} };
expect(isPanelKindV2(panel)).toBe(false);
});
it('returns false when queries contain invalid entries', () => {
const panel = defaultPanelKind();
panel.spec.data = defaultQueryGroupKind();
// @ts-expect-error push an invalid query shape
panel.spec.data.spec.queries = [{}];
expect(isPanelKindV2(panel)).toBe(false);
// Ensure a valid query shape passes
panel.spec.data.spec.queries = [defaultPanelQueryKind()];
expect(isPanelKindV2(panel)).toBe(true);
});
it('returns false when vizConfig.group is not a string', () => {
const panel = defaultPanelKind();
panel.spec.vizConfig = defaultVizConfigKind();
// @ts-expect-error force wrong type
panel.spec.vizConfig.group = 42;
expect(isPanelKindV2(panel)).toBe(false);
});
it('returns false when transparent is not a boolean', () => {
const panel = defaultPanelKind();
// @ts-expect-error wrong type
panel.spec.transparent = 'yes';
expect(isPanelKindV2(panel)).toBe(false);
});
});

View File

@@ -1,137 +0,0 @@
import {
PanelKind,
QueryGroupKind,
VizConfigKind,
PanelQueryKind,
TransformationKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isPanelQueryKind(value: unknown): value is PanelQueryKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'PanelQuery' || !isObject(value.spec)) {
return false;
}
// Minimal checks for query spec; accept additional properties
if (typeof value.spec.refId !== 'string') {
return false;
}
if (typeof value.spec.hidden !== 'boolean') {
return false;
}
// value.spec.query is an opaque "DataQueryKind" which is { kind: string, spec: Record<string, any> }
const q = value.spec.query;
if (!isObject(q) || typeof q.kind !== 'string' || !isObject(q.spec)) {
return false;
}
return true;
}
function isTransformationKind(value: unknown): value is TransformationKind {
if (!isObject(value)) {
return false;
}
if (typeof value.kind !== 'string') {
return false;
}
if (!isObject(value.spec)) {
return false;
}
return true;
}
function isQueryGroupKind(value: unknown): value is QueryGroupKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'QueryGroup' || !isObject(value.spec)) {
return false;
}
const spec = value.spec;
if (!Array.isArray(spec.queries) || !spec.queries.every(isPanelQueryKind)) {
return false;
}
if (!Array.isArray(spec.transformations) || !spec.transformations.every(isTransformationKind)) {
return false;
}
if (!isObject(spec.queryOptions)) {
return false;
}
return true;
}
function isVizConfigKind(value: unknown): value is VizConfigKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'VizConfig') {
return false;
}
if (typeof value.group !== 'string') {
return false;
}
if (typeof value.version !== 'string') {
return false;
}
if (!isObject(value.spec)) {
return false;
}
const spec = value.spec;
if (!isObject(spec.options)) {
return false;
}
if (!isObject(spec.fieldConfig)) {
return false;
}
// Minimal fieldConfig shape (defaults/overrides may be empty)
if (!isObject(spec.fieldConfig)) {
return false;
}
return true;
}
export function isPanelKindV2(value: unknown): value is PanelKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'Panel') {
return false;
}
if (!isObject(value.spec)) {
return false;
}
const spec = value.spec;
if (typeof spec.id !== 'number') {
return false;
}
if (typeof spec.title !== 'string') {
return false;
}
if (typeof spec.description !== 'string') {
return false;
}
if (!Array.isArray(spec.links)) {
return false;
}
if (!isQueryGroupKind(spec.data)) {
return false;
}
if (!isVizConfigKind(spec.vizConfig)) {
return false;
}
if (spec.transparent !== undefined && typeof spec.transparent !== 'boolean') {
return false;
}
return true;
}
export function validatePanelKindV2(value: unknown): asserts value is PanelKind {
if (!isPanelKindV2(value)) {
throw new Error('Provided JSON is not a valid v2 Panel spec');
}
}

View File

@@ -78,36 +78,11 @@ export function unboxNearMembraneProxies(structure: unknown): unknown {
if (Array.isArray(structure)) {
return structure.map(unboxNearMembraneProxies);
}
if (isTransferable(structure)) {
return structure;
}
if (typeof structure === 'object') {
return Object.keys(structure).reduce((acc, key) => {
Reflect.set(acc, key, unboxNearMembraneProxies(Reflect.get(structure, key)));
return acc;
}, {});
}
return structure;
}
function isTransferable(structure: unknown): structure is Transferable {
// We should probably add all of the transferable types here.
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
return (
structure instanceof ArrayBuffer ||
structure instanceof OffscreenCanvas ||
structure instanceof ImageBitmap ||
structure instanceof MessagePort ||
structure instanceof MediaSourceHandle ||
structure instanceof ReadableStream ||
structure instanceof WritableStream ||
structure instanceof TransformStream ||
structure instanceof AudioData ||
structure instanceof VideoFrame ||
structure instanceof RTCDataChannel ||
structure instanceof ArrayBuffer
);
}

View File

@@ -33,11 +33,6 @@ const getSummaryColumns = () => [
header: 'Unchanged',
cell: ({ row: { original: item } }: SummaryCell) => item.noop?.toString() || '-',
},
{
id: 'warnings',
header: 'Warnings',
cell: ({ row: { original: item } }: SummaryCell) => item.warning?.toString() || '-',
},
{
id: 'errors',
header: 'Errors',

View File

@@ -1,46 +0,0 @@
import { t } from '@grafana/i18n';
import { useScopes } from '@grafana/runtime';
import { ToolbarButton } from '@grafana/ui';
import { useScopesServices } from '../ScopesContextProvider';
interface Props {
className?: string;
hideWhenOpen?: boolean;
}
export function ContextualNavigationPaneToggle({ className, hideWhenOpen }: Props) {
const scopes = useScopes();
const services = useScopesServices();
if (!scopes || !services) {
return;
}
const { scopesDashboardsService } = services;
const { readOnly, drawerOpened } = scopes.state;
if (hideWhenOpen && drawerOpened) {
return null;
}
const dashboardsIconLabel = readOnly
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
: drawerOpened
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
: t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list');
return (
<div className={className}>
<ToolbarButton
icon="web-section-alt"
aria-label={dashboardsIconLabel}
tooltip={dashboardsIconLabel}
data-testid="scopes-dashboards-expand"
disabled={readOnly}
onClick={scopesDashboardsService.toggleDrawer}
variant={'canvas'}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { useObservable } from 'react-use';
import { Observable } from 'rxjs';
@@ -34,22 +34,22 @@ export function ScopesDashboards() {
if (!loading) {
if (forScopeNames.length === 0) {
return (
<div className={styles.container} data-testid="scopes-dashboards-container">
<ScopesDashboardsTreeSearch disabled={loading} query={searchQuery} onChange={changeSearchQuery} />
<div className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundNoScopes">
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans>
</div>
<div
className={cx(styles.container, styles.noResultsContainer)}
data-testid="scopes-dashboards-notFoundNoScopes"
>
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans>
</div>
);
} else if (dashboards.length === 0 && scopeNavigations.length === 0) {
return (
<div className={styles.container} data-testid="scopes-dashboards-container">
<div className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForScope">
<Trans i18nKey="scopes.dashboards.noResultsForScopes">
No dashboards or links found for the selected scopes
</Trans>
</div>
<div
className={cx(styles.container, styles.noResultsContainer)}
data-testid="scopes-dashboards-notFoundForScope"
>
<Trans i18nKey="scopes.dashboards.noResultsForScopes">
No dashboards or links found for the selected scopes
</Trans>
</div>
);
}
@@ -94,14 +94,13 @@ export function ScopesDashboards() {
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
backgroundColor: theme.colors.background.canvas,
backgroundColor: theme.colors.background.primary,
borderRight: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
flexDirection: 'column',
height: '100%',
gap: theme.spacing(1),
padding: theme.spacing(0, 2),
margin: theme.spacing(2, 0),
padding: theme.spacing(2),
width: theme.spacing(37.5),
}),
noResultsContainer: css({

View File

@@ -887,161 +887,4 @@ describe('ScopesDashboardsService', () => {
expect(service.state.navScopePath).toEqual(['mimir']);
});
});
describe('disableSubScopeSelection', () => {
it('should set disableSubScopeSelection on folder when navigation has it set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
disableSubScopeSelection: true,
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
// Find the folder created for this subScope
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.disableSubScopeSelection).toBe(true);
expect(folder.subScopeName).toBe('subScope1');
}
});
it('should set disableSubScopeSelection to false when navigation has it set to false', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
disableSubScopeSelection: false,
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.disableSubScopeSelection).toBe(false);
expect(folder.subScopeName).toBe('subScope1');
}
});
it('should set disableSubScopeSelection to undefined when navigation does not have it', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.disableSubScopeSelection).toBeUndefined();
expect(folder.subScopeName).toBe('subScope1');
}
});
it('should handle multiple navigations with different disableSubScopeSelection values', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
disableSubScopeSelection: true,
},
status: {
title: 'Disabled Navigation',
},
metadata: {
name: 'nav1',
},
},
{
spec: {
url: '/d/dashboard2',
scope: 'scope1',
subScope: 'subScope2',
disableSubScopeSelection: false,
},
status: {
title: 'Enabled Navigation',
},
metadata: {
name: 'nav2',
},
},
{
spec: {
url: '/d/dashboard3',
scope: 'scope1',
subScope: 'subScope3',
},
status: {
title: 'Default Navigation',
},
metadata: {
name: 'nav3',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folders = service.state.folders[''].folders;
const folder1Key = Object.keys(folders).find((key) => key.includes('subScope1'));
const folder2Key = Object.keys(folders).find((key) => key.includes('subScope2'));
const folder3Key = Object.keys(folders).find((key) => key.includes('subScope3'));
expect(folder1Key).toBeDefined();
expect(folder2Key).toBeDefined();
expect(folder3Key).toBeDefined();
expect(folders[folder1Key!].disableSubScopeSelection).toBe(true);
expect(folders[folder2Key!].disableSubScopeSelection).toBe(false);
expect(folders[folder3Key!].disableSubScopeSelection).toBeUndefined();
});
});
});

View File

@@ -10,7 +10,6 @@ import { ScopesServiceBase } from '../ScopesServiceBase';
import { buildSubScopePath, isCurrentPath } from './scopeNavgiationUtils';
import {
ScopeNavigation,
ScopeNavigationSpec,
SuggestedNavigationsFolder,
SuggestedNavigationsFoldersMap,
SuggestedNavigationsMap,
@@ -387,17 +386,12 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
// All folders with the same subScope will load the same content when expanded
const folderKey = `${subScope}-${navigation.metadata.name}`;
if (!rootNode.folders[folderKey]) {
let disableSubScopeSelection: ScopeNavigationSpec['disableSubScopeSelection'] = undefined;
if ('disableSubScopeSelection' in navigation.spec) {
disableSubScopeSelection = navigation.spec.disableSubScopeSelection;
}
rootNode.folders[folderKey] = {
title: navigationTitle,
expanded,
folders: {},
suggestedNavigations: {},
subScopeName: subScope,
disableSubScopeSelection,
};
}
if (expanded && !rootNode.folders[folderKey].expanded) {

View File

@@ -287,63 +287,4 @@ describe('ScopesDashboardsTreeFolderItem', () => {
// The component checks for scopesSelectorService existence before calling setNavigationScope
expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled();
});
describe('disableSubScopeSelection', () => {
it('does not show exchange icon when disableSubScopeSelection is true', () => {
const folder = createMockFolder({
subScopeName: 'subScope1',
disableSubScopeSelection: true,
});
render(
<ScopesDashboardsTreeFolderItem
folder={folder}
folderPath={['']}
folders={createMockFolders}
onFolderUpdate={mockOnFolderUpdate}
/>
);
const exchangeButtons = screen.queryAllByRole('button', { name: /change root scope/i });
expect(exchangeButtons).toHaveLength(0);
});
it('shows exchange icon when disableSubScopeSelection is false', () => {
const folder = createMockFolder({
subScopeName: 'subScope1',
disableSubScopeSelection: false,
});
render(
<ScopesDashboardsTreeFolderItem
folder={folder}
folderPath={['']}
folders={createMockFolders}
onFolderUpdate={mockOnFolderUpdate}
/>
);
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
expect(exchangeButton).toBeInTheDocument();
});
it('shows exchange icon when disableSubScopeSelection is undefined', () => {
const folder = createMockFolder({
subScopeName: 'subScope1',
disableSubScopeSelection: undefined,
});
render(
<ScopesDashboardsTreeFolderItem
folder={folder}
folderPath={['']}
folders={createMockFolders}
onFolderUpdate={mockOnFolderUpdate}
/>
);
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
expect(exchangeButton).toBeInTheDocument();
});
});
});

View File

@@ -48,7 +48,7 @@ export function ScopesDashboardsTreeFolderItem({
{folder.loading && <Spinner inline size="sm" className={styles.loadingIcon} />}
</button>
{folder.subScopeName && !folder.disableSubScopeSelection && (
{folder.subScopeName && (
<IconButton
className={styles.exchangeIcon}
tooltip={t('scopes.dashboards.exchange', 'Change root scope to {{scope}}', {

View File

@@ -6,8 +6,6 @@ import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { FilterInput, useStyles2 } from '@grafana/ui';
import { ContextualNavigationPaneToggle } from './ContextualNavigationPaneToggle';
export interface ScopesDashboardsTreeSearchProps {
disabled: boolean;
query: string;
@@ -44,7 +42,6 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes
data-testid="scopes-dashboards-search"
onChange={(value) => setInputState({ value, dirty: true })}
/>
<ContextualNavigationPaneToggle />
</div>
);
}
@@ -52,8 +49,6 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
gap: theme.spacing(1),
flex: '0 1 auto',
}),
};

View File

@@ -5,9 +5,6 @@ export interface ScopeNavigationSpec {
url: string;
scope: string;
subScope?: string;
preLoadSubScopeChildren?: boolean;
expandOnLoad?: boolean;
disableSubScopeSelection?: boolean;
}
export interface ScopeNavigationStatus {
@@ -43,7 +40,6 @@ export interface SuggestedNavigationsFolder {
suggestedNavigations: SuggestedNavigationsMap;
subScopeName?: string;
loading?: boolean;
disableSubScopeSelection?: boolean;
}
export type SuggestedNavigationsFoldersMap = Record<string, SuggestedNavigationsFolder>;

View File

@@ -6,7 +6,7 @@ import { Observable } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { useScopes } from '@grafana/runtime';
import { Button, Drawer, ErrorBoundary, ErrorWithStack, Spinner, Text, useStyles2 } from '@grafana/ui';
import { Button, Drawer, ErrorBoundary, ErrorWithStack, IconButton, Spinner, Text, useStyles2 } from '@grafana/ui';
import { getModKey } from 'app/core/utils/browser';
import { useScopesServices } from '../ScopesContextProvider';
@@ -54,8 +54,8 @@ export const ScopesSelector = () => {
tree,
scopes: scopesMap,
} = selectorServiceState;
const { scopesService, scopesSelectorService } = services;
const { readOnly, loading } = scopes.state;
const { scopesService, scopesSelectorService, scopesDashboardsService } = services;
const { readOnly, drawerOpened, loading } = scopes.state;
const {
open,
removeAllScopes,
@@ -70,8 +70,24 @@ export const ScopesSelector = () => {
const recentScopes = getRecentScopes();
const dashboardsIconLabel = readOnly
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
: drawerOpened
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
: t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list');
return (
<>
<IconButton
name="web-section-alt"
className={styles.dashboards}
aria-label={dashboardsIconLabel}
tooltip={dashboardsIconLabel}
data-testid="scopes-dashboards-expand"
disabled={readOnly}
onClick={scopesDashboardsService.toggleDrawer}
/>
<ScopesInput
nodes={nodes}
scopes={scopesMap}

View File

@@ -26,6 +26,7 @@ import {
expectNoDashboardsForFilter,
expectNoDashboardsForScope,
expectNoDashboardsNoScopes,
expectNoDashboardsSearch,
} from './utils/assertions';
import {
alternativeDashboardWithRootFolder,
@@ -303,12 +304,14 @@ describe('Dashboards list', () => {
it('Shows a proper message when no scopes are selected', async () => {
await toggleDashboards();
expectNoDashboardsNoScopes();
expectNoDashboardsSearch();
});
it('Does not show the input when there are no dashboards found for scope', async () => {
await updateScopes(scopesService, ['cloud']);
await toggleDashboards();
expectNoDashboardsForScope();
expectNoDashboardsSearch();
});
it('Shows the input and a message when there are no dashboards found for filter', async () => {

View File

@@ -190,7 +190,9 @@ export default class TempoLanguageProvider extends LanguageProvider {
* @returns the encoded tag
*/
private encodeTag = (tag: string): string => {
return encodeURIComponent(tag);
// If we call `encodeURIComponent` only once, we still get an error when issuing a request to the backend
// Reference: https://stackoverflow.com/a/37456192
return encodeURIComponent(encodeURIComponent(tag));
};
generateQueryFromFilters({

View File

@@ -911,7 +911,7 @@ const traceSubFrame = (
subFrame.add(transformSpanToTraceData(span, spanSet, trace));
});
return toDataFrame(subFrame);
return subFrame;
};
interface TraceTableData {

View File

@@ -3739,10 +3739,6 @@
"clear": "Vymazat vyhledávání a filtry",
"text": "Nebyly nalezeny žádné výsledky pro váš dotaz"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5996,25 +5992,13 @@
"title-error-loading-dashboard": "Chyba při načítání nástěnky"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Upravit panel",
"view-panel": "Zobrazit panel"
},
"title": {
"dashboard": "Nástěnka",
"discard-changes-to-dashboard": "Zahodit změny nástěnky?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Zahodit změny nástěnky?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10814,6 +10798,7 @@
"title": "Nové"
},
"new-dashboard": {
"empty-title": "",
"title": "Nová nástěnka"
},
"new-folder": {
@@ -11973,6 +11958,7 @@
"title-setting-connection-could-cause-temporary-outage": "Nastavení tohoto připojení může způsobit dočasný výpadek"
},
"getting-started-page": {
"header": "Zajišťování",
"subtitle-provisioning-feature": "Zobrazujte a spravujte vazby zajištění"
},
"git": {
@@ -12744,6 +12730,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importovat",
"new": "Nové",
"new-dashboard": "Nová nástěnka",

View File

@@ -3707,10 +3707,6 @@
"clear": "Suche und Filter löschen",
"text": "Keine Ergebnisse für deine Abfrage gefunden"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Fehler beim Laden des Dashboards"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Panel bearbeiten",
"view-panel": "Panel anzeigen"
},
"title": {
"dashboard": "Dashboard",
"discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Neu"
},
"new-dashboard": {
"empty-title": "",
"title": "Neues Dashboard"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Das Einrichten dieser Verbindung kann zu einem vorübergehenden Ausfall führen"
},
"getting-started-page": {
"header": "Bereitstellung",
"subtitle-provisioning-feature": "Sehen und verwalten Sie Ihre Bereitstellungsverbindungen"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importieren",
"new": "Neu",
"new-dashboard": "Neues Dashboard",

View File

@@ -5133,7 +5133,6 @@
"empty-state-message": "Run a query to visualize it here or go to all visualizations to add other panel types",
"menu-open-panel-editor": "Configure",
"menu-use-library-panel": "Use library panel",
"missing-config": "Missing panel configuration",
"suggestions": {
"empty-state-message": "Run a query to start seeing suggested visualizations"
}
@@ -6146,10 +6145,7 @@
"no-data-found": "No data found"
},
"inspect-json-tab": {
"apply": "Apply",
"error-invalid-json": "Invalid JSON",
"error-invalid-v2-panel": "Panel JSON did not pass validation. Please check the JSON and try again.",
"validation-error": "Validation error"
"apply": "Apply"
},
"interval-variable-form": {
"description-auto-option": "Dynamically calculates interval by dividing time range by the count specified",

View File

@@ -3707,10 +3707,6 @@
"clear": "Borrar la búsqueda y los filtros",
"text": "No se han encontrado resultados para tu consulta"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Error al cargar el panel de control"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Editar panel",
"view-panel": "Ver panel"
},
"title": {
"dashboard": "Panel de control",
"discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Nuevo"
},
"new-dashboard": {
"empty-title": "",
"title": "Nuevo panel de control"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Configurar esta conexión podría causar una interrupción temporal"
},
"getting-started-page": {
"header": "Aprovisionamiento",
"subtitle-provisioning-feature": "Ver y gestionar tus conexiones de aprovisionamiento"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importar",
"new": "Nuevo",
"new-dashboard": "Nuevo panel de control",

View File

@@ -3707,10 +3707,6 @@
"clear": "Effacer la recherche et les filtres",
"text": "Aucun résultat n'a été trouvé pour votre requête"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Erreur lors du chargement du tableau de bord"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Modifier le panneau",
"view-panel": "Afficher le panneau"
},
"title": {
"dashboard": "Tableau de bord",
"discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Nouveau"
},
"new-dashboard": {
"empty-title": "",
"title": "Nouveau tableau de bord"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "La configuration de cette connexion peut entraîner une interruption temporaire"
},
"getting-started-page": {
"header": "Mise en service",
"subtitle-provisioning-feature": "Afficher et gérer vos connexions de mise en service"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importer",
"new": "Nouveau",
"new-dashboard": "Nouveau tableau de bord",

View File

@@ -3707,10 +3707,6 @@
"clear": "Keresés és szűrők törlése",
"text": "Nincs találat a lekérdezésre"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Hiba történt az irányítópult betöltésekor"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Panel szerkesztése",
"view-panel": "Panel megtekintése"
},
"title": {
"dashboard": "Irányítópult",
"discard-changes-to-dashboard": "Elveti az irányítópult módosításait?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Elveti az irányítópult módosításait?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Új"
},
"new-dashboard": {
"empty-title": "",
"title": "Új irányítópult"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "A kapcsolat létrehozása ideiglenes üzemszünetet okozhat"
},
"getting-started-page": {
"header": "Kiépítés",
"subtitle-provisioning-feature": "Kiépítési kapcsolatok megtekintése és kezelése"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importálás",
"new": "Új",
"new-dashboard": "Új irányítópult",

View File

@@ -3691,10 +3691,6 @@
"clear": "Hapus pencarian dan filter",
"text": "Hasil untuk kueri Anda tidak ditemukan"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5933,25 +5929,13 @@
"title-error-loading-dashboard": "Kesalahan saat memuat dasbor"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Edit panel",
"view-panel": "Lihat panel"
},
"title": {
"dashboard": "Dasbor",
"discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10685,6 +10669,7 @@
"title": "Baru"
},
"new-dashboard": {
"empty-title": "",
"title": "Dasbor baru"
},
"new-folder": {
@@ -11820,6 +11805,7 @@
"title-setting-connection-could-cause-temporary-outage": "Mengatur koneksi ini dapat menyebabkan pemadaman sementara"
},
"getting-started-page": {
"header": "Penyediaan",
"subtitle-provisioning-feature": "Lihat dan kelola koneksi penyediaan Anda"
},
"git": {
@@ -12582,6 +12568,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Impor",
"new": "Baru",
"new-dashboard": "Dasbor baru",

View File

@@ -3707,10 +3707,6 @@
"clear": "Cancella ricerca e filtri",
"text": "Nessun risultato trovato per la ricerca"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Errore durante il caricamento del dashboard"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Modifica pannello",
"view-panel": "Visualizza pannello"
},
"title": {
"dashboard": "Dashboard",
"discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Nuovo"
},
"new-dashboard": {
"empty-title": "",
"title": "Nuovo dashboard"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "La configurazione di questa connessione potrebbe causare un'interruzione temporanea"
},
"getting-started-page": {
"header": "Provisioning",
"subtitle-provisioning-feature": "Visualizza e gestisci le connessioni di provisioning"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importa",
"new": "Nuovo",
"new-dashboard": "Nuovo dashboard",

View File

@@ -3691,10 +3691,6 @@
"clear": "検索とフィルタをクリア",
"text": "クエリに一致する結果が見つかりませんでした。"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5933,25 +5929,13 @@
"title-error-loading-dashboard": "ダッシュボードの読み込み中にエラーが発生しました"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "パネルを編集",
"view-panel": "パネルを表示"
},
"title": {
"dashboard": "ダッシュボード",
"discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10685,6 +10669,7 @@
"title": "新規"
},
"new-dashboard": {
"empty-title": "",
"title": "新しいダッシュボード"
},
"new-folder": {
@@ -11820,6 +11805,7 @@
"title-setting-connection-could-cause-temporary-outage": "この接続設定を行うことで、一時的に停止する可能性があります"
},
"getting-started-page": {
"header": "プロビジョニング",
"subtitle-provisioning-feature": "プロビジョニング接続を表示・管理"
},
"git": {
@@ -12582,6 +12568,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "インポート",
"new": "新規",
"new-dashboard": "新しいダッシュボード",

View File

@@ -3691,10 +3691,6 @@
"clear": "검색 및 필터 초기화",
"text": "쿼리에 대해 찾은 결과 없음"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5933,25 +5929,13 @@
"title-error-loading-dashboard": "대시보드 로딩 중 오류 발생"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "패널 편집",
"view-panel": "패널 보기"
},
"title": {
"dashboard": "대시보드",
"discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10685,6 +10669,7 @@
"title": "신규"
},
"new-dashboard": {
"empty-title": "",
"title": "새 대시보드"
},
"new-folder": {
@@ -11820,6 +11805,7 @@
"title-setting-connection-could-cause-temporary-outage": "이 연결을 설정하면 일시적인 중단이 발생할 수 있습니다"
},
"getting-started-page": {
"header": "프로비저닝",
"subtitle-provisioning-feature": "프로비저닝 연결 보기 및 관리"
},
"git": {
@@ -12582,6 +12568,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "가져오기",
"new": "신규",
"new-dashboard": "새 대시보드",

View File

@@ -3707,10 +3707,6 @@
"clear": "Zoekopdracht en filters wissen",
"text": "Geen resultaten gevonden voor je zoekopdracht"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Er is een fout opgetreden bij het laden van het dashboard"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Paneel bewerken",
"view-panel": "Paneel bekijken"
},
"title": {
"dashboard": "Dashboard",
"discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Nieuw"
},
"new-dashboard": {
"empty-title": "",
"title": "Nieuw dashboard"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Het opzetten van deze verbinding kan een tijdelijke storing veroorzaken"
},
"getting-started-page": {
"header": "Provisioning",
"subtitle-provisioning-feature": "Je provisioningverbindingen bekijken en beheren"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importeren",
"new": "Nieuw",
"new-dashboard": "Nieuw dashboard",

View File

@@ -3739,10 +3739,6 @@
"clear": "Wyczyść wyszukiwanie i filtry",
"text": "Nie znaleziono wyników dla tego zapytania"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5996,25 +5992,13 @@
"title-error-loading-dashboard": "Błąd wczytywania pulpitu"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Edytuj panel",
"view-panel": "Wyświetl panel"
},
"title": {
"dashboard": "Pulpit",
"discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10814,6 +10798,7 @@
"title": "Nowy"
},
"new-dashboard": {
"empty-title": "",
"title": "Nowy pulpit"
},
"new-folder": {
@@ -11973,6 +11958,7 @@
"title-setting-connection-could-cause-temporary-outage": "Skonfigurowanie tego połączenia może spowodować tymczasową niedostępność"
},
"getting-started-page": {
"header": "Konfiguracja",
"subtitle-provisioning-feature": "Wyświetlaj połączenia aprowizacyjne i nimi zarządzaj"
},
"git": {
@@ -12744,6 +12730,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importuj",
"new": "Nowy",
"new-dashboard": "Nowy pulpit",

View File

@@ -3707,10 +3707,6 @@
"clear": "Limpar busca e filtros",
"text": "Nenhum resultado encontrado para sua consulta"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Erro ao carregar o painel de controle"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Editar painel",
"view-panel": "Visualizar painel"
},
"title": {
"dashboard": "Painel de controle",
"discard-changes-to-dashboard": "Deseja descartar as alterações no painel?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Deseja descartar as alterações no painel?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Novo"
},
"new-dashboard": {
"empty-title": "",
"title": "Novo painel de controle"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Estabelecer esta conexão pode causar uma interrupção temporária"
},
"getting-started-page": {
"header": "Aprovisionamento",
"subtitle-provisioning-feature": "Visualize e gerencie suas conexões de provisionamento"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importar",
"new": "Novo",
"new-dashboard": "Novo painel de controle",

View File

@@ -3707,10 +3707,6 @@
"clear": "Limpar a pesquisa e os filtros",
"text": "Não foram encontrados resultados para a sua consulta"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Erro ao carregar o painel de controlo"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Editar painel",
"view-panel": "Visualizar painel"
},
"title": {
"dashboard": "Painel de controlo",
"discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Novo"
},
"new-dashboard": {
"empty-title": "",
"title": "Novo painel de controlo"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Configurar esta ligação pode causar uma interrupção temporária"
},
"getting-started-page": {
"header": "Provisionamento",
"subtitle-provisioning-feature": "Ver e gerir as suas ligações de provisionamento"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importar",
"new": "Novo",
"new-dashboard": "Novo painel de controlo",

View File

@@ -3739,10 +3739,6 @@
"clear": "Очистить поиск и фильтры",
"text": "По вашему запросу ничего не найдено"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5996,25 +5992,13 @@
"title-error-loading-dashboard": "Ошибка при загрузке дашборда"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Редактировать панель",
"view-panel": "Просмотр панели"
},
"title": {
"dashboard": "Дашборд",
"discard-changes-to-dashboard": "Отменить изменения на дашборде?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Отменить изменения на дашборде?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10814,6 +10798,7 @@
"title": "Новые элементы"
},
"new-dashboard": {
"empty-title": "",
"title": "Новый дашборд"
},
"new-folder": {
@@ -11973,6 +11958,7 @@
"title-setting-connection-could-cause-temporary-outage": "Настройка этого подключения может привести к временному сбою"
},
"getting-started-page": {
"header": "Подготовка к работе",
"subtitle-provisioning-feature": "Просмотр подключений для подготовки и управлением ими"
},
"git": {
@@ -12744,6 +12730,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Импорт",
"new": "Новые элементы",
"new-dashboard": "Новый дашборд",

View File

@@ -3707,10 +3707,6 @@
"clear": "Rensa sökning och filter",
"text": "Inga resultat hittades för din fråga"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Fel vid laddning av instrumentpanel"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Redigera panel",
"view-panel": "Visa panel"
},
"title": {
"dashboard": "Instrumentpanel",
"discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Nyhet"
},
"new-dashboard": {
"empty-title": "",
"title": "Ny instrumentpanel"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Konfiguration av den här anslutningen kan orsaka ett tillfälligt avbrott"
},
"getting-started-page": {
"header": "Provisionering",
"subtitle-provisioning-feature": "Visa och hantera dina provisioneringsanslutningar"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importera",
"new": "Nyhet",
"new-dashboard": "Ny instrumentpanel",

View File

@@ -3707,10 +3707,6 @@
"clear": "Aramayı ve filtreleri temizle",
"text": "Sorgunuz için sonuç bulunamadı"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5954,25 +5950,13 @@
"title-error-loading-dashboard": "Pano yüklenirken hata oluştu"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Paneli düzenle",
"view-panel": "Paneli görüntüle"
},
"title": {
"dashboard": "Pano",
"discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10728,6 +10712,7 @@
"title": "Yeni"
},
"new-dashboard": {
"empty-title": "",
"title": "Yeni pano"
},
"new-folder": {
@@ -11871,6 +11856,7 @@
"title-setting-connection-could-cause-temporary-outage": "Bu bağlantıyı kurmak geçici bir kesintiye neden olabilir"
},
"getting-started-page": {
"header": "Sağlama",
"subtitle-provisioning-feature": "Sağlama bağlantılarınızı görüntüleyin ve yönetin"
},
"git": {
@@ -12636,6 +12622,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "İçe aktar",
"new": "Yeni",
"new-dashboard": "Yeni pano",

View File

@@ -3691,10 +3691,6 @@
"clear": "清除搜索和筛选条件",
"text": "未找到与您的查询相关的结果"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5933,25 +5929,13 @@
"title-error-loading-dashboard": "加载数据面板时出错"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "编辑面板",
"view-panel": "查看面板"
},
"title": {
"dashboard": "仪表板",
"discard-changes-to-dashboard": "放弃对数据面板的更改?",
"unsaved-changes-question": ""
"discard-changes-to-dashboard": "放弃对数据面板的更改?"
}
},
"dashboard-scene-page-state-manager": {
@@ -10685,6 +10669,7 @@
"title": "新建"
},
"new-dashboard": {
"empty-title": "",
"title": "新建仪表板"
},
"new-folder": {
@@ -11820,6 +11805,7 @@
"title-setting-connection-could-cause-temporary-outage": "设置此连接可能会导致暂时中断"
},
"getting-started-page": {
"header": "配置",
"subtitle-provisioning-feature": "查看和管理您的预配连接"
},
"git": {
@@ -12582,6 +12568,7 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "导入",
"new": "新建",
"new-dashboard": "新建仪表板",

Some files were not shown because too many files have changed in this diff Show More