Compare commits
3 Commits
bugfix/fil
...
docs/add-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2789c17062 | ||
|
|
f6f7f681c7 | ||
|
|
1d44841640 |
1
.github/workflows/pr-patch-check-event.yml
vendored
1
.github/workflows/pr-patch-check-event.yml
vendored
@@ -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
.github/workflows/pr-patch-check.yml
vendored
21
.github/workflows/pr-patch-check.yml
vendored
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -210,7 +210,6 @@ navigationTree:
|
||||
url: /d/UTv--wqMk
|
||||
scope: shoe-org
|
||||
subScope: apparel
|
||||
disableSubScopeSelection: true
|
||||
children:
|
||||
- name: apparel-product-overview
|
||||
title: Product Overview
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,6 +60,7 @@ The following documents will help you get started with the PostgreSQL data sourc
|
||||
|
||||
- [Configure the PostgreSQL data source](ref:configure-postgres-data-source)
|
||||
- [PostgreSQL query editor](ref:postgres-query-editor)
|
||||
- [Troubleshooting](troubleshooting/)
|
||||
|
||||
After you have configured the data source you can:
|
||||
|
||||
|
||||
380
docs/sources/datasources/postgres/troubleshooting/index.md
Normal file
380
docs/sources/datasources/postgres/troubleshooting/index.md
Normal file
@@ -0,0 +1,380 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../data-sources/postgres/troubleshooting/
|
||||
description: Troubleshooting the PostgreSQL data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- postgresql
|
||||
- troubleshooting
|
||||
- errors
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Troubleshooting
|
||||
title: Troubleshoot PostgreSQL data source issues
|
||||
weight: 600
|
||||
---
|
||||
|
||||
# Troubleshoot PostgreSQL data source issues
|
||||
|
||||
This document provides troubleshooting information for common errors you may encounter when using the PostgreSQL data source in Grafana.
|
||||
|
||||
## Connection errors
|
||||
|
||||
The following errors occur when Grafana cannot establish or maintain a connection to PostgreSQL.
|
||||
|
||||
### Failed to connect to PostgreSQL
|
||||
|
||||
**Error message:** `failed to connect to ... : connect: connection refused` or `dial tcp: connect: connection refused`
|
||||
|
||||
**Cause:** Grafana cannot establish a network connection to the PostgreSQL server.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that the Host URL is correct in the data source configuration.
|
||||
1. Check that PostgreSQL is running and accessible from the Grafana server.
|
||||
1. Verify the port is correct (the PostgreSQL default port is `5432`).
|
||||
1. Ensure there are no firewall rules blocking the connection.
|
||||
1. Check that PostgreSQL is configured to accept connections from the Grafana server in `pg_hba.conf`.
|
||||
1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your PostgreSQL instance is not publicly accessible.
|
||||
|
||||
### Request timed out
|
||||
|
||||
**Error message:** "context deadline exceeded" or "i/o timeout"
|
||||
|
||||
**Cause:** The connection to PostgreSQL timed out before receiving a response.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the network latency between Grafana and PostgreSQL.
|
||||
1. Verify that PostgreSQL is not overloaded or experiencing performance issues.
|
||||
1. Increase the **Max lifetime** setting in the data source configuration under **Connection limits**.
|
||||
1. Reduce the time range or complexity of your query.
|
||||
1. Check if any network devices (load balancers, proxies) are timing out the connection.
|
||||
|
||||
### Host not found
|
||||
|
||||
**Error message:** `failed to connect to ... : hostname resolving error` or `lookup hostname: no such host`
|
||||
|
||||
**Cause:** The hostname specified in the data source configuration cannot be resolved.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the hostname is spelled correctly.
|
||||
1. Check that DNS resolution is working on the Grafana server.
|
||||
1. Try using an IP address instead of a hostname.
|
||||
1. Ensure the PostgreSQL server is accessible from the Grafana server's network.
|
||||
|
||||
## Authentication errors
|
||||
|
||||
The following errors occur when there are issues with authentication credentials or permissions.
|
||||
|
||||
### Password authentication failed
|
||||
|
||||
**Error message:** `failed to connect to ... : server error: FATAL: password authentication failed for user "username" (SQLSTATE 28P01)`
|
||||
|
||||
**Cause:** The username or password is incorrect.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that the username and password are correct in the data source configuration.
|
||||
1. Check that the user exists in PostgreSQL.
|
||||
1. Verify the password has not expired.
|
||||
1. If no password is specified, ensure a [PostgreSQL password file](https://www.postgresql.org/docs/current/static/libpq-pgpass.html) is configured.
|
||||
|
||||
### Permission denied
|
||||
|
||||
**Error message:** `ERROR: permission denied for table table_name (SQLSTATE 42501)` or `ERROR: permission denied for schema schema_name (SQLSTATE 42501)`
|
||||
|
||||
**Cause:** The database user does not have permission to access the requested table or schema.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the user has `SELECT` permissions on the required tables.
|
||||
1. Grant the necessary permissions:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON SCHEMA schema_name TO grafanareader;
|
||||
GRANT SELECT ON schema_name.table_name TO grafanareader;
|
||||
```
|
||||
|
||||
1. Check that the user has access to the correct database.
|
||||
1. Verify the search path includes the schema containing your tables.
|
||||
|
||||
### No pg_hba.conf entry
|
||||
|
||||
**Error message:** `failed to connect to ... : server error: FATAL: no pg_hba.conf entry for host "ip_address", user "username", database "database_name" (SQLSTATE 28000)`
|
||||
|
||||
**Cause:** PostgreSQL is not configured to accept connections from the Grafana server.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Edit the `pg_hba.conf` file on the PostgreSQL server.
|
||||
1. Add an entry to allow connections from the Grafana server:
|
||||
|
||||
```text
|
||||
host database_name username grafana_ip/32 md5
|
||||
```
|
||||
|
||||
1. Reload PostgreSQL configuration: `SELECT pg_reload_conf();`
|
||||
1. If using SSL, ensure the correct authentication method is specified (for example, `hostssl` instead of `host`).
|
||||
|
||||
## TLS and certificate errors
|
||||
|
||||
The following errors occur when there are issues with TLS configuration.
|
||||
|
||||
### Certificate verification failed
|
||||
|
||||
**Error message:** "x509: certificate signed by unknown authority" or "certificate verify failed"
|
||||
|
||||
**Cause:** Grafana cannot verify the TLS certificate presented by PostgreSQL.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Set the **TLS/SSL Mode** to the appropriate level (`require`, `verify-ca`, or `verify-full`).
|
||||
1. If using a self-signed certificate, add the CA certificate in **TLS/SSL Auth Details**.
|
||||
1. Verify the certificate chain is complete and valid.
|
||||
1. Ensure the certificate has not expired.
|
||||
1. For testing only, set **TLS/SSL Mode** to `disable` (not recommended for production).
|
||||
|
||||
### SSL not supported
|
||||
|
||||
**Error message:** `failed to connect to ... : server refused TLS connection` or `server does not support SSL`
|
||||
|
||||
**Cause:** The PostgreSQL server is not configured for SSL connections, but the data source requires SSL.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Set **TLS/SSL Mode** to `disable` if SSL is not required.
|
||||
1. Alternatively, enable SSL on the PostgreSQL server by configuring `ssl = on` in `postgresql.conf`.
|
||||
1. Ensure the server has valid SSL certificates configured.
|
||||
|
||||
### Client certificate error
|
||||
|
||||
**Error message:** "TLS: failed to find any PEM data in certificate input" or "could not load client certificate"
|
||||
|
||||
**Cause:** The client certificate or key is invalid or incorrectly formatted.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the certificate and key are in PEM format.
|
||||
1. Ensure the certificate file path is correct and readable by the Grafana process.
|
||||
1. Check that the certificate and key match (belong to the same key pair).
|
||||
1. If using certificate content, ensure you've pasted the complete certificate including headers.
|
||||
|
||||
## Database errors
|
||||
|
||||
The following errors occur when there are issues with the database configuration.
|
||||
|
||||
### Database does not exist
|
||||
|
||||
**Error message:** `failed to connect to ... : server error: FATAL: database "database_name" does not exist (SQLSTATE 3D000)`
|
||||
|
||||
**Cause:** The specified database name is incorrect or the database doesn't exist.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the database name in the data source configuration.
|
||||
1. Check that the database exists: `\l` in psql or `SELECT datname FROM pg_database;`
|
||||
1. Ensure the database name is case-sensitive and matches exactly.
|
||||
1. Verify the user has permission to connect to the database.
|
||||
|
||||
### Relation does not exist
|
||||
|
||||
**Error message:** `ERROR: relation "table_name" does not exist (SQLSTATE 42P01)`
|
||||
|
||||
**Cause:** The specified table or view does not exist, or the user cannot access it.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the table name is correct and exists in the database.
|
||||
1. Check the schema name if the table is not in the public schema.
|
||||
1. Use fully qualified names: `schema_name.table_name`.
|
||||
1. Verify the user has `SELECT` permission on the table.
|
||||
1. Check the search path: `SHOW search_path;`
|
||||
|
||||
## Query errors
|
||||
|
||||
The following errors occur when there are issues with SQL syntax or query execution.
|
||||
|
||||
### Query syntax error
|
||||
|
||||
**Error message:** `ERROR: syntax error at or near "keyword" (SQLSTATE 42601)`
|
||||
|
||||
**Cause:** The SQL query contains invalid syntax.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check your query syntax for typos or invalid keywords.
|
||||
1. Verify column and table names are correctly quoted if they contain special characters or are reserved words.
|
||||
1. Use double quotes for identifiers: `"column_name"`.
|
||||
1. Test the query directly in a PostgreSQL client (psql, pgAdmin).
|
||||
|
||||
### Column does not exist
|
||||
|
||||
**Error message:** `ERROR: column "column_name" does not exist (SQLSTATE 42703)`
|
||||
|
||||
**Cause:** The specified column name is incorrect or doesn't exist in the table.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the column name is spelled correctly.
|
||||
1. Check that column names are case-sensitive in PostgreSQL when quoted.
|
||||
1. Use the correct quoting for column names: `"Column_Name"` for case-sensitive names.
|
||||
1. Verify the column exists in the table: `\d table_name` in psql.
|
||||
|
||||
### No time column found
|
||||
|
||||
**Error message:** "no time column found" or time series visualization shows no data
|
||||
|
||||
**Cause:** The query result does not include a properly formatted time column.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Ensure your query includes a column named `time` that returns a timestamp or epoch value.
|
||||
1. Use an alias to rename your time column: `SELECT created_at AS time`.
|
||||
1. Ensure the time column is of type `timestamp`, `timestamptz`, or a numeric epoch value.
|
||||
1. Order results by the time column: `ORDER BY time ASC`.
|
||||
|
||||
### Macro expansion error
|
||||
|
||||
**Error message:** "macro '$\_\_timeFilter' not found" or incorrect query results with macros
|
||||
|
||||
**Cause:** Grafana macros are not being properly expanded.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the macro syntax is correct, for example `$__timeFilter(time_column)`.
|
||||
1. Ensure the column name passed to the macro exists in your table.
|
||||
1. Use the **Preview** toggle in Builder mode to see the expanded query.
|
||||
1. For time-based macros, ensure the column contains timestamp data.
|
||||
|
||||
## Performance issues
|
||||
|
||||
The following issues relate to slow query execution or resource constraints.
|
||||
|
||||
### Query timeout
|
||||
|
||||
**Error message:** "canceling statement due to statement timeout" or "query timeout"
|
||||
|
||||
**Cause:** The query took longer than the configured timeout.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add indexes to columns used in WHERE clauses and joins.
|
||||
1. Use the `$__timeFilter` macro to limit data to the dashboard time range.
|
||||
1. Increase the statement timeout in PostgreSQL if you have admin access.
|
||||
1. Optimize your query to reduce complexity.
|
||||
|
||||
### Too many connections
|
||||
|
||||
**Error message:** `failed to connect to ... : server error: FATAL: too many connections for role "username" (SQLSTATE 53300)` or `connection pool exhausted`
|
||||
|
||||
**Cause:** The maximum number of connections to PostgreSQL has been reached.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the **Max open** connections setting in the data source configuration.
|
||||
1. Increase `max_connections` in PostgreSQL's `postgresql.conf` if you have admin access.
|
||||
1. Check for connection leaks in other applications connecting to the same database.
|
||||
1. Enable **Auto max idle** to automatically manage idle connections.
|
||||
|
||||
### Slow query performance
|
||||
|
||||
**Cause:** Queries take a long time to execute.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add appropriate indexes to your tables.
|
||||
1. Use the `$__timeFilter` macro to limit the data scanned.
|
||||
1. Increase the **Min time interval** setting to reduce the number of data points.
|
||||
1. Use `EXPLAIN ANALYZE` in PostgreSQL to identify query bottlenecks.
|
||||
1. Consider using materialized views for complex aggregations.
|
||||
|
||||
## Provisioning errors
|
||||
|
||||
The following errors occur when provisioning the data source via YAML.
|
||||
|
||||
### Invalid provisioning configuration
|
||||
|
||||
**Error message:** "metric request error" or data source test fails after provisioning
|
||||
|
||||
**Cause:** The provisioning YAML file contains incorrect configuration.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Ensure parameter names match the expected format exactly.
|
||||
1. Verify the database name is **not** included in the URL.
|
||||
1. Use the correct format for the URL: `hostname:port`.
|
||||
1. Check that string values are properly quoted in the YAML file.
|
||||
1. Refer to the [provisioning example](../configure/#provision-the-data-source) for the correct format.
|
||||
|
||||
Example correct configuration:
|
||||
|
||||
```yaml
|
||||
datasources:
|
||||
- name: Postgres
|
||||
type: postgres
|
||||
url: localhost:5432
|
||||
user: grafana
|
||||
secureJsonData:
|
||||
password: 'Password!'
|
||||
jsonData:
|
||||
database: grafana
|
||||
sslmode: 'disable'
|
||||
```
|
||||
|
||||
## Other common issues
|
||||
|
||||
The following issues don't produce specific error messages but are commonly encountered.
|
||||
|
||||
### Empty query results
|
||||
|
||||
**Cause:** The query returns no data.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the time range includes data in your database.
|
||||
1. Check that table and column names are correct.
|
||||
1. Test the query directly in PostgreSQL.
|
||||
1. Ensure filters are not excluding all data.
|
||||
1. Verify the `$__timeFilter` macro is using the correct time column.
|
||||
|
||||
### TimescaleDB functions not available
|
||||
|
||||
**Cause:** TimescaleDB-specific functions like `time_bucket` are not available in the query builder.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Enable the **TimescaleDB** toggle in the data source configuration under **PostgreSQL Options**.
|
||||
1. Verify TimescaleDB is installed and enabled in your PostgreSQL database.
|
||||
1. Check that the `timescaledb` extension is created: `CREATE EXTENSION IF NOT EXISTS timescaledb;`
|
||||
|
||||
### Data appears delayed or missing recent points
|
||||
|
||||
**Cause:** The visualization doesn't show the most recent data.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the dashboard time range and refresh settings.
|
||||
1. Verify the **Min time interval** is not set too high.
|
||||
1. Ensure data has been committed to the database (not in an uncommitted transaction).
|
||||
1. Check for clock synchronization issues between Grafana and PostgreSQL.
|
||||
|
||||
## Get additional help
|
||||
|
||||
If you continue to experience issues after following this troubleshooting guide:
|
||||
|
||||
1. Check the [PostgreSQL documentation](https://www.postgresql.org/docs/) for database-specific guidance.
|
||||
1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues.
|
||||
1. Contact Grafana Support if you are a Cloud Pro, Cloud Contracted, or Enterprise user.
|
||||
1. When reporting issues, include:
|
||||
- Grafana version
|
||||
- PostgreSQL version
|
||||
- Error messages (redact sensitive information)
|
||||
- Steps to reproduce
|
||||
- Relevant configuration such as data source settings, TLS mode, and connection limits (redact passwords and other credentials)
|
||||
@@ -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)—the data source that uses a result set from another panel in the same dashboard—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:
|
||||
|
||||
|
||||
@@ -223,25 +223,17 @@ To export a dashboard in its current state as a PDF, follow these steps:
|
||||
|
||||
1. Click the **X** at the top-right corner to close the share drawer.
|
||||
|
||||
### Export a dashboard as code
|
||||
### Export a dashboard as JSON
|
||||
|
||||
Export a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. To export a JSON file, follow these steps:
|
||||
|
||||
1. Click **Dashboards** in the main menu.
|
||||
1. Open the dashboard you want to export.
|
||||
1. Click the **Export** drop-down list in the top-right corner and select **Export as code**.
|
||||
1. Click the **Export** drop-down list in the top-right corner and select **Export as JSON**.
|
||||
|
||||
The **Export dashboard** drawer opens.
|
||||
|
||||
1. Select the dashboard JSON model that you to export:
|
||||
- **Classic** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/view-dashboard-json-model/).
|
||||
- **V1 Resource** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/view-dashboard-json-model/) wrapped in the `spec` property of the [V1 Kubernetes-style resource](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2alpha1). Choose between **JSON** and **YAML** format.
|
||||
- **V2 Resource** - Export dashboards created using the [V2 Resource schema](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2beta1). Choose between **JSON** and **YAML** format.
|
||||
|
||||
1. Do one of the following:
|
||||
- Toggle the **Export for sharing externally** switch to generate the JSON with a different data source UID.
|
||||
- Toggle the **Remove deployment details** switch to make the dashboard externally shareable.
|
||||
The **Export dashboard JSON** drawer opens.
|
||||
|
||||
1. Toggle the **Export the dashboard to use in another instance** switch to generate the JSON with a different data source UID.
|
||||
1. Click **Download file** or **Copy to clipboard**.
|
||||
1. Click the **X** at the top-right corner to close the share drawer.
|
||||
|
||||
|
||||
@@ -343,33 +343,6 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
|
||||
// TODO -- saving for another day.
|
||||
});
|
||||
|
||||
test('Tests nested table expansion', async ({ gotoDashboardPage, selectors, page }) => {
|
||||
const dashboardPage = await gotoDashboardPage({
|
||||
uid: DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '4' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Nested tables'))
|
||||
).toBeVisible();
|
||||
|
||||
await waitForTableLoad(page);
|
||||
|
||||
await expect(page.locator('[role="row"]')).toHaveCount(3); // header + 2 rows
|
||||
|
||||
const firstRowExpander = dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.RowExpander)
|
||||
.first();
|
||||
|
||||
await firstRowExpander.click();
|
||||
await expect(page.locator('[role="row"]')).not.toHaveCount(3); // more rows are present now, it is dynamic tho.
|
||||
|
||||
// TODO: test sorting
|
||||
|
||||
await firstRowExpander.click();
|
||||
await expect(page.locator('[role="row"]')).toHaveCount(3); // back to original state
|
||||
});
|
||||
|
||||
test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await gotoDashboardPage({
|
||||
uid: DASHBOARD_UID,
|
||||
|
||||
@@ -804,6 +804,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"packages/grafana-ui/src/components/Table/TableNG/utils.ts": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"packages/grafana-ui/src/components/Table/TableRT/Filter.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1830,6 +1835,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 2
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -499,9 +499,6 @@ export const versionedComponents = {
|
||||
},
|
||||
},
|
||||
TableNG: {
|
||||
RowExpander: {
|
||||
'12.4.0': 'data-testid tableng row expander',
|
||||
},
|
||||
Filters: {
|
||||
HeaderButton: {
|
||||
'12.1.0': 'data-testid tableng header filter',
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -154,18 +154,8 @@ export function TableNG(props: TableNGProps) {
|
||||
|
||||
const resizeHandler = useColumnResize(onColumnResize);
|
||||
|
||||
const rows = useMemo(() => frameToRecords(data), [data]);
|
||||
const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]);
|
||||
const nestedFramesFieldName = useMemo(() => {
|
||||
if (!hasNestedFrames) {
|
||||
return;
|
||||
}
|
||||
const firstNestedField = data.fields.find((f) => f.type === FieldType.nestedFrames);
|
||||
if (!firstNestedField) {
|
||||
return;
|
||||
}
|
||||
return getDisplayName(firstNestedField);
|
||||
}, [data, hasNestedFrames]);
|
||||
const rows = useMemo(() => frameToRecords(data, nestedFramesFieldName), [data, nestedFramesFieldName]);
|
||||
const getTextColorForBackground = useMemo(() => memoize(_getTextColorForBackground, { maxSize: 1000 }), []);
|
||||
|
||||
const {
|
||||
@@ -384,11 +374,7 @@ export function TableNG(props: TableNGProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expandedRecords = applySort(
|
||||
frameToRecords(nestedData, nestedFramesFieldName),
|
||||
nestedData.fields,
|
||||
sortColumns
|
||||
);
|
||||
const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns);
|
||||
if (!expandedRecords.length) {
|
||||
return (
|
||||
<div className={styles.noDataNested}>
|
||||
@@ -412,7 +398,7 @@ export function TableNG(props: TableNGProps) {
|
||||
width: COLUMN.EXPANDER_WIDTH,
|
||||
minWidth: COLUMN.EXPANDER_WIDTH,
|
||||
}),
|
||||
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles, nestedFramesFieldName]
|
||||
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles]
|
||||
);
|
||||
|
||||
const fromFields = useCallback(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2 } from '../../../../themes/ThemeContext';
|
||||
@@ -17,21 +16,13 @@ export function RowExpander({ onCellExpand, isExpanded }: RowExpanderNGProps) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.expanderCell}
|
||||
onClick={onCellExpand}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid={selectors.components.Panels.Visualization.TableNG.RowExpander}
|
||||
>
|
||||
<div role="button" tabIndex={0} className={styles.expanderCell} onClick={onCellExpand} onKeyDown={handleKeyDown}>
|
||||
<Icon
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t('grafana-ui.row-expander-ng.aria-label-collapse', 'Collapse row')
|
||||
: t('grafana-ui.row-expander.aria-label-expand', 'Expand row')
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
name={isExpanded ? 'angle-down' : 'angle-right'}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface TableRow {
|
||||
|
||||
// Nested table properties
|
||||
data?: DataFrame;
|
||||
__nestedFrames?: DataFrame[];
|
||||
__expanded?: boolean; // For row expansion state
|
||||
|
||||
// Generic typing for column values
|
||||
@@ -261,7 +262,7 @@ export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOpti
|
||||
export type Comparator = (a: TableCellValue, b: TableCellValue) => number;
|
||||
|
||||
// Type for converting a DataFrame into an array of TableRows
|
||||
export type FrameToRowsConverter = (frame: DataFrame, nestedFramesFieldName?: string) => TableRow[];
|
||||
export type FrameToRowsConverter = (frame: DataFrame) => TableRow[];
|
||||
|
||||
// Type for mapping column names to their field types
|
||||
export type ColumnTypes = Record<string, FieldType>;
|
||||
|
||||
@@ -675,12 +675,10 @@ export function applySort(
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string): TableRow[] => {
|
||||
export const frameToRecords = (frame: DataFrame): TableRow[] => {
|
||||
const fnBody = `
|
||||
const rows = Array(frame.length);
|
||||
const values = frame.fields.map(f => f.values);
|
||||
const hasNestedFrames = '${nestedFramesFieldName ?? ''}'.length > 0;
|
||||
|
||||
let rowCount = 0;
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
rows[rowCount] = {
|
||||
@@ -688,14 +686,11 @@ export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string)
|
||||
__index: i,
|
||||
${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')}
|
||||
};
|
||||
rowCount++;
|
||||
|
||||
if (hasNestedFrames) {
|
||||
const childFrame = rows[rowCount-1][${JSON.stringify(nestedFramesFieldName)}];
|
||||
if (childFrame){
|
||||
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
|
||||
rowCount++;
|
||||
}
|
||||
rowCount += 1;
|
||||
if (rows[rowCount-1]['__nestedFrames']){
|
||||
const childFrame = rows[rowCount-1]['__nestedFrames'];
|
||||
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
|
||||
rowCount += 1;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -703,9 +698,8 @@ export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string)
|
||||
|
||||
// Creates a function that converts a DataFrame into an array of TableRows
|
||||
// Uses new Function() for performance as it's faster than creating rows using loops
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const convert = new Function('frame', 'nestedFramesFieldName', fnBody) as FrameToRowsConverter;
|
||||
return convert(frame, nestedFramesFieldName);
|
||||
const convert = new Function('frame', fnBody) as FrameToRowsConverter;
|
||||
return convert(frame);
|
||||
};
|
||||
|
||||
/* ----------------------------- Data grid comparator ---------------------------- */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -618,7 +618,6 @@ type Cfg struct {
|
||||
EnableSearch bool
|
||||
OverridesFilePath string
|
||||
OverridesReloadInterval time.Duration
|
||||
EnableSQLKVBackend bool
|
||||
|
||||
// Secrets Management
|
||||
SecretsManagement SecretsManagerSettings
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
3
public/api-merged.json
generated
3
public/api-merged.json
generated
@@ -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"
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -25,17 +25,10 @@ import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer';
|
||||
import { PanelTimeRange, PanelTimeRangeState } from '../../scene/panel-timerange/PanelTimeRange';
|
||||
import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager';
|
||||
import { transformSaveModelSchemaV2ToScene } from '../../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
|
||||
import { findVizPanelByKey } from '../../utils/utils';
|
||||
import { buildPanelEditScene } from '../PanelEditor';
|
||||
import {
|
||||
testDashboard,
|
||||
panelWithTransformations,
|
||||
panelWithQueriesOnly,
|
||||
testDashboardV2,
|
||||
} from '../testfiles/testDashboard';
|
||||
import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard';
|
||||
|
||||
import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab';
|
||||
|
||||
@@ -831,78 +824,6 @@ describe('PanelDataQueriesTab', () => {
|
||||
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
|
||||
});
|
||||
});
|
||||
|
||||
describe('V2 schema behavior - panel datasource undefined but queries have datasource', () => {
|
||||
it('should load datasource from first query for V2 panel with prometheus datasource', async () => {
|
||||
// panel-1 has a query with prometheus datasource
|
||||
const { queriesTab } = await setupV2Scene('panel-1');
|
||||
|
||||
// V2 panels have undefined panel-level datasource for non-mixed panels
|
||||
expect(queriesTab.queryRunner.state.datasource).toBeUndefined();
|
||||
|
||||
// But the query has its own datasource
|
||||
expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({
|
||||
type: 'grafana-prometheus-datasource',
|
||||
uid: 'gdev-prometheus',
|
||||
});
|
||||
|
||||
// Should load the datasource from the first query
|
||||
expect(queriesTab.state.datasource?.uid).toBe('gdev-prometheus');
|
||||
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-prometheus');
|
||||
});
|
||||
|
||||
it('should load datasource from first query for V2 panel with testdata datasource', async () => {
|
||||
// panel-2 has a query with testdata datasource
|
||||
const { queriesTab } = await setupV2Scene('panel-2');
|
||||
|
||||
// V2 panels have undefined panel-level datasource for non-mixed panels
|
||||
expect(queriesTab.queryRunner.state.datasource).toBeUndefined();
|
||||
|
||||
// But the query has its own datasource
|
||||
expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({
|
||||
type: 'grafana-testdata-datasource',
|
||||
uid: 'gdev-testdata',
|
||||
});
|
||||
|
||||
// Should load the datasource from the first query
|
||||
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
|
||||
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
|
||||
});
|
||||
|
||||
it('should fall back to last used datasource when V2 query has no explicit datasource', async () => {
|
||||
store.exists.mockReturnValue(true);
|
||||
store.getObject.mockImplementation((key: string, def: unknown) => {
|
||||
if (key === PANEL_EDIT_LAST_USED_DATASOURCE) {
|
||||
return {
|
||||
dashboardUid: 'v2-dashboard-uid',
|
||||
datasourceUid: 'gdev-testdata',
|
||||
};
|
||||
}
|
||||
return def;
|
||||
});
|
||||
|
||||
// panel-3 has a query with NO explicit datasource (datasource.name is undefined)
|
||||
const { queriesTab } = await setupV2Scene('panel-3');
|
||||
|
||||
// V2 panel with no explicit datasource on query should fall back to last used
|
||||
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
|
||||
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
|
||||
});
|
||||
|
||||
it('should use panel-level datasource when available (V1 behavior preserved)', async () => {
|
||||
const { queriesTab } = await setupScene('panel-1');
|
||||
|
||||
// V1 panels have panel-level datasource set
|
||||
expect(queriesTab.queryRunner.state.datasource).toEqual({
|
||||
uid: 'gdev-testdata',
|
||||
type: 'grafana-testdata-datasource',
|
||||
});
|
||||
|
||||
// Should use the panel-level datasource
|
||||
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
|
||||
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -923,24 +844,3 @@ async function setupScene(panelId: string) {
|
||||
|
||||
return { panel, scene: dashboard, queriesTab };
|
||||
}
|
||||
|
||||
// Setup V2 scene - uses transformSaveModelSchemaV2ToScene
|
||||
async function setupV2Scene(panelKey: string) {
|
||||
const dashboard = transformSaveModelSchemaV2ToScene(testDashboardV2);
|
||||
|
||||
const vizPanels = (dashboard.state.body as DashboardLayoutManager).getVizPanels();
|
||||
const panel = vizPanels.find((p) => p.state.key === panelKey)!;
|
||||
|
||||
const panelEditor = buildPanelEditScene(panel);
|
||||
dashboard.setState({ editPanel: panelEditor });
|
||||
|
||||
deactivators.push(dashboard.activate());
|
||||
deactivators.push(panelEditor.activate());
|
||||
|
||||
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
|
||||
deactivators.push(queriesTab.activate());
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
return { panel, scene: dashboard, queriesTab };
|
||||
}
|
||||
|
||||
@@ -86,17 +86,6 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
|
||||
let datasource: DataSourceApi | undefined;
|
||||
let dsSettings: DataSourceInstanceSettings | undefined;
|
||||
|
||||
// If no panel-level datasource (V2 schema non-mixed case), infer from first query
|
||||
// This also improves the V1 behavior because it doesn't make sense to rely on last used
|
||||
// if underlying queries have different datasources
|
||||
if (!datasourceToLoad) {
|
||||
const queries = this.queryRunner.state.queries;
|
||||
const firstQueryDs = queries[0]?.datasource;
|
||||
if (firstQueryDs) {
|
||||
datasourceToLoad = firstQueryDs;
|
||||
}
|
||||
}
|
||||
|
||||
if (!datasourceToLoad) {
|
||||
const dashboardScene = getDashboardSceneFor(this);
|
||||
const dashboardUid = dashboardScene.state.uid ?? '';
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { Spec as DashboardV2Spec, defaultDataQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
|
||||
export const panelWithQueriesOnly = {
|
||||
datasource: {
|
||||
type: 'grafana-testdata-datasource',
|
||||
@@ -754,223 +751,3 @@ export const testDashboard = {
|
||||
version: 6,
|
||||
weekStart: '',
|
||||
};
|
||||
|
||||
// V2 Dashboard fixture - panels have queries with datasources but NO panel-level datasource
|
||||
export const testDashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = {
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
metadata: {
|
||||
name: 'v2-dashboard-uid',
|
||||
namespace: 'default',
|
||||
labels: {},
|
||||
generation: 1,
|
||||
resourceVersion: '1',
|
||||
creationTimestamp: new Date().toISOString(),
|
||||
},
|
||||
spec: {
|
||||
title: 'V2 Test Dashboard',
|
||||
description: 'Test dashboard for V2 schema',
|
||||
tags: [],
|
||||
cursorSync: 'Off',
|
||||
liveNow: false,
|
||||
editable: true,
|
||||
preload: false,
|
||||
links: [],
|
||||
variables: [],
|
||||
annotations: [],
|
||||
timeSettings: {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
autoRefresh: '',
|
||||
autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
|
||||
fiscalYearStartMonth: 0,
|
||||
hideTimepicker: false,
|
||||
timezone: '',
|
||||
weekStart: undefined,
|
||||
quickRanges: [],
|
||||
},
|
||||
elements: {
|
||||
'panel-1': {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id: 1,
|
||||
title: 'Panel with Prometheus datasource',
|
||||
description: '',
|
||||
links: [],
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group: 'grafana-prometheus-datasource',
|
||||
datasource: {
|
||||
name: 'gdev-prometheus',
|
||||
},
|
||||
spec: {
|
||||
expr: 'up',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
transformations: [],
|
||||
queryOptions: {},
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: 'VizConfig',
|
||||
group: 'timeseries',
|
||||
version: '1.0.0',
|
||||
spec: {
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'panel-2': {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id: 2,
|
||||
title: 'Panel with TestData datasource',
|
||||
description: '',
|
||||
links: [],
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group: 'grafana-testdata-datasource',
|
||||
datasource: {
|
||||
name: 'gdev-testdata',
|
||||
},
|
||||
spec: {
|
||||
scenarioId: 'random_walk',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
transformations: [],
|
||||
queryOptions: {},
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: 'VizConfig',
|
||||
group: 'timeseries',
|
||||
version: '1.0.0',
|
||||
spec: {
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'panel-3': {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
id: 3,
|
||||
title: 'Panel with no datasource on query',
|
||||
description: '',
|
||||
links: [],
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId: 'A',
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group: 'grafana-testdata-datasource',
|
||||
// No datasource.name - simulates panel with no explicit datasource
|
||||
spec: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
transformations: [],
|
||||
queryOptions: {},
|
||||
},
|
||||
},
|
||||
vizConfig: {
|
||||
kind: 'VizConfig',
|
||||
group: 'timeseries',
|
||||
version: '1.0.0',
|
||||
spec: {
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
kind: 'GridLayout',
|
||||
spec: {
|
||||
items: [
|
||||
{
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 8,
|
||||
element: { kind: 'ElementReference', name: 'panel-1' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
x: 12,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 8,
|
||||
element: { kind: 'ElementReference', name: 'panel-2' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
x: 0,
|
||||
y: 8,
|
||||
width: 12,
|
||||
height: 8,
|
||||
element: { kind: 'ElementReference', name: 'panel-3' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
access: {
|
||||
url: '/d/v2-dashboard-uid',
|
||||
slug: 'v2-test-dashboard',
|
||||
},
|
||||
apiVersion: 'v2',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
import { RowItem } from './RowItem';
|
||||
|
||||
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
const { layout, collapse, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
|
||||
const isCollapsed = collapse && !isHeaderHidden; // never allow a row without a header to be collapsed
|
||||
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
|
||||
const isClone = isRepeatCloneOrChildOf(model);
|
||||
const { isEditing } = useDashboardState(model);
|
||||
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(
|
||||
@@ -238,7 +237,6 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
}),
|
||||
dragging: css({
|
||||
cursor: 'move',
|
||||
backgroundColor: theme.colors.background.canvas,
|
||||
}),
|
||||
wrapperGrow: css({
|
||||
flexGrow: 1,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -78,36 +78,11 @@ export function unboxNearMembraneProxies(structure: unknown): unknown {
|
||||
if (Array.isArray(structure)) {
|
||||
return structure.map(unboxNearMembraneProxies);
|
||||
}
|
||||
|
||||
if (isTransferable(structure)) {
|
||||
return structure;
|
||||
}
|
||||
|
||||
if (typeof structure === 'object') {
|
||||
return Object.keys(structure).reduce((acc, key) => {
|
||||
Reflect.set(acc, key, unboxNearMembraneProxies(Reflect.get(structure, key)));
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return structure;
|
||||
}
|
||||
|
||||
function isTransferable(structure: unknown): structure is Transferable {
|
||||
// We should probably add all of the transferable types here.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
|
||||
return (
|
||||
structure instanceof ArrayBuffer ||
|
||||
structure instanceof OffscreenCanvas ||
|
||||
structure instanceof ImageBitmap ||
|
||||
structure instanceof MessagePort ||
|
||||
structure instanceof MediaSourceHandle ||
|
||||
structure instanceof ReadableStream ||
|
||||
structure instanceof WritableStream ||
|
||||
structure instanceof TransformStream ||
|
||||
structure instanceof AudioData ||
|
||||
structure instanceof VideoFrame ||
|
||||
structure instanceof RTCDataChannel ||
|
||||
structure instanceof ArrayBuffer
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,6 @@ const getSummaryColumns = () => [
|
||||
header: 'Unchanged',
|
||||
cell: ({ row: { original: item } }: SummaryCell) => item.noop?.toString() || '-',
|
||||
},
|
||||
{
|
||||
id: 'warnings',
|
||||
header: 'Warnings',
|
||||
cell: ({ row: { original: item } }: SummaryCell) => item.warning?.toString() || '-',
|
||||
},
|
||||
{
|
||||
id: 'errors',
|
||||
header: 'Errors',
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useScopes } from '@grafana/runtime';
|
||||
import { ToolbarButton } from '@grafana/ui';
|
||||
|
||||
import { useScopesServices } from '../ScopesContextProvider';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
hideWhenOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ContextualNavigationPaneToggle({ className, hideWhenOpen }: Props) {
|
||||
const scopes = useScopes();
|
||||
const services = useScopesServices();
|
||||
|
||||
if (!scopes || !services) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scopesDashboardsService } = services;
|
||||
const { readOnly, drawerOpened } = scopes.state;
|
||||
|
||||
if (hideWhenOpen && drawerOpened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardsIconLabel = readOnly
|
||||
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
|
||||
: drawerOpened
|
||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
|
||||
: t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list');
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ToolbarButton
|
||||
icon="web-section-alt"
|
||||
aria-label={dashboardsIconLabel}
|
||||
tooltip={dashboardsIconLabel}
|
||||
data-testid="scopes-dashboards-expand"
|
||||
disabled={readOnly}
|
||||
onClick={scopesDashboardsService.toggleDrawer}
|
||||
variant={'canvas'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useObservable } from 'react-use';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@@ -34,22 +34,22 @@ export function ScopesDashboards() {
|
||||
if (!loading) {
|
||||
if (forScopeNames.length === 0) {
|
||||
return (
|
||||
<div className={styles.container} data-testid="scopes-dashboards-container">
|
||||
<ScopesDashboardsTreeSearch disabled={loading} query={searchQuery} onChange={changeSearchQuery} />
|
||||
|
||||
<div className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundNoScopes">
|
||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans>
|
||||
</div>
|
||||
<div
|
||||
className={cx(styles.container, styles.noResultsContainer)}
|
||||
data-testid="scopes-dashboards-notFoundNoScopes"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsNoScopes">No scopes selected</Trans>
|
||||
</div>
|
||||
);
|
||||
} else if (dashboards.length === 0 && scopeNavigations.length === 0) {
|
||||
return (
|
||||
<div className={styles.container} data-testid="scopes-dashboards-container">
|
||||
<div className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForScope">
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">
|
||||
No dashboards or links found for the selected scopes
|
||||
</Trans>
|
||||
</div>
|
||||
<div
|
||||
className={cx(styles.container, styles.noResultsContainer)}
|
||||
data-testid="scopes-dashboards-notFoundForScope"
|
||||
>
|
||||
<Trans i18nKey="scopes.dashboards.noResultsForScopes">
|
||||
No dashboards or links found for the selected scopes
|
||||
</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,14 +94,13 @@ export function ScopesDashboards() {
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
backgroundColor: theme.colors.background.canvas,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(0, 2),
|
||||
margin: theme.spacing(2, 0),
|
||||
padding: theme.spacing(2),
|
||||
width: theme.spacing(37.5),
|
||||
}),
|
||||
noResultsContainer: css({
|
||||
|
||||
@@ -887,161 +887,4 @@ describe('ScopesDashboardsService', () => {
|
||||
expect(service.state.navScopePath).toEqual(['mimir']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableSubScopeSelection', () => {
|
||||
it('should set disableSubScopeSelection on folder when navigation has it set to true', async () => {
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
url: '/d/dashboard1',
|
||||
scope: 'scope1',
|
||||
subScope: 'subScope1',
|
||||
disableSubScopeSelection: true,
|
||||
},
|
||||
status: {
|
||||
title: 'Test Navigation',
|
||||
},
|
||||
metadata: {
|
||||
name: 'nav1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
// Find the folder created for this subScope
|
||||
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
|
||||
expect(folderKey).toBeDefined();
|
||||
|
||||
if (folderKey) {
|
||||
const folder = service.state.folders[''].folders[folderKey];
|
||||
expect(folder.disableSubScopeSelection).toBe(true);
|
||||
expect(folder.subScopeName).toBe('subScope1');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set disableSubScopeSelection to false when navigation has it set to false', async () => {
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
url: '/d/dashboard1',
|
||||
scope: 'scope1',
|
||||
subScope: 'subScope1',
|
||||
disableSubScopeSelection: false,
|
||||
},
|
||||
status: {
|
||||
title: 'Test Navigation',
|
||||
},
|
||||
metadata: {
|
||||
name: 'nav1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
|
||||
expect(folderKey).toBeDefined();
|
||||
|
||||
if (folderKey) {
|
||||
const folder = service.state.folders[''].folders[folderKey];
|
||||
expect(folder.disableSubScopeSelection).toBe(false);
|
||||
expect(folder.subScopeName).toBe('subScope1');
|
||||
}
|
||||
});
|
||||
|
||||
it('should set disableSubScopeSelection to undefined when navigation does not have it', async () => {
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
url: '/d/dashboard1',
|
||||
scope: 'scope1',
|
||||
subScope: 'subScope1',
|
||||
},
|
||||
status: {
|
||||
title: 'Test Navigation',
|
||||
},
|
||||
metadata: {
|
||||
name: 'nav1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
|
||||
expect(folderKey).toBeDefined();
|
||||
|
||||
if (folderKey) {
|
||||
const folder = service.state.folders[''].folders[folderKey];
|
||||
expect(folder.disableSubScopeSelection).toBeUndefined();
|
||||
expect(folder.subScopeName).toBe('subScope1');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple navigations with different disableSubScopeSelection values', async () => {
|
||||
const mockNavigations: ScopeNavigation[] = [
|
||||
{
|
||||
spec: {
|
||||
url: '/d/dashboard1',
|
||||
scope: 'scope1',
|
||||
subScope: 'subScope1',
|
||||
disableSubScopeSelection: true,
|
||||
},
|
||||
status: {
|
||||
title: 'Disabled Navigation',
|
||||
},
|
||||
metadata: {
|
||||
name: 'nav1',
|
||||
},
|
||||
},
|
||||
{
|
||||
spec: {
|
||||
url: '/d/dashboard2',
|
||||
scope: 'scope1',
|
||||
subScope: 'subScope2',
|
||||
disableSubScopeSelection: false,
|
||||
},
|
||||
status: {
|
||||
title: 'Enabled Navigation',
|
||||
},
|
||||
metadata: {
|
||||
name: 'nav2',
|
||||
},
|
||||
},
|
||||
{
|
||||
spec: {
|
||||
url: '/d/dashboard3',
|
||||
scope: 'scope1',
|
||||
subScope: 'subScope3',
|
||||
},
|
||||
status: {
|
||||
title: 'Default Navigation',
|
||||
},
|
||||
metadata: {
|
||||
name: 'nav3',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
|
||||
await service.fetchDashboards(['scope1']);
|
||||
|
||||
const folders = service.state.folders[''].folders;
|
||||
const folder1Key = Object.keys(folders).find((key) => key.includes('subScope1'));
|
||||
const folder2Key = Object.keys(folders).find((key) => key.includes('subScope2'));
|
||||
const folder3Key = Object.keys(folders).find((key) => key.includes('subScope3'));
|
||||
|
||||
expect(folder1Key).toBeDefined();
|
||||
expect(folder2Key).toBeDefined();
|
||||
expect(folder3Key).toBeDefined();
|
||||
|
||||
expect(folders[folder1Key!].disableSubScopeSelection).toBe(true);
|
||||
expect(folders[folder2Key!].disableSubScopeSelection).toBe(false);
|
||||
expect(folders[folder3Key!].disableSubScopeSelection).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ScopesServiceBase } from '../ScopesServiceBase';
|
||||
import { buildSubScopePath, isCurrentPath } from './scopeNavgiationUtils';
|
||||
import {
|
||||
ScopeNavigation,
|
||||
ScopeNavigationSpec,
|
||||
SuggestedNavigationsFolder,
|
||||
SuggestedNavigationsFoldersMap,
|
||||
SuggestedNavigationsMap,
|
||||
@@ -387,17 +386,12 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
|
||||
// All folders with the same subScope will load the same content when expanded
|
||||
const folderKey = `${subScope}-${navigation.metadata.name}`;
|
||||
if (!rootNode.folders[folderKey]) {
|
||||
let disableSubScopeSelection: ScopeNavigationSpec['disableSubScopeSelection'] = undefined;
|
||||
if ('disableSubScopeSelection' in navigation.spec) {
|
||||
disableSubScopeSelection = navigation.spec.disableSubScopeSelection;
|
||||
}
|
||||
rootNode.folders[folderKey] = {
|
||||
title: navigationTitle,
|
||||
expanded,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: subScope,
|
||||
disableSubScopeSelection,
|
||||
};
|
||||
}
|
||||
if (expanded && !rootNode.folders[folderKey].expanded) {
|
||||
|
||||
@@ -287,63 +287,4 @@ describe('ScopesDashboardsTreeFolderItem', () => {
|
||||
// The component checks for scopesSelectorService existence before calling setNavigationScope
|
||||
expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('disableSubScopeSelection', () => {
|
||||
it('does not show exchange icon when disableSubScopeSelection is true', () => {
|
||||
const folder = createMockFolder({
|
||||
subScopeName: 'subScope1',
|
||||
disableSubScopeSelection: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButtons = screen.queryAllByRole('button', { name: /change root scope/i });
|
||||
expect(exchangeButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows exchange icon when disableSubScopeSelection is false', () => {
|
||||
const folder = createMockFolder({
|
||||
subScopeName: 'subScope1',
|
||||
disableSubScopeSelection: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
expect(exchangeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows exchange icon when disableSubScopeSelection is undefined', () => {
|
||||
const folder = createMockFolder({
|
||||
subScopeName: 'subScope1',
|
||||
disableSubScopeSelection: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
folder={folder}
|
||||
folderPath={['']}
|
||||
folders={createMockFolders}
|
||||
onFolderUpdate={mockOnFolderUpdate}
|
||||
/>
|
||||
);
|
||||
|
||||
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
|
||||
expect(exchangeButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ScopesDashboardsTreeFolderItem({
|
||||
{folder.loading && <Spinner inline size="sm" className={styles.loadingIcon} />}
|
||||
</button>
|
||||
|
||||
{folder.subScopeName && !folder.disableSubScopeSelection && (
|
||||
{folder.subScopeName && (
|
||||
<IconButton
|
||||
className={styles.exchangeIcon}
|
||||
tooltip={t('scopes.dashboards.exchange', 'Change root scope to {{scope}}', {
|
||||
|
||||
@@ -6,8 +6,6 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { FilterInput, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ContextualNavigationPaneToggle } from './ContextualNavigationPaneToggle';
|
||||
|
||||
export interface ScopesDashboardsTreeSearchProps {
|
||||
disabled: boolean;
|
||||
query: string;
|
||||
@@ -44,7 +42,6 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes
|
||||
data-testid="scopes-dashboards-search"
|
||||
onChange={(value) => setInputState({ value, dirty: true })}
|
||||
/>
|
||||
<ContextualNavigationPaneToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,8 +49,6 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
flex: '0 1 auto',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -5,9 +5,6 @@ export interface ScopeNavigationSpec {
|
||||
url: string;
|
||||
scope: string;
|
||||
subScope?: string;
|
||||
preLoadSubScopeChildren?: boolean;
|
||||
expandOnLoad?: boolean;
|
||||
disableSubScopeSelection?: boolean;
|
||||
}
|
||||
|
||||
export interface ScopeNavigationStatus {
|
||||
@@ -43,7 +40,6 @@ export interface SuggestedNavigationsFolder {
|
||||
suggestedNavigations: SuggestedNavigationsMap;
|
||||
subScopeName?: string;
|
||||
loading?: boolean;
|
||||
disableSubScopeSelection?: boolean;
|
||||
}
|
||||
|
||||
export type SuggestedNavigationsFoldersMap = Record<string, SuggestedNavigationsFolder>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Observable } from 'rxjs';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { useScopes } from '@grafana/runtime';
|
||||
import { Button, Drawer, ErrorBoundary, ErrorWithStack, Spinner, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Drawer, ErrorBoundary, ErrorWithStack, IconButton, Spinner, Text, useStyles2 } from '@grafana/ui';
|
||||
import { getModKey } from 'app/core/utils/browser';
|
||||
|
||||
import { useScopesServices } from '../ScopesContextProvider';
|
||||
@@ -54,8 +54,8 @@ export const ScopesSelector = () => {
|
||||
tree,
|
||||
scopes: scopesMap,
|
||||
} = selectorServiceState;
|
||||
const { scopesService, scopesSelectorService } = services;
|
||||
const { readOnly, loading } = scopes.state;
|
||||
const { scopesService, scopesSelectorService, scopesDashboardsService } = services;
|
||||
const { readOnly, drawerOpened, loading } = scopes.state;
|
||||
const {
|
||||
open,
|
||||
removeAllScopes,
|
||||
@@ -70,8 +70,24 @@ export const ScopesSelector = () => {
|
||||
|
||||
const recentScopes = getRecentScopes();
|
||||
|
||||
const dashboardsIconLabel = readOnly
|
||||
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
|
||||
: drawerOpened
|
||||
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
|
||||
: t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list');
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
name="web-section-alt"
|
||||
className={styles.dashboards}
|
||||
aria-label={dashboardsIconLabel}
|
||||
tooltip={dashboardsIconLabel}
|
||||
data-testid="scopes-dashboards-expand"
|
||||
disabled={readOnly}
|
||||
onClick={scopesDashboardsService.toggleDrawer}
|
||||
/>
|
||||
|
||||
<ScopesInput
|
||||
nodes={nodes}
|
||||
scopes={scopesMap}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
expectNoDashboardsForFilter,
|
||||
expectNoDashboardsForScope,
|
||||
expectNoDashboardsNoScopes,
|
||||
expectNoDashboardsSearch,
|
||||
} from './utils/assertions';
|
||||
import {
|
||||
alternativeDashboardWithRootFolder,
|
||||
@@ -303,12 +304,14 @@ describe('Dashboards list', () => {
|
||||
it('Shows a proper message when no scopes are selected', async () => {
|
||||
await toggleDashboards();
|
||||
expectNoDashboardsNoScopes();
|
||||
expectNoDashboardsSearch();
|
||||
});
|
||||
|
||||
it('Does not show the input when there are no dashboards found for scope', async () => {
|
||||
await updateScopes(scopesService, ['cloud']);
|
||||
await toggleDashboards();
|
||||
expectNoDashboardsForScope();
|
||||
expectNoDashboardsSearch();
|
||||
});
|
||||
|
||||
it('Shows the input and a message when there are no dashboards found for filter', async () => {
|
||||
|
||||
@@ -190,7 +190,9 @@ export default class TempoLanguageProvider extends LanguageProvider {
|
||||
* @returns the encoded tag
|
||||
*/
|
||||
private encodeTag = (tag: string): string => {
|
||||
return encodeURIComponent(tag);
|
||||
// If we call `encodeURIComponent` only once, we still get an error when issuing a request to the backend
|
||||
// Reference: https://stackoverflow.com/a/37456192
|
||||
return encodeURIComponent(encodeURIComponent(tag));
|
||||
};
|
||||
|
||||
generateQueryFromFilters({
|
||||
|
||||
@@ -911,7 +911,7 @@ const traceSubFrame = (
|
||||
subFrame.add(transformSpanToTraceData(span, spanSet, trace));
|
||||
});
|
||||
|
||||
return toDataFrame(subFrame);
|
||||
return subFrame;
|
||||
};
|
||||
|
||||
interface TraceTableData {
|
||||
|
||||
@@ -3739,10 +3739,6 @@
|
||||
"clear": "Vymazat vyhledávání a filtry",
|
||||
"text": "Nebyly nalezeny žádné výsledky pro váš dotaz"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5996,25 +5992,13 @@
|
||||
"title-error-loading-dashboard": "Chyba při načítání nástěnky"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Upravit panel",
|
||||
"view-panel": "Zobrazit panel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Nástěnka",
|
||||
"discard-changes-to-dashboard": "Zahodit změny nástěnky?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Zahodit změny nástěnky?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10814,6 +10798,7 @@
|
||||
"title": "Nové"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Nová nástěnka"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11973,6 +11958,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Nastavení tohoto připojení může způsobit dočasný výpadek"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Zajišťování",
|
||||
"subtitle-provisioning-feature": "Zobrazujte a spravujte vazby zajištění"
|
||||
},
|
||||
"git": {
|
||||
@@ -12744,6 +12730,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importovat",
|
||||
"new": "Nové",
|
||||
"new-dashboard": "Nová nástěnka",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Suche und Filter löschen",
|
||||
"text": "Keine Ergebnisse für deine Abfrage gefunden"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Fehler beim Laden des Dashboards"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Panel bearbeiten",
|
||||
"view-panel": "Panel anzeigen"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Dashboard",
|
||||
"discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Neu"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Neues Dashboard"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Das Einrichten dieser Verbindung kann zu einem vorübergehenden Ausfall führen"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Bereitstellung",
|
||||
"subtitle-provisioning-feature": "Sehen und verwalten Sie Ihre Bereitstellungsverbindungen"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importieren",
|
||||
"new": "Neu",
|
||||
"new-dashboard": "Neues Dashboard",
|
||||
|
||||
@@ -5133,7 +5133,6 @@
|
||||
"empty-state-message": "Run a query to visualize it here or go to all visualizations to add other panel types",
|
||||
"menu-open-panel-editor": "Configure",
|
||||
"menu-use-library-panel": "Use library panel",
|
||||
"missing-config": "Missing panel configuration",
|
||||
"suggestions": {
|
||||
"empty-state-message": "Run a query to start seeing suggested visualizations"
|
||||
}
|
||||
@@ -6146,10 +6145,7 @@
|
||||
"no-data-found": "No data found"
|
||||
},
|
||||
"inspect-json-tab": {
|
||||
"apply": "Apply",
|
||||
"error-invalid-json": "Invalid JSON",
|
||||
"error-invalid-v2-panel": "Panel JSON did not pass validation. Please check the JSON and try again.",
|
||||
"validation-error": "Validation error"
|
||||
"apply": "Apply"
|
||||
},
|
||||
"interval-variable-form": {
|
||||
"description-auto-option": "Dynamically calculates interval by dividing time range by the count specified",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Borrar la búsqueda y los filtros",
|
||||
"text": "No se han encontrado resultados para tu consulta"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Error al cargar el panel de control"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Editar panel",
|
||||
"view-panel": "Ver panel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Panel de control",
|
||||
"discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Nuevo"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Nuevo panel de control"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Configurar esta conexión podría causar una interrupción temporal"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Aprovisionamiento",
|
||||
"subtitle-provisioning-feature": "Ver y gestionar tus conexiones de aprovisionamiento"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importar",
|
||||
"new": "Nuevo",
|
||||
"new-dashboard": "Nuevo panel de control",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Effacer la recherche et les filtres",
|
||||
"text": "Aucun résultat n'a été trouvé pour votre requête"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Erreur lors du chargement du tableau de bord"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Modifier le panneau",
|
||||
"view-panel": "Afficher le panneau"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Nouveau"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Nouveau tableau de bord"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "La configuration de cette connexion peut entraîner une interruption temporaire"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Mise en service",
|
||||
"subtitle-provisioning-feature": "Afficher et gérer vos connexions de mise en service"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importer",
|
||||
"new": "Nouveau",
|
||||
"new-dashboard": "Nouveau tableau de bord",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Keresés és szűrők törlése",
|
||||
"text": "Nincs találat a lekérdezésre"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Hiba történt az irányítópult betöltésekor"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Panel szerkesztése",
|
||||
"view-panel": "Panel megtekintése"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Irányítópult",
|
||||
"discard-changes-to-dashboard": "Elveti az irányítópult módosításait?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Elveti az irányítópult módosításait?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Új"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Új irányítópult"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "A kapcsolat létrehozása ideiglenes üzemszünetet okozhat"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Kiépítés",
|
||||
"subtitle-provisioning-feature": "Kiépítési kapcsolatok megtekintése és kezelése"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importálás",
|
||||
"new": "Új",
|
||||
"new-dashboard": "Új irányítópult",
|
||||
|
||||
@@ -3691,10 +3691,6 @@
|
||||
"clear": "Hapus pencarian dan filter",
|
||||
"text": "Hasil untuk kueri Anda tidak ditemukan"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_other": "",
|
||||
@@ -5933,25 +5929,13 @@
|
||||
"title-error-loading-dashboard": "Kesalahan saat memuat dasbor"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Edit panel",
|
||||
"view-panel": "Lihat panel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Dasbor",
|
||||
"discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10685,6 +10669,7 @@
|
||||
"title": "Baru"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Dasbor baru"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11820,6 +11805,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Mengatur koneksi ini dapat menyebabkan pemadaman sementara"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Penyediaan",
|
||||
"subtitle-provisioning-feature": "Lihat dan kelola koneksi penyediaan Anda"
|
||||
},
|
||||
"git": {
|
||||
@@ -12582,6 +12568,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Impor",
|
||||
"new": "Baru",
|
||||
"new-dashboard": "Dasbor baru",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Cancella ricerca e filtri",
|
||||
"text": "Nessun risultato trovato per la ricerca"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Errore durante il caricamento del dashboard"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Modifica pannello",
|
||||
"view-panel": "Visualizza pannello"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Dashboard",
|
||||
"discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Nuovo"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Nuovo dashboard"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "La configurazione di questa connessione potrebbe causare un'interruzione temporanea"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Provisioning",
|
||||
"subtitle-provisioning-feature": "Visualizza e gestisci le connessioni di provisioning"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importa",
|
||||
"new": "Nuovo",
|
||||
"new-dashboard": "Nuovo dashboard",
|
||||
|
||||
@@ -3691,10 +3691,6 @@
|
||||
"clear": "検索とフィルタをクリア",
|
||||
"text": "クエリに一致する結果が見つかりませんでした。"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_other": "",
|
||||
@@ -5933,25 +5929,13 @@
|
||||
"title-error-loading-dashboard": "ダッシュボードの読み込み中にエラーが発生しました"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "パネルを編集",
|
||||
"view-panel": "パネルを表示"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "ダッシュボード",
|
||||
"discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10685,6 +10669,7 @@
|
||||
"title": "新規"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "新しいダッシュボード"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11820,6 +11805,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "この接続設定を行うことで、一時的に停止する可能性があります"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "プロビジョニング",
|
||||
"subtitle-provisioning-feature": "プロビジョニング接続を表示・管理"
|
||||
},
|
||||
"git": {
|
||||
@@ -12582,6 +12568,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "インポート",
|
||||
"new": "新規",
|
||||
"new-dashboard": "新しいダッシュボード",
|
||||
|
||||
@@ -3691,10 +3691,6 @@
|
||||
"clear": "검색 및 필터 초기화",
|
||||
"text": "쿼리에 대해 찾은 결과 없음"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_other": "",
|
||||
@@ -5933,25 +5929,13 @@
|
||||
"title-error-loading-dashboard": "대시보드 로딩 중 오류 발생"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "패널 편집",
|
||||
"view-panel": "패널 보기"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "대시보드",
|
||||
"discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10685,6 +10669,7 @@
|
||||
"title": "신규"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "새 대시보드"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11820,6 +11805,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "이 연결을 설정하면 일시적인 중단이 발생할 수 있습니다"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "프로비저닝",
|
||||
"subtitle-provisioning-feature": "프로비저닝 연결 보기 및 관리"
|
||||
},
|
||||
"git": {
|
||||
@@ -12582,6 +12568,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "가져오기",
|
||||
"new": "신규",
|
||||
"new-dashboard": "새 대시보드",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Zoekopdracht en filters wissen",
|
||||
"text": "Geen resultaten gevonden voor je zoekopdracht"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Er is een fout opgetreden bij het laden van het dashboard"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Paneel bewerken",
|
||||
"view-panel": "Paneel bekijken"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Dashboard",
|
||||
"discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Nieuw"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Nieuw dashboard"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Het opzetten van deze verbinding kan een tijdelijke storing veroorzaken"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Provisioning",
|
||||
"subtitle-provisioning-feature": "Je provisioningverbindingen bekijken en beheren"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importeren",
|
||||
"new": "Nieuw",
|
||||
"new-dashboard": "Nieuw dashboard",
|
||||
|
||||
@@ -3739,10 +3739,6 @@
|
||||
"clear": "Wyczyść wyszukiwanie i filtry",
|
||||
"text": "Nie znaleziono wyników dla tego zapytania"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5996,25 +5992,13 @@
|
||||
"title-error-loading-dashboard": "Błąd wczytywania pulpitu"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Edytuj panel",
|
||||
"view-panel": "Wyświetl panel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Pulpit",
|
||||
"discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10814,6 +10798,7 @@
|
||||
"title": "Nowy"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Nowy pulpit"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11973,6 +11958,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Skonfigurowanie tego połączenia może spowodować tymczasową niedostępność"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Konfiguracja",
|
||||
"subtitle-provisioning-feature": "Wyświetlaj połączenia aprowizacyjne i nimi zarządzaj"
|
||||
},
|
||||
"git": {
|
||||
@@ -12744,6 +12730,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importuj",
|
||||
"new": "Nowy",
|
||||
"new-dashboard": "Nowy pulpit",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Limpar busca e filtros",
|
||||
"text": "Nenhum resultado encontrado para sua consulta"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Erro ao carregar o painel de controle"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Editar painel",
|
||||
"view-panel": "Visualizar painel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Painel de controle",
|
||||
"discard-changes-to-dashboard": "Deseja descartar as alterações no painel?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Deseja descartar as alterações no painel?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Novo"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Novo painel de controle"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Estabelecer esta conexão pode causar uma interrupção temporária"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Aprovisionamento",
|
||||
"subtitle-provisioning-feature": "Visualize e gerencie suas conexões de provisionamento"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importar",
|
||||
"new": "Novo",
|
||||
"new-dashboard": "Novo painel de controle",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Limpar a pesquisa e os filtros",
|
||||
"text": "Não foram encontrados resultados para a sua consulta"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Erro ao carregar o painel de controlo"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Editar painel",
|
||||
"view-panel": "Visualizar painel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Painel de controlo",
|
||||
"discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Novo"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Novo painel de controlo"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Configurar esta ligação pode causar uma interrupção temporária"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Provisionamento",
|
||||
"subtitle-provisioning-feature": "Ver e gerir as suas ligações de provisionamento"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importar",
|
||||
"new": "Novo",
|
||||
"new-dashboard": "Novo painel de controlo",
|
||||
|
||||
@@ -3739,10 +3739,6 @@
|
||||
"clear": "Очистить поиск и фильтры",
|
||||
"text": "По вашему запросу ничего не найдено"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5996,25 +5992,13 @@
|
||||
"title-error-loading-dashboard": "Ошибка при загрузке дашборда"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Редактировать панель",
|
||||
"view-panel": "Просмотр панели"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Дашборд",
|
||||
"discard-changes-to-dashboard": "Отменить изменения на дашборде?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Отменить изменения на дашборде?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10814,6 +10798,7 @@
|
||||
"title": "Новые элементы"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Новый дашборд"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11973,6 +11958,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Настройка этого подключения может привести к временному сбою"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Подготовка к работе",
|
||||
"subtitle-provisioning-feature": "Просмотр подключений для подготовки и управлением ими"
|
||||
},
|
||||
"git": {
|
||||
@@ -12744,6 +12730,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Импорт",
|
||||
"new": "Новые элементы",
|
||||
"new-dashboard": "Новый дашборд",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Rensa sökning och filter",
|
||||
"text": "Inga resultat hittades för din fråga"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Fel vid laddning av instrumentpanel"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Redigera panel",
|
||||
"view-panel": "Visa panel"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Instrumentpanel",
|
||||
"discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Nyhet"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Ny instrumentpanel"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Konfiguration av den här anslutningen kan orsaka ett tillfälligt avbrott"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Provisionering",
|
||||
"subtitle-provisioning-feature": "Visa och hantera dina provisioneringsanslutningar"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "Importera",
|
||||
"new": "Nyhet",
|
||||
"new-dashboard": "Ny instrumentpanel",
|
||||
|
||||
@@ -3707,10 +3707,6 @@
|
||||
"clear": "Aramayı ve filtreleri temizle",
|
||||
"text": "Sorgunuz için sonuç bulunamadı"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_one": "",
|
||||
@@ -5954,25 +5950,13 @@
|
||||
"title-error-loading-dashboard": "Pano yüklenirken hata oluştu"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "Paneli düzenle",
|
||||
"view-panel": "Paneli görüntüle"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "Pano",
|
||||
"discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10728,6 +10712,7 @@
|
||||
"title": "Yeni"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "Yeni pano"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11871,6 +11856,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Bu bağlantıyı kurmak geçici bir kesintiye neden olabilir"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Sağlama",
|
||||
"subtitle-provisioning-feature": "Sağlama bağlantılarınızı görüntüleyin ve yönetin"
|
||||
},
|
||||
"git": {
|
||||
@@ -12636,6 +12622,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "İçe aktar",
|
||||
"new": "Yeni",
|
||||
"new-dashboard": "Yeni pano",
|
||||
|
||||
@@ -3691,10 +3691,6 @@
|
||||
"clear": "清除搜索和筛选条件",
|
||||
"text": "未找到与您的查询相关的结果"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
"success": "",
|
||||
"all-failed_other": "",
|
||||
@@ -5933,25 +5929,13 @@
|
||||
"title-error-loading-dashboard": "加载数据面板时出错"
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"modal": {
|
||||
"cancel": "",
|
||||
"discard": "",
|
||||
"save": "",
|
||||
"text": {
|
||||
"save-changes-question": ""
|
||||
},
|
||||
"title": {
|
||||
"unsaved-changes": ""
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"edit-panel": "编辑面板",
|
||||
"view-panel": "查看面板"
|
||||
},
|
||||
"title": {
|
||||
"dashboard": "仪表板",
|
||||
"discard-changes-to-dashboard": "放弃对数据面板的更改?",
|
||||
"unsaved-changes-question": ""
|
||||
"discard-changes-to-dashboard": "放弃对数据面板的更改?"
|
||||
}
|
||||
},
|
||||
"dashboard-scene-page-state-manager": {
|
||||
@@ -10685,6 +10669,7 @@
|
||||
"title": "新建"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "",
|
||||
"title": "新建仪表板"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11820,6 +11805,7 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "设置此连接可能会导致暂时中断"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "配置",
|
||||
"subtitle-provisioning-feature": "查看和管理您的预配连接"
|
||||
},
|
||||
"git": {
|
||||
@@ -12582,6 +12568,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "",
|
||||
"import": "导入",
|
||||
"new": "新建",
|
||||
"new-dashboard": "新建仪表板",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user