Compare commits

..

12 Commits

Author SHA1 Message Date
Hugo Häggmark 6024fbb363 Merge remote-tracking branch 'origin/main' into hugoh/decouple-app-plugins 2025-12-15 10:23:39 +01:00
Hugo Häggmark 512f4bc8dc chore: more refactor 2025-12-15 10:13:26 +01:00
Hugo Häggmark 88924ee9ac Merge remote-tracking branch 'origin/main' into hugoh/decouple-app-plugins 2025-12-12 13:55:00 +01:00
Hugo Häggmark 5e3c7ad0c1 chore: refactoring to async 2025-12-12 12:10:28 +01:00
Hugo Häggmark 75e08a20f6 chore: move exports to unstable 2025-12-10 14:33:54 +01:00
Hugo Häggmark c8908c5100 chore: remove console again 2025-12-08 06:55:10 +01:00
Hugo Häggmark d8106adb63 chore: add back console.error 2025-12-08 06:08:59 +01:00
Hugo Häggmark a4c1b51182 chore: updates after pr feedback 2025-12-08 06:08:59 +01:00
Hugo Häggmark 535c9be2f7 chore: remove console error for now 2025-12-08 06:08:58 +01:00
Hugo Häggmark 49f891a24d chore: updates after pr feedback 2025-12-08 06:08:58 +01:00
Hugo Häggmark 86018141d0 chore: updates after pr feedback 2025-12-08 06:08:57 +01:00
Hugo Häggmark 7fd2476a12 bootdata: decouples config.apps 2025-12-08 06:08:57 +01:00
116 changed files with 3555 additions and 7392 deletions
+1
View File
@@ -653,6 +653,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/components/QueryEditorWithMigration* @grafana/plugins-platform-frontend @grafana/plugins-platform-backend
/packages/grafana-runtime/src/config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/services/ @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/services/plugins.ts @grafana/plugins-platform-frontend
/packages/grafana-runtime/src/services/pluginExtensions @grafana/plugins-platform-frontend
/packages/grafana-runtime/src/services/CorrelationsService.ts @grafana/datapro
/packages/grafana-runtime/src/services/LocationService.test.tsx @grafana/grafana-search-navigate-organise
@@ -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.
-21
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"
@@ -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
}
}
}
}
@@ -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"
}
}
}
@@ -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"
}
}
}
@@ -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"
}
}
}
@@ -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)
}
@@ -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.
@@ -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"},
},
-1
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
+17 -21
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
@@ -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
@@ -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
@@ -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?
@@ -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.
@@ -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:
+5
View File
@@ -1830,6 +1830,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
+36
View File
@@ -575,6 +575,42 @@ module.exports = [
"Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']",
message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.',
},
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas() from @grafana/runtime instead',
},
],
},
},
{
files: [...commonTestIgnores],
ignores: [
// FIXME: Remove once all enterprise issues are fixed -
// we don't have a suppressions file/approach for enterprise code yet
...enterpriseIgnores,
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas() from @grafana/runtime instead',
},
],
},
},
{
files: [...enterpriseIgnores],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas() from @grafana/runtime instead',
},
],
},
},
-3
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=
@@ -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,
+1
View File
@@ -86,6 +86,7 @@ export class GrafanaBootConfig {
snapshotEnabled = true;
datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {};
/** @deprecated it will be removed in a future release, use getAppPluginMetas function or useAppPluginMetas hook instead */
apps: Record<string, AppPluginConfigGrafanaData> = {};
auth: AuthSettings = {};
minRefreshInterval = '';
@@ -29,3 +29,4 @@ export {
export { UserStorage } from '../utils/userStorage';
export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
export { setAppPluginMetas } from '../services/plugins';
@@ -0,0 +1,90 @@
import { cloneDeep } from 'lodash';
import { useAsync } from 'react-use';
import { AppPluginConfig } from '@grafana/data';
import { config } from '../config';
export type AppPluginMetas = Record<string, AppPluginConfig>;
let apps: AppPluginMetas = {};
let appsPromise: Promise<void> | undefined = undefined;
function areAppsInitialized(): boolean {
return Boolean(Object.keys(apps).length);
}
async function initPluginMetas(): Promise<void> {
if (appsPromise) {
return appsPromise;
}
appsPromise = new Promise((resolve) => {
if (config.featureToggles.useMTPlugins) {
// add loading app configs from MT API here
apps = {};
resolve();
return;
}
// eslint-disable-next-line no-restricted-syntax
apps = config.apps;
resolve();
return;
});
return appsPromise;
}
export async function getAppPluginMetas(): Promise<AppPluginConfig[]> {
if (!areAppsInitialized()) {
await initPluginMetas();
}
return Object.values(cloneDeep(apps));
}
export async function getAppPluginMeta(id: string): Promise<AppPluginConfig | undefined> {
if (!areAppsInitialized()) {
await initPluginMetas();
}
if (!apps[id]) {
return undefined;
}
return cloneDeep(apps[id]);
}
export function setAppPluginMetas(override: AppPluginMetas) {
// We allow overriding apps in tests
if (override && process.env.NODE_ENV !== 'test') {
throw new Error('setAppPluginMetas() function can only be called from tests.');
}
apps = { ...override };
}
export interface UseAppPluginMetasResult {
isAppPluginMetasLoading: boolean;
error: Error | undefined;
apps: AppPluginConfig[];
}
export function useAppPluginMetas(filterByIds: string[] = []): UseAppPluginMetasResult {
const { loading, error, value: apps = [] } = useAsync(getAppPluginMetas);
const filtered = apps.filter((app) => filterByIds.includes(app.id));
return { isAppPluginMetasLoading: loading, error, apps: filtered };
}
export interface UseAppPluginMetaResult {
isAppPluginMetaLoading: boolean;
error: Error | undefined;
app: AppPluginConfig | undefined;
}
export function useAppPluginMeta(filterById: string): UseAppPluginMetaResult {
const { loading, error, value: app } = useAsync(() => getAppPluginMeta(filterById));
return { isAppPluginMetaLoading: loading, error, app };
}
+10
View File
@@ -11,3 +11,13 @@
// This is a dummy export so typescript doesn't error importing an "empty module"
export const unstable = {};
export {
type AppPluginMetas,
type UseAppPluginMetaResult,
type UseAppPluginMetasResult,
getAppPluginMeta,
getAppPluginMetas,
useAppPluginMeta,
useAppPluginMetas,
} from './services/plugins';
@@ -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);
@@ -384,7 +384,6 @@ export function PanelChrome({
menu={menu}
title={typeof title === 'string' ? title : undefined}
dragClass={dragClass}
onDragStart={onDragStart}
offset={hoverHeaderOffset}
onOpenMenu={onOpenMenu}
>
+1 -3
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
@@ -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,
+2 -4
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,
)
+1 -2
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"),
@@ -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)
@@ -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
}
@@ -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
}
@@ -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)
})
-1
View File
@@ -618,7 +618,6 @@ type Cfg struct {
EnableSearch bool
OverridesFilePath string
OverridesReloadInterval time.Duration
EnableSQLKVBackend bool
// Secrets Management
SecretsManagement SecretsManagerSettings
-3
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("")
}
+46 -136
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",
+24 -86
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{
+17 -67
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{
-70
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!")
}
@@ -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
+14 -33
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
+1 -7
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)
})
-34
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,
},
})
}
@@ -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,
},
})
}
+158 -262
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.
@@ -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")
+1 -9
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)
}
+1 -2
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"
+3 -7
View File
@@ -99,10 +99,9 @@ import { usePluginComponent } from './features/plugins/extensions/usePluginCompo
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { initSystemJSHooks } from './features/plugins/loader/systemjsHooks';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { preloadPluginsToBeAwaited, preloadPluginsToBePreloaded } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
import { runRequest } from './features/query/state/runRequest';
import { initWindowRuntime } from './features/runtime/init';
@@ -257,11 +256,8 @@ export class GrafanaApp {
const skipAppPluginsPreload =
config.featureToggles.rendererDisableAppPluginsPreload && contextSrv.user.authenticatedBy === 'render';
if (contextSrv.user.orgRole !== '' && !skipAppPluginsPreload) {
const appPluginsToAwait = getAppPluginsToAwait();
const appPluginsToPreload = getAppPluginsToPreload();
preloadPlugins(appPluginsToPreload);
await preloadPlugins(appPluginsToAwait);
preloadPluginsToBePreloaded();
await preloadPluginsToBeAwaited();
}
setHelpNavItemHook(useHelpNode);
@@ -3,6 +3,7 @@ import { useLocalStorage } from 'react-use';
import { PluginExtensionPoints, store } from '@grafana/data';
import { getAppEvents, reportInteraction, usePluginLinks, locationService } from '@grafana/runtime';
import { useAppPluginMetas } from '@grafana/runtime/unstable';
import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events';
@@ -90,19 +91,21 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
// that means, a plugin would need to register both, a link and a component to
// `grafana/extension-sidebar/v0-alpha` and the link's `configure` method would control
// whether the component is rendered or not
const { links, isLoading } = usePluginLinks({
const { links, isLoading: isPluginLinksLoading } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExtensionSidebar,
context: {
path: currentPath,
},
});
const { apps, isAppPluginMetasLoading: isAppPluginConfigsLoading } = useAppPluginMetas();
const isLoading = isPluginLinksLoading || isAppPluginConfigsLoading;
// get all components for this extension point, but only for the permitted plugins
// if the extension sidebar is not enabled, we will return an empty map
const availableComponents = useMemo(
() =>
new Map(
Array.from(getExtensionPointPluginMeta(PluginExtensionPoints.ExtensionSidebar).entries()).filter(
Array.from(getExtensionPointPluginMeta(apps, PluginExtensionPoints.ExtensionSidebar).entries()).filter(
([pluginId, pluginMeta]) =>
PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(pluginId) &&
links.some(
@@ -112,7 +115,7 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
)
)
),
[links]
[links, apps]
);
// check if the stored docked component is still available
@@ -7,6 +7,7 @@ import { getProxyApiUrl } from './onCallApi';
describe('getProxyApiUrl', () => {
it('should return URL with IRM plugin ID when IRM plugin is present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
@@ -15,6 +16,7 @@ describe('getProxyApiUrl', () => {
});
it('should return URL with OnCall plugin ID when IRM plugin is not present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -67,6 +67,7 @@ describe('filterRulerRulesConfig', () => {
};
it('should filter by namespace', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Slo]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]) };
const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig, 'namespace1');
@@ -214,6 +214,7 @@ export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
export const removePlugin = (pluginId: string) => {
// eslint-disable-next-line no-restricted-syntax
delete config.apps[pluginId];
server.use(getPluginMissingHandler(pluginId));
};
@@ -12,6 +12,7 @@ const PLUGIN_NOT_FOUND_RESPONSE = { message: 'Plugin not found, no installed plu
*/
export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
plugins.forEach(({ id, baseUrl, info, angular }) => {
// eslint-disable-next-line no-restricted-syntax
config.apps[id] = {
id,
path: baseUrl,
@@ -137,6 +137,7 @@ describe('cloneRuleDefinition', () => {
it('Should remove the origin label when cloning data source plugin-provided rules', () => {
// Mock the plugin as installed
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.Slo]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]),
};
@@ -174,6 +175,7 @@ describe('cloneRuleDefinition', () => {
});
it('Should remove the origin label when cloning Grafana-managed plugin-provided rules', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.Slo]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]),
};
@@ -62,11 +62,13 @@ describe('checkEvaluationIntervalGlobalLimit', () => {
describe('getIsIrmPluginPresent', () => {
it('should return true when IRM plugin is present in config.apps', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getIsIrmPluginPresent()).toBe(true);
});
it('should return false when IRM plugin is not present in config.apps', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -77,11 +79,13 @@ describe('getIsIrmPluginPresent', () => {
describe('getIrmIfPresentOrIncidentPluginId', () => {
it('should return IRM plugin ID when IRM plugin is present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Irm);
});
it('should return Incident plugin ID when IRM plugin is not present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -92,11 +96,13 @@ describe('getIrmIfPresentOrIncidentPluginId', () => {
describe('getIrmIfPresentOrOnCallPluginId', () => {
it('should return IRM plugin ID when IRM plugin is present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.Irm);
});
it('should return OnCall plugin ID when IRM plugin is not present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -30,6 +30,7 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
}
export function getIsIrmPluginPresent() {
// eslint-disable-next-line no-restricted-syntax
return SupportedPlugin.Irm in config.apps;
}
@@ -42,6 +42,7 @@ describe('getRuleOrigin', () => {
});
it('returns pluginId when origin label matches expected format and plugin is installed', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
installed_plugin: {
id: 'installed_plugin',
@@ -273,6 +273,7 @@ export function getRulePluginOrigin(rule?: Rule | PromRuleDTO | RulerRuleDTO): R
}
function isPluginInstalled(pluginId: string) {
// eslint-disable-next-line no-restricted-syntax
return Boolean(config.apps[pluginId]);
}
@@ -3,12 +3,14 @@ import userEvent from '@testing-library/user-event';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { contextSrv } from 'app/core/services/context_srv';
import { AdvisorRedirectNotice } from './AdvisorRedirectNotice';
const originalFeatureToggleValue = config.featureToggles.grafanaAdvisor;
jest.mock('@grafana/runtime/internal', () => ({
...jest.requireActual('@grafana/runtime/internal'),
UserStorage: jest.fn().mockImplementation(() => ({
getItem: jest.fn().mockResolvedValue('true'),
setItem: jest.fn().mockResolvedValue(undefined),
@@ -24,27 +26,29 @@ describe('AdvisorRedirectNotice', () => {
afterEach(() => {
jest.clearAllMocks();
config.featureToggles.grafanaAdvisor = originalFeatureToggleValue;
config.apps['grafana-advisor-app'] = {
id: 'grafana-advisor-app',
path: '/a/grafana-advisor-app',
version: '1.0.0',
preload: false,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaDependency: '*',
grafanaVersion: '*',
plugins: [],
extensions: { exposedComponents: [] },
setAppPluginMetas({
'grafana-advisor-app': {
id: 'grafana-advisor-app',
path: '/a/grafana-advisor-app',
version: '1.0.0',
preload: false,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaDependency: '*',
grafanaVersion: '*',
plugins: [],
extensions: { exposedComponents: [] },
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
});
});
it('should not render when user is not admin', async () => {
@@ -60,7 +64,7 @@ describe('AdvisorRedirectNotice', () => {
});
it('should not render when app is not installed', async () => {
delete config.apps['grafana-advisor-app'];
setAppPluginMetas({});
render(<AdvisorRedirectNotice />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
@@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { UserStorage } from '@grafana/runtime/internal';
import { useAppPluginMeta } from '@grafana/runtime/unstable';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
@@ -27,8 +28,9 @@ export function AdvisorRedirectNotice() {
const styles = useStyles2(getStyles);
const hasAdminRights = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
const [showNotice, setShowNotice] = useState(false);
const { app } = useAppPluginMeta('grafana-advisor-app');
const canUseAdvisor = hasAdminRights && config.featureToggles.grafanaAdvisor && !!config.apps['grafana-advisor-app'];
const canUseAdvisor = hasAdminRights && config.featureToggles.grafanaAdvisor && !!app;
useEffect(() => {
if (canUseAdvisor) {
@@ -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 />
</>
)}
@@ -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}
@@ -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);
});
});
@@ -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 }) => (
@@ -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),
}),
};
}
@@ -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>
);
@@ -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,
};
}
@@ -238,7 +238,6 @@ function getStyles(theme: GrafanaTheme2) {
}),
dragging: css({
cursor: 'move',
backgroundColor: theme.colors.background.canvas,
}),
wrapperGrow: css({
flexGrow: 1,
@@ -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,
};
}
@@ -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>
@@ -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);
});
});
@@ -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');
}
}
@@ -17,14 +17,9 @@ jest.mock('@grafana/llm', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
apps: {
'grafana-llm-app': true,
},
},
jest.mock('@grafana/runtime/unstable', () => ({
...jest.requireActual('@grafana/runtime/unstable'),
getAppPluginMeta: () => Promise.resolve({}),
}));
describe('getDashboardChanges', () => {
@@ -1,7 +1,7 @@
import { pick } from 'lodash';
import { llm } from '@grafana/llm';
import { config } from '@grafana/runtime';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { Panel } from '@grafana/schema';
import { DashboardModel } from '../../state/DashboardModel';
@@ -70,7 +70,8 @@ let llmHealthCheck: Promise<boolean> | undefined;
* @returns true if the LLM plugin is enabled.
*/
export async function isLLMPluginEnabled(): Promise<boolean> {
if (!config.apps['grafana-llm-app']) {
const app = await getAppPluginMeta('grafana-llm-app');
if (!app) {
return false;
}
@@ -2,7 +2,8 @@ import React from 'react';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -30,7 +31,6 @@ jest.mock('../logs/log', () => {
});
describe('AddedComponentsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -61,13 +61,11 @@ describe('AddedComponentsRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no extensions registered', async () => {
@@ -450,7 +448,11 @@ describe('AddedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -499,7 +501,11 @@ describe('AddedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -525,7 +531,11 @@ describe('AddedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [componentConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [componentConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -30,10 +30,10 @@ export class AddedComponentsRegistry extends Registry<
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<AddedComponentRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedComponentConfig>
): RegistryType<AddedComponentRegistryItem[]> {
): Promise<RegistryType<AddedComponentRegistryItem[]>> {
const { pluginId, configs } = item;
for (const config of configs) {
@@ -51,7 +51,7 @@ export class AddedComponentsRegistry extends Registry<
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isAddedComponentMetaInfoMissing(pluginId, config, configLog)
(await isAddedComponentMetaInfoMissing(pluginId, config, configLog))
) {
continue;
}
@@ -1,7 +1,8 @@
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -29,7 +30,6 @@ jest.mock('../logs/log', () => {
});
describe('addedFunctionsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -60,13 +60,11 @@ describe('addedFunctionsRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no extensions registered', async () => {
@@ -642,7 +640,11 @@ describe('addedFunctionsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -691,7 +693,11 @@ describe('addedFunctionsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -717,7 +723,11 @@ describe('addedFunctionsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [fnConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [fnConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -28,11 +28,12 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<AddedFunctionsRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedFunctionConfig>
): RegistryType<AddedFunctionsRegistryItem[]> {
): Promise<RegistryType<AddedFunctionsRegistryItem[]>> {
const { pluginId, configs } = item;
for (const config of configs) {
const configLog = this.logger.child({
title: config.title,
@@ -49,7 +50,11 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) {
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
(await isAddedFunctionMetaInfoMissing(pluginId, config, configLog))
) {
continue;
}
@@ -1,7 +1,8 @@
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -29,7 +30,6 @@ jest.mock('../logs/log', () => {
});
describe('AddedLinksRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -60,13 +60,11 @@ describe('AddedLinksRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no extensions registered', async () => {
@@ -626,7 +624,11 @@ describe('AddedLinksRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -677,7 +679,11 @@ describe('AddedLinksRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -704,7 +710,11 @@ describe('AddedLinksRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [linkConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [linkConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -34,10 +34,10 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<AddedLinkRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedLinkConfig>
): RegistryType<AddedLinkRegistryItem[]> {
): Promise<RegistryType<AddedLinkRegistryItem[]>> {
const { pluginId, configs } = item;
for (const config of configs) {
@@ -66,7 +66,11 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config, configLog)) {
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
(await isAddedLinkMetaInfoMissing(pluginId, config, configLog))
) {
continue;
}
@@ -2,7 +2,8 @@ import React from 'react';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -30,7 +31,6 @@ jest.mock('../logs/log', () => {
});
describe('ExposedComponentsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -61,13 +61,11 @@ describe('ExposedComponentsRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no exposed components have been registered', async () => {
@@ -423,7 +421,11 @@ describe('ExposedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, exposedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -472,7 +474,11 @@ describe('ExposedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, exposedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -497,8 +503,11 @@ describe('ExposedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [componentConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, exposedComponents: [componentConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -30,10 +30,10 @@ export class ExposedComponentsRegistry extends Registry<
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<ExposedComponentRegistryItem>,
{ pluginId, configs }: PluginExtensionConfigs<PluginExtensionExposedComponentConfig>
): RegistryType<ExposedComponentRegistryItem> {
): Promise<RegistryType<ExposedComponentRegistryItem>> {
if (!configs) {
return registry;
}
@@ -65,7 +65,7 @@ export class ExposedComponentsRegistry extends Registry<
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog)
(await isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog))
) {
continue;
}
@@ -1,4 +1,13 @@
import { Observable, ReplaySubject, Subject, distinctUntilChanged, firstValueFrom, map, scan, startWith } from 'rxjs';
import {
Observable,
ReplaySubject,
Subject,
distinctUntilChanged,
firstValueFrom,
map,
mergeScan,
startWith,
} from 'rxjs';
import { ExtensionsLog, log } from '../logs/log';
import { deepFreeze } from '../utils';
@@ -44,7 +53,7 @@ export abstract class Registry<TRegistryValue extends object | unknown[] | Recor
this.registrySubject = new ReplaySubject<RegistryType<TRegistryValue>>(1);
this.resultSubject
.pipe(
scan(this.mapToRegistry.bind(this), options.initialState ?? {}),
mergeScan(this.mapToRegistry.bind(this), options.initialState ?? {}),
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
startWith(options.initialState ?? {})
)
@@ -55,7 +64,7 @@ export abstract class Registry<TRegistryValue extends object | unknown[] | Recor
abstract mapToRegistry(
registry: RegistryType<TRegistryValue>,
item: PluginExtensionConfigs<TMapType>
): RegistryType<TRegistryValue>;
): Promise<RegistryType<TRegistryValue>>;
register(result: PluginExtensionConfigs<TMapType>): void {
if (this.isReadOnly) {
@@ -1,19 +1,12 @@
import { useAsync } from 'react-use';
import { preloadPlugins } from '../pluginPreloader';
import { PreloadAppPluginsPredicate, preloadPluginsWithPredicate } from '../pluginPreloader';
import { getAppPluginConfigs } from './utils';
export function useLoadAppPlugins(extensionId: string, predicate: PreloadAppPluginsPredicate): { isLoading: boolean } {
const { loading: isLoading } = useAsync(
() => preloadPluginsWithPredicate(extensionId, predicate),
[extensionId, predicate]
);
export function useLoadAppPlugins(pluginIds: string[] = []): { isLoading: boolean } {
const { loading: isLoading } = useAsync(async () => {
const appConfigs = getAppPluginConfigs(pluginIds);
if (!appConfigs.length) {
return;
}
await preloadPlugins(appConfigs);
});
return { isLoading };
return { isLoading: isLoading };
}
@@ -3,6 +3,7 @@ import type { JSX } from 'react';
import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
@@ -51,7 +52,6 @@ describe('usePluginComponent()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const exposedComponentId = `${pluginId}/exposed-component/v1`;
const exposedComponentConfig = {
@@ -135,9 +135,7 @@ describe('usePluginComponent()', () => {
},
};
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@@ -145,7 +143,7 @@ describe('usePluginComponent()', () => {
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return null if there are no component exposed for the id', () => {
@@ -15,7 +15,7 @@ import { isExposedComponentDependencyMissing } from './validators';
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
const registryItem = useExposedComponentRegistrySlice<Props>(id);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(id, getExposedComponentPluginDependencies);
return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
@@ -9,6 +9,8 @@ import {
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@@ -115,31 +117,33 @@ describe('usePluginComponents()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
setAppPluginMetas({
[pluginId]: {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
@@ -496,7 +500,7 @@ describe('usePluginComponents()', () => {
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
it('should not validate the extension point meta-info for core plugins', async () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const componentConfig = {
@@ -506,8 +510,12 @@ describe('usePluginComponents()', () => {
component: () => <div>Component</div>,
};
// The `AddedComponentsRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedComponents = [componentConfig];
// The `AddedComponentsRegistry` is validating if the link is registered in the plugin metadata.
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [componentConfig] } };
setAppPluginMetas({ [pluginId]: app });
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
@@ -21,7 +21,7 @@ export function usePluginComponents<Props extends object = {}>({
}: UsePluginComponentsOptions): UsePluginComponentsResult<Props> {
const registryItems = useAddedComponentsRegistrySlice<Props>(extensionPointId);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(extensionPointId, getExtensionPointPluginDependencies);
return useMemo(() => {
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
@@ -8,7 +8,8 @@ import {
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@@ -107,31 +108,33 @@ describe('usePluginFunctions()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
setAppPluginMetas({
[pluginId]: {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
@@ -318,7 +321,7 @@ describe('usePluginFunctions()', () => {
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
it('should not validate the extension point meta-info for core plugins', async () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const functionConfig = {
@@ -328,8 +331,12 @@ describe('usePluginFunctions()', () => {
fn: () => 'function1',
};
// The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedFunctions = [functionConfig];
// The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata.
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [functionConfig] } };
setAppPluginMetas({ [pluginId]: app });
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
@@ -15,8 +15,7 @@ export function usePluginFunctions<Signature>({
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
const registryItems = useAddedFunctionsRegistrySlice<Signature>(extensionPointId);
const pluginContext = usePluginContext();
const deps = getExtensionPointPluginDependencies(extensionPointId);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(extensionPointId, getExtensionPointPluginDependencies);
return useMemo(() => {
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
@@ -8,7 +8,8 @@ import {
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@@ -107,31 +108,33 @@ describe('usePluginLinks()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
setAppPluginMetas({
[pluginId]: {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
@@ -258,7 +261,7 @@ describe('usePluginLinks()', () => {
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
it('should not validate the extension point meta-info for core plugins', async () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const linkConfig = {
@@ -269,7 +272,11 @@ describe('usePluginLinks()', () => {
};
// The `AddedLinksRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedLinks = [linkConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [linkConfig] } };
setAppPluginMetas({ [pluginId]: app });
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
@@ -24,7 +24,7 @@ export function usePluginLinks({
}: UsePluginLinksOptions): UsePluginLinksResult {
const registryItems = useAddedLinksRegistrySlice(extensionPointId);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(extensionPointId, getExtensionPointPluginDependencies);
return useMemo(() => {
const { result, pointLog } = validateExtensionPoint({
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useAsync } from 'react-use';
import {
type AppPluginConfig,
type PluginExtensionEventHelpers,
type PluginExtensionOpenModalOptions,
isDateTime,
@@ -13,10 +14,9 @@ import {
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
PluginExtensionPoints,
ExtensionInfo,
} from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { reportInteraction, config } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
@@ -28,6 +28,7 @@ import {
} from 'app/types/events';
import { RestrictedGrafanaApisProvider } from '../components/restrictedGrafanaApis/RestrictedGrafanaApisProvider';
import { PreloadAppPluginsPredicate } from '../pluginPreloader';
import { ExtensionErrorBoundary } from './ExtensionErrorBoundary';
import { ExtensionsLog, log as baseLog } from './logs/log';
@@ -609,9 +610,6 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
export const getAppPluginConfigs = (pluginIds: string[] = []) =>
Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
return exposedComponentId.split('/')[0];
};
@@ -619,8 +617,11 @@ export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string)
// Returns a list of app plugin ids that are registering extensions to this extension point.
// (These plugins are necessary to be loaded to use the extension point.)
// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.)
export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => {
return Object.values(config.apps)
export const getExtensionPointPluginDependencies: PreloadAppPluginsPredicate = (
apps: AppPluginConfig[],
extensionPointId: string
): string[] => {
return apps
.filter(
(app) =>
app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
@@ -628,7 +629,7 @@ export const getExtensionPointPluginDependencies = (extensionPointId: string): s
)
.map((app) => app.id)
.reduce((acc: string[], id: string) => {
return [...acc, id, ...getAppPluginDependencies(id)];
return [...acc, id, ...getAppPluginDependencies(apps, id)];
}, []);
};
@@ -645,11 +646,14 @@ export type ExtensionPointPluginMeta = Map<
* @param extensionPointId - The id of the extension point.
* @returns A map of plugin ids and their addedComponents and addedLinks to the extension point.
*/
export const getExtensionPointPluginMeta = (extensionPointId: string): ExtensionPointPluginMeta => {
export const getExtensionPointPluginMeta = (
apps: AppPluginConfig[],
extensionPointId: string
): ExtensionPointPluginMeta => {
return new Map(
getExtensionPointPluginDependencies(extensionPointId)
getExtensionPointPluginDependencies(apps, extensionPointId)
.map((pluginId) => {
const app = config.apps[pluginId];
const app = apps.find((a) => a.id === pluginId);
// if the plugin does not exist or does not expose any components or links to the extension point, return undefined
if (
!app ||
@@ -674,19 +678,27 @@ export const getExtensionPointPluginMeta = (extensionPointId: string): Extension
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
export const getExposedComponentPluginDependencies: PreloadAppPluginsPredicate = (
apps: AppPluginConfig[],
exposedComponentId: string
) => {
const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId);
return [pluginId].reduce((acc: string[], pluginId: string) => {
return [...acc, pluginId, ...getAppPluginDependencies(pluginId)];
return [...acc, pluginId, ...getAppPluginDependencies(apps, pluginId)];
}, []);
};
// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions`
// metadata field. (For example the plugins that expose components that the app depends on.)
// Heads up! This is a recursive function.
export const getAppPluginDependencies = (pluginId: string, visited: string[] = []): string[] => {
if (!config.apps[pluginId]) {
export const getAppPluginDependencies = (
apps: AppPluginConfig[],
pluginId: string,
visited: string[] = []
): string[] => {
const app = apps.find((a) => a.id === pluginId);
if (!app) {
return [];
}
@@ -695,38 +707,14 @@ export const getAppPluginDependencies = (pluginId: string, visited: string[] = [
return [];
}
const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
getAppPluginIdFromExposedComponentId
);
const pluginIdDependencies = app.dependencies.extensions.exposedComponents.map(getAppPluginIdFromExposedComponentId);
return (
pluginIdDependencies
.reduce((acc, _pluginId) => {
return [...acc, ...getAppPluginDependencies(_pluginId, [...visited, pluginId])];
return [...acc, ...getAppPluginDependencies(apps, _pluginId, [...visited, pluginId])];
}, pluginIdDependencies)
// We don't want the plugin to "depend on itself"
.filter((id) => id !== pluginId)
);
};
// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization.
export const getAppPluginsToAwait = () => {
const pluginIds = [
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
'cloud-home-app',
];
return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
};
// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization.
export const getAppPluginsToPreload = () => {
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu);
const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id);
const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id);
return Object.values(config.apps).filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
};
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,8 @@ import {
PluginExtensionPointPatterns,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc } from '@grafana/data/internal';
import { config, isPluginExtensionLink } from '@grafana/runtime';
import { isPluginExtensionLink } from '@grafana/runtime';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import * as errors from './errors';
import { ExtensionsLog } from './logs/log';
@@ -145,13 +146,13 @@ export const isExposedComponentDependencyMissing = (id: string, pluginContext: P
return !exposedComponentsDependencies || !exposedComponentsDependencies.includes(id);
};
export const isAddedLinkMetaInfoMissing = (
export const isAddedLinkMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionAddedLinkConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register link extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app ? app.extensions.addedLinks.filter(({ title }) => title === metaInfo.title) : null;
if (!app) {
@@ -177,13 +178,13 @@ export const isAddedLinkMetaInfoMissing = (
return false;
};
export const isAddedFunctionMetaInfoMissing = (
export const isAddedFunctionMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionAddedFunctionConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register function extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.filter(({ title }) => title === metaInfo.title) : null;
if (!app) {
@@ -209,13 +210,13 @@ export const isAddedFunctionMetaInfoMissing = (
return false;
};
export const isAddedComponentMetaInfoMissing = (
export const isAddedComponentMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register component extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app
? app.extensions.addedComponents.filter(({ title }) => title === metaInfo.title)
: null;
@@ -243,13 +244,13 @@ export const isAddedComponentMetaInfoMissing = (
return false;
};
export const isExposedComponentMetaInfoMissing = (
export const isExposedComponentMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionExposedComponentConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register exposed component extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.filter(({ id }) => id === metaInfo.id) : null;
if (!app) {
+61 -5
View File
@@ -1,12 +1,15 @@
import type {
AppPluginConfig,
PluginExtensionAddedLinkConfig,
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
import {
type AppPluginConfig,
type PluginExtensionAddedLinkConfig,
type PluginExtensionExposedComponentConfig,
type PluginExtensionAddedComponentConfig,
PluginExtensionPoints,
} from '@grafana/data';
import { getAppPluginMetas } from '@grafana/runtime/unstable';
import { contextSrv } from 'app/core/services/context_srv';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { getExtensionPointPluginDependencies } from './extensions/utils';
import { pluginImporter } from './importer/pluginImporter';
export type PluginPreloadResult = {
@@ -23,6 +26,59 @@ export const clearPreloadedPluginsCache = () => {
preloadPromises.clear();
};
function getAppPluginIdsToAwait() {
const pluginIds = [
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
'cloud-home-app',
];
return pluginIds;
}
function isNotAwaited(app: AppPluginConfig) {
return !getAppPluginIdsToAwait().includes(app.id);
}
export async function preloadPluginsToBeAwaited() {
const apps = await getAppPluginMetas();
const awaited = getAppPluginIdsToAwait();
const filtered = apps.filter((app) => awaited.includes(app.id));
preloadPlugins(filtered);
}
export async function preloadPluginsToBePreloaded() {
const apps = await getAppPluginMetas();
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(
apps,
PluginExtensionPoints.DashboardPanelMenu
);
const filtered = apps.filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
preloadPlugins(filtered);
}
export type PreloadAppPluginsPredicate = (apps: AppPluginConfig[], extensionId: string) => string[];
const noop: PreloadAppPluginsPredicate = () => [];
export async function preloadPluginsWithPredicate(extensionId: string, predicate: PreloadAppPluginsPredicate = noop) {
const apps = await getAppPluginMetas();
const filteredIds = predicate(apps, extensionId);
const filtered = apps.filter((app) => filteredIds.includes(app.id));
if (!filtered.length) {
return;
}
preloadPlugins(filtered);
}
export async function preloadPlugins(apps: AppPluginConfig[] = []) {
// Create preload promises for each app, reusing existing promises if already loading
const promises = apps.map((app) => {

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