Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs

This commit is contained in:
Ryan McKinley
2025-07-01 16:53:45 -07:00
22 changed files with 801 additions and 402 deletions
-6
View File
@@ -1493,12 +1493,6 @@ exports[`better eslint`] = {
"public/app/features/browse-dashboards/components/NewFolderForm.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"]
],
"public/app/features/browse-dashboards/state/index.ts:5381": [
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"],
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "1"],
@@ -21,10 +21,10 @@
}
]
},
"description": "",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 21273,
"links": [
{
"asDropdown": true,
@@ -37,9 +37,9 @@
"type": "dashboards"
}
],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
@@ -47,6 +47,7 @@
"y": 0
},
"id": 44,
"panels": [],
"title": "Enums",
"type": "row"
},
@@ -99,7 +100,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -162,6 +163,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -209,6 +211,7 @@
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
@@ -225,7 +228,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -282,6 +285,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -318,6 +322,7 @@
"type": "state-timeline"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
@@ -325,6 +330,7 @@
"y": 17
},
"id": 43,
"panels": [],
"title": "Gaps and connected",
"type": "row"
},
@@ -375,7 +381,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -394,7 +400,6 @@
"y": 18
},
"id": 3,
"links": [],
"options": {
"legend": {
"calcs": [],
@@ -407,7 +412,7 @@
"sort": "none"
}
},
"pluginVersion": "7.4.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -490,7 +495,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -509,7 +514,6 @@
"y": 18
},
"id": 2,
"links": [],
"options": {
"legend": {
"calcs": [],
@@ -522,7 +526,7 @@
"sort": "none"
}
},
"pluginVersion": "7.4.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -613,7 +617,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -632,7 +636,6 @@
"y": 18
},
"id": 6,
"links": [],
"options": {
"legend": {
"calcs": [],
@@ -645,7 +648,7 @@
"sort": "none"
}
},
"pluginVersion": "7.4.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -754,7 +757,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -773,7 +776,6 @@
"y": 25
},
"id": 4,
"links": [],
"options": {
"legend": {
"calcs": [],
@@ -786,7 +788,7 @@
"sort": "none"
}
},
"pluginVersion": "7.4.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -869,7 +871,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -888,7 +890,6 @@
"y": 25
},
"id": 5,
"links": [],
"options": {
"legend": {
"calcs": [],
@@ -901,7 +902,7 @@
"sort": "none"
}
},
"pluginVersion": "7.4.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -992,7 +993,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1011,7 +1012,6 @@
"y": 25
},
"id": 7,
"links": [],
"options": {
"legend": {
"calcs": [],
@@ -1024,7 +1024,7 @@
"sort": "none"
}
},
"pluginVersion": "7.4.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -1133,7 +1133,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1197,7 +1197,7 @@
"sort": "none"
}
},
"pluginVersion": "7.2.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"refId": "A",
@@ -1232,7 +1232,6 @@
}
],
"title": "Null values in first series & show gaps ",
"transformations": [],
"type": "timeseries"
},
{
@@ -1282,7 +1281,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1346,7 +1345,7 @@
"sort": "none"
}
},
"pluginVersion": "7.2.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -1381,11 +1380,12 @@
}
],
"title": "Null values in second series show gaps (bugged)",
"transformations": [],
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1432,7 +1432,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1462,6 +1462,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"refId": "A",
@@ -1473,7 +1474,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1520,7 +1523,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1550,6 +1553,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -1565,7 +1569,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1609,7 +1615,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1640,6 +1646,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -1655,7 +1662,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1699,7 +1708,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1730,6 +1739,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -1745,7 +1755,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1789,7 +1801,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1820,6 +1832,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -1835,7 +1848,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1879,7 +1894,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1891,7 +1906,7 @@
"overrides": []
},
"gridPos": {
"h": 10,
"h": 5,
"w": 8,
"x": 0,
"y": 61
@@ -1910,6 +1925,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -1925,7 +1941,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -1969,7 +1987,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -1981,7 +1999,7 @@
"overrides": []
},
"gridPos": {
"h": 10,
"h": 5,
"w": 8,
"x": 8,
"y": 61
@@ -2000,6 +2018,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2015,7 +2034,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -2059,7 +2080,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2071,7 +2092,7 @@
"overrides": []
},
"gridPos": {
"h": 10,
"h": 5,
"w": 8,
"x": 16,
"y": 61
@@ -2090,6 +2111,7 @@
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2104,6 +2126,192 @@
"title": "Higher density (mostly nulls, no leading/trailing)",
"type": "timeseries"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 8,
"x": 0,
"y": 66
},
"id": 47,
"maxDataPoints": 150,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "csv_metric_values",
"stringInput": "8"
}
],
"title": "Higher density (mostly nulls, one leading point)",
"type": "timeseries"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 8,
"x": 8,
"y": 66
},
"id": 48,
"maxDataPoints": 150,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "csv_metric_values",
"stringInput": ",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,8"
}
],
"title": "Higher density (mostly nulls, one trailing point)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
@@ -2165,7 +2373,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2196,7 +2404,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -2213,7 +2421,9 @@
"type": "timeseries"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
@@ -2257,7 +2467,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2288,7 +2498,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -2305,13 +2515,16 @@
"type": "trend"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
@@ -2328,7 +2541,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "#EAB839",
@@ -2374,7 +2587,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"alias": "",
@@ -2438,7 +2651,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2469,7 +2682,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2531,7 +2744,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2562,7 +2775,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2587,6 +2800,7 @@
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
@@ -2603,7 +2817,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "#EAB839",
@@ -2649,7 +2863,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2711,7 +2925,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2742,7 +2956,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2804,7 +3018,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -2835,7 +3049,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2860,6 +3074,7 @@
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
@@ -2876,7 +3091,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "#EAB839",
@@ -2922,7 +3137,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -2984,7 +3199,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -3015,7 +3230,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -3077,7 +3292,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "red",
@@ -3108,7 +3323,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -3133,6 +3348,7 @@
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
@@ -3149,7 +3365,7 @@
"steps": [
{
"color": "green",
"value": null
"value": 0
},
{
"color": "#EAB839",
@@ -3195,7 +3411,7 @@
"sort": "none"
}
},
"pluginVersion": "7.5.0-pre",
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -3210,8 +3426,9 @@
"type": "state-timeline"
}
],
"preload": false,
"refresh": false,
"schemaVersion": 39,
"schemaVersion": 41,
"tags": [
"gdev",
"panel-tests",
@@ -3232,6 +3449,5 @@
"timezone": "",
"title": "Panel Tests - Graph NG - Discrete panels",
"uid": "8mmCAF1Mz",
"version": 25,
"weekStart": ""
"version": 1
}
+1 -1
View File
@@ -87,7 +87,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20250627094213-dc5663fe02aa // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250618124654-54543efcfeed // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
+2 -2
View File
@@ -1587,8 +1587,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20250627094213-dc5663fe02aa h1:wVzOJepVLzq6nd2aYbpkMBycP7ffPuSAuWSzRoYw4bE=
github.com/grafana/alerting v0.0.0-20250627094213-dc5663fe02aa/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 h1:3imTbxFpZSVI6IBIB9mn+Xc40lUweWjfMaBSgXR7rLs=
github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
github.com/grafana/authlib v0.0.0-20250618124654-54543efcfeed h1:k5Ng33zE9fCawqfEVybOasXY7/FQD5Qg2J92ePneeVM=
github.com/grafana/authlib v0.0.0-20250618124654-54543efcfeed/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI=
github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d h1:34E6btDAhdDOiSEyrMaYaHwnJpM8w9QKzVQZIBzLNmM=
+59
View File
@@ -0,0 +1,59 @@
package ofrep
import (
"context"
"net/http"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
// This is a dummy connector that is not actually used for anything
// EXCEPT -- k8s requires *something* to be registered so it will add the storage to discovery.
// As a quick workaround, we register a noop storage; and then manually remove it from openapi
type NoopConnector struct{}
var (
_ rest.Connecter = (*NoopConnector)(nil)
_ rest.StorageMetadata = (*NoopConnector)(nil)
_ rest.Scoper = (*NoopConnector)(nil)
_ rest.SingularNameProvider = (*NoopConnector)(nil)
)
func (r *NoopConnector) New() runtime.Object {
return &metav1.Status{}
}
func (r *NoopConnector) NamespaceScoped() bool {
return true // namespaced
}
func (r *NoopConnector) GetSingularName() string {
return "noop"
}
func (r *NoopConnector) Destroy() {
}
func (r *NoopConnector) ConnectMethods() []string {
return []string{"GET"}
}
func (r *NoopConnector) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (r *NoopConnector) ProducesMIMETypes(verb string) []string {
return nil
}
func (r *NoopConnector) ProducesObject(verb string) interface{} {
return r.New()
}
func (r *NoopConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte("NOOP"))
}), nil
}
+138 -25
View File
@@ -7,23 +7,23 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/gorilla/mux"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/gorilla/mux"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
var _ builder.APIGroupBuilder = (*APIBuilder)(nil)
@@ -32,6 +32,11 @@ var _ builder.APIGroupVersionProvider = (*APIBuilder)(nil)
const ofrepPath = "/ofrep/v1/evaluate/flags"
var groupVersion = schema.GroupVersion{
Group: "features.grafana.app",
Version: "v0alpha1",
}
type APIBuilder struct {
providerType string
url *url.URL
@@ -66,18 +71,19 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
}
func (b *APIBuilder) GetGroupVersion() schema.GroupVersion {
return schema.GroupVersion{
Group: "features.grafana.app",
Version: "v0alpha1",
}
return groupVersion
}
func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
metav1.AddToGroupVersion(scheme, b.GetGroupVersion())
return scheme.SetVersionPriority(b.GetGroupVersion())
metav1.AddToGroupVersion(scheme, groupVersion)
scheme.AddKnownTypes(groupVersion, &metav1.Status{}) // for noop
return scheme.SetVersionPriority(groupVersion)
}
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storage := map[string]rest.Storage{}
storage["noop"] = &NoopConnector{}
apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version] = storage
return nil
}
@@ -91,20 +97,125 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
return []string{builder.AllResourcesAllowed}
}
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
oas.Info.Description = "Proxy access to open feature flags"
// Remove the NOOP connector
delete(oas.Paths.Paths, "/apis/"+groupVersion.String()+"/namespaces/{namespace}/noop/{name}")
return oas, nil
}
func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
evaluationContext := &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Description: "EvaluationContext provides ambient information for the purposes of flag evaluation",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: spec.MapProperty(spec.MapProperty(nil)),
Example: map[string]map[string]any{
"context": {
"targetingKey": "1234",
"grafana_version": "12.0.0",
},
},
},
},
}}}
return &builder.APIRoutes{
Namespace: []builder.APIRouteHandler{
{
Path: "ofrep/v1/evaluate/flags/",
Spec: &spec3.PathProps{
Post: &spec3.Operation{},
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
Tags: []string{"Evaluate"},
Description: "Evaluate all flags",
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "namespace",
In: "path",
Required: true,
Example: "default",
Description: "workspace",
Schema: spec.StringProperty(),
},
},
},
RequestBody: evaluationContext,
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
StatusCodeResponses: map[int]*spec3.Response{
200: {
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: spec.MapProperty(nil), // TODO... real type?
},
},
},
},
},
},
},
},
},
},
},
Handler: b.allFlagsHandler,
},
{
Path: "ofrep/v1/evaluate/flags/{flagKey}",
Spec: &spec3.PathProps{
Post: &spec3.Operation{},
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
Tags: []string{"Evaluate"},
Description: "Evaluate a single flag",
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "namespace",
In: "path",
Required: true,
Example: "default",
Description: "workspace",
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "flagKey",
In: "path",
Required: true,
Example: "testflag",
Description: "flag key",
Schema: spec.StringProperty(),
},
},
},
RequestBody: evaluationContext,
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
StatusCodeResponses: map[int]*spec3.Response{
200: {
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: spec.MapProperty(nil), // TODO, real type
},
},
},
},
},
},
},
},
},
},
},
Handler: b.oneFlagHandler,
},
@@ -168,29 +279,25 @@ func writeResponse(statusCode int, result any, logger log.Logger, w http.Respons
}
}
func (b *APIBuilder) stackIdFromEvalCtx(body []byte) string {
func (b *APIBuilder) stackIdFromEvalCtx(body []byte) int64 {
// Extract stackID from request body without consuming it
var evalCtx struct {
Context struct {
StackID int32 `json:"stackId"`
StackID int64 `json:"stackId"` // TODO -- replace with namespace "stackId" ONLY makes sense in cloud
} `json:"context"`
}
if err := json.Unmarshal(body, &evalCtx); err != nil {
b.logger.Debug("Failed to unmarshal evaluation context", "error", err, "body", string(body))
return ""
return 0
}
if evalCtx.Context.StackID <= 0 {
b.logger.Debug("Invalid or missing stackId in evaluation context", "stackId", evalCtx.Context.StackID)
return ""
return 0
}
return strconv.Itoa(int(evalCtx.Context.StackID))
}
func removeStackPrefix(tenant string) string {
return strings.TrimPrefix(tenant, "stacks-")
return evalCtx.Context.StackID
}
// isAuthenticatedRequest returns true if the request is authenticated
@@ -217,6 +324,12 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
namespace = mux.Vars(r)["namespace"]
}
info, err := types.ParseNamespace(namespace)
if err != nil {
b.logger.Error("Error parsing namespace", "error", err)
return false
}
// Extract stackId from feature flag evaluation context
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -226,7 +339,7 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
r.Body = io.NopCloser(bytes.NewBuffer(body))
// "default" namespace case can only occur in on-prem grafana
if b.stackIdFromEvalCtx(body) == removeStackPrefix(namespace) || namespace == "default" {
if b.stackIdFromEvalCtx(body) == info.StackID {
return true
}
+3 -3
View File
@@ -43,7 +43,7 @@ type StorageOptions struct {
// For unified-grpc
Address string
IndexServerAddress string
SearchServerAddress string
GrpcClientAuthenticationToken string
GrpcClientAuthenticationTokenExchangeURL string
GrpcClientAuthenticationTokenNamespace string
@@ -138,8 +138,8 @@ func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfi
return err
}
var indexConn *grpc.ClientConn
if o.IndexServerAddress != "" {
indexConn, err = grpc.NewClient(o.IndexServerAddress,
if o.SearchServerAddress != "" {
indexConn, err = grpc.NewClient(o.SearchServerAddress,
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
+8 -8
View File
@@ -58,12 +58,12 @@ func ProvideUnifiedStorageClient(opts *Options,
// See: apiserver.applyAPIServerConfig(cfg, features, o)
apiserverCfg := opts.Cfg.SectionWithEnvOverrides("grafana-apiserver")
client, err := newClient(options.StorageOptions{
StorageType: options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeUnified))),
DataPath: apiserverCfg.Key("storage_path").MustString(filepath.Join(opts.Cfg.DataPath, "grafana-apiserver")),
Address: apiserverCfg.Key("address").MustString(""),
IndexServerAddress: apiserverCfg.Key("index_server_address").MustString(""),
BlobStoreURL: apiserverCfg.Key("blob_url").MustString(""),
BlobThresholdBytes: apiserverCfg.Key("blob_threshold_bytes").MustInt(options.BlobThresholdDefault),
StorageType: options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeUnified))),
DataPath: apiserverCfg.Key("storage_path").MustString(filepath.Join(opts.Cfg.DataPath, "grafana-apiserver")),
Address: apiserverCfg.Key("address").MustString(""),
SearchServerAddress: apiserverCfg.Key("search_server_address").MustString(""),
BlobStoreURL: apiserverCfg.Key("blob_url").MustString(""),
BlobThresholdBytes: apiserverCfg.Key("blob_threshold_bytes").MustInt(options.BlobThresholdDefault),
}, opts.Cfg, opts.Features, opts.DB, opts.Tracer, opts.Reg, opts.Authzc, opts.Docs, storageMetrics, indexMetrics)
if err == nil {
// Used to get the folder stats
@@ -135,8 +135,8 @@ func newClient(opts options.StorageOptions,
return nil, err
}
if opts.IndexServerAddress != "" {
indexConn, err = newGrpcConn(opts.IndexServerAddress, metrics, features)
if opts.SearchServerAddress != "" {
indexConn, err = newGrpcConn(opts.SearchServerAddress, metrics, features)
if err != nil {
return nil, err
+3 -3
View File
@@ -65,9 +65,9 @@ func TestUnifiedStorageClient(t *testing.T) {
client, err := newClient(
options.StorageOptions{
StorageType: options.StorageTypeUnifiedGrpc,
Address: resourceServerAddress,
IndexServerAddress: indexServerAddress,
StorageType: options.StorageTypeUnifiedGrpc,
Address: resourceServerAddress,
SearchServerAddress: indexServerAddress,
},
&setting.Cfg{},
featuremgmt.WithFeatures(),
+53
View File
@@ -0,0 +1,53 @@
package features
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestIntegrationFeatures(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Enable a random flag -- check that it is reported as enabled
flag := featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: false, // allow anon user
EnableFeatureToggles: []string{
flag, // used in test below
},
})
t.Run("Test evaluate flags", func(t *testing.T) {
rsp := apis.DoRequest(helper, apis.RequestParams{
Method: http.MethodPost,
Path: "/apis/features.grafana.app/v0alpha1/namespaces/default/ofrep/v1/evaluate/flags/" + flag,
User: helper.Org1.Admin,
}, &map[string]any{})
require.Equal(t, 200, rsp.Response.StatusCode)
require.JSONEq(t, `{
"Value": true,
"FlagKey": "`+flag+`",
"FlagType": 0,
"Variant": "enabled",
"Reason": "STATIC",
"ErrorCode": "",
"ErrorMessage": "",
"FlagMetadata": {}
}`, string(rsp.Body))
})
}
+17 -9
View File
@@ -343,7 +343,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
let pointsFilter: uPlot.Series.Points.Filter = () => null;
if (customConfig.spanNulls !== true) {
if (customConfig.spanNulls !== true && showPoints === VisibilityMode.Auto) {
pointsFilter = (u, seriesIdx, show, gaps) => {
let filtered = [];
@@ -390,15 +390,23 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
filtered.push(lastIdx);
}
}
// single point surrounded by nulls
// single point
else {
// meh heuristic
if (yData[0] == null && yData[yData.length - 1] == null) {
for (let i = 0; i < yData.length; i++) {
if (yData[i] != null) {
filtered.push(i);
}
}
// scan right
let leftIdx = 0;
while (yData[leftIdx] == null) {
leftIdx++;
}
// scan left
let rightIdx = yData.length - 1;
while (rightIdx >= leftIdx && yData[rightIdx] == null) {
rightIdx--;
}
// render if same
if (leftIdx === rightIdx) {
filtered.push(leftIdx);
}
}
}
@@ -107,11 +107,7 @@ export default function CreateNewButton({ parentFolder, canCreateDashboard, canC
size="sm"
>
{parentFolder?.managedBy === ManagerKind.Repo || isProvisionedInstance ? (
<NewProvisionedFolderForm
onSubmit={() => setShowNewFolderDrawer(false)}
onCancel={() => setShowNewFolderDrawer(false)}
parentFolder={parentFolder}
/>
<NewProvisionedFolderForm onDismiss={() => setShowNewFolderDrawer(false)} parentFolder={parentFolder} />
) : (
<NewFolderForm onConfirm={onCreateFolder} onCancel={() => setShowNewFolderDrawer(false)} />
)}
@@ -33,8 +33,8 @@ jest.mock('./BrowseActions/DescendantCount', () => ({
DescendantCount: () => <div data-testid="descendant-count">2 folders, 5 dashboards</div>,
}));
jest.mock('app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields', () => ({
DashboardEditFormSharedFields: () => <div data-testid="shared-fields" />,
jest.mock('app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields', () => ({
ResourceEditFormSharedFields: () => <div data-testid="shared-fields" />,
}));
const mockUseDeleteRepositoryFilesMutation = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction<
@@ -8,7 +8,7 @@ import { Box, Button, Stack } from '@grafana/ui';
import { Folder } from 'app/api/clients/folder/v1beta1';
import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
import { DashboardEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields';
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared';
import { FolderDTO } from 'app/types';
@@ -121,7 +121,7 @@ function FormContent({
/>
</Box>
<DashboardEditFormSharedFields
<ResourceEditFormSharedFields
resourceType="folder"
isNew={false}
workflow={workflow}
@@ -3,13 +3,12 @@ import userEvent from '@testing-library/user-event';
import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
import { FolderDTO } from '../../../types';
import { ProvisionedFolderFormDataResult, useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
import { NewProvisionedFolderForm } from './NewProvisionedFolderForm';
@@ -41,9 +40,9 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => {
};
});
jest.mock('app/api/clients/folder/v1beta1', () => {
jest.mock('../hooks/useProvisionedFolderFormData', () => {
return {
useGetFolderQuery: jest.fn(),
useProvisionedFolderFormData: jest.fn(),
};
});
@@ -53,12 +52,6 @@ jest.mock('app/features/provisioning/hooks/usePullRequestParam', () => {
};
});
jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => {
return {
useGetResourceRepositoryView: jest.fn(),
};
});
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
@@ -79,17 +72,15 @@ jest.mock('../../dashboard-scene/saving/provisioned/defaults', () => {
});
interface Props {
onSubmit: () => void;
onCancel: () => void;
parentFolder: FolderDTO;
onDismiss?: () => void;
parentFolder?: FolderDTO;
}
function setup(props: Partial<Props> = {}) {
function setup(props: Partial<Props> = {}, hookData = mockHookData) {
const user = userEvent.setup();
const defaultProps: Props = {
onSubmit: jest.fn(),
onCancel: jest.fn(),
onDismiss: jest.fn(),
parentFolder: {
id: 1,
uid: 'folder-uid',
@@ -108,6 +99,8 @@ function setup(props: Partial<Props> = {}) {
...props,
};
(useProvisionedFolderFormData as jest.Mock).mockReturnValue(hookData);
return {
user,
...render(<NewProvisionedFolderForm {...defaultProps} />),
@@ -123,6 +116,40 @@ const mockRequest = {
data: { resource: { upsert: { metadata: { name: 'new-folder' } } } },
};
const mockHookData: ProvisionedFolderFormDataResult = {
repository: {
name: 'test-repo',
title: 'Test Repository',
type: 'github',
workflows: ['write', 'branch'],
target: 'folder',
},
folder: {
metadata: {
annotations: {
'grafana.app/sourcePath': '/dashboards',
},
},
spec: {
title: '',
},
status: {},
},
workflowOptions: [
{ label: 'Commit directly', value: 'write' },
{ label: 'Create a branch', value: 'branch' },
],
isGitHub: true,
initialValues: {
title: '',
comment: '',
ref: 'folder/test-timestamp',
repo: 'test-repo',
path: '/dashboards',
workflow: 'write',
},
};
describe('NewProvisionedFolderForm', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -133,39 +160,11 @@ describe('NewProvisionedFolderForm', () => {
};
(getAppEvents as jest.Mock).mockReturnValue(mockAppEvents);
(useGetResourceRepositoryView as jest.Mock).mockReturnValue({
isLoading: false,
repository: {
name: 'test-repo',
title: 'Test Repository',
type: 'github',
github: {
url: 'https://github.com/grafana/grafana',
branch: 'main',
},
workflows: [{ name: 'default', path: 'workflows/default.json' }],
},
});
// Mock useGetFolderQuery
(useGetFolderQuery as jest.Mock).mockReturnValue({
data: {
metadata: {
annotations: {
'source.path': '/dashboards',
},
},
},
isLoading: false,
isError: false,
});
// Mock usePullRequestParam
(usePullRequestParam as jest.Mock).mockReturnValue(null);
// Mock useCreateRepositoryFilesWithPathMutation
const mockCreate = jest.fn();
(useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([mockCreate, mockRequest]);
(validationSrv.validateNewFolderName as jest.Mock).mockResolvedValue(true);
@@ -182,25 +181,27 @@ describe('NewProvisionedFolderForm', () => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('should show loading state when repository data is loading', () => {
(useGetResourceRepositoryView as jest.Mock).mockReturnValue({
isLoading: true,
});
setup();
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
it('should return null when initialValues is not available', () => {
const { container } = setup(
{},
{
...mockHookData,
initialValues: undefined,
}
);
expect(container.firstChild).toBeNull();
});
it('should show error when repository is not found', () => {
(useGetResourceRepositoryView as jest.Mock).mockReturnValue({
isLoading: false,
repository: undefined,
});
setup();
expect(screen.getByText('Repository not found')).toBeInTheDocument();
const { container } = setup(
{},
{
...mockHookData,
repository: undefined,
initialValues: undefined,
}
);
expect(container.firstChild).toBeNull();
});
it('should show branch field when branch workflow is selected', async () => {
@@ -289,6 +290,7 @@ describe('NewProvisionedFolderForm', () => {
expect.objectContaining({
ref: undefined, // write workflow uses undefined ref
name: 'test-repo',
path: '/dashboards/new-test-folder/',
message: 'Creating a new test folder',
body: {
title: 'New Test Folder',
@@ -298,8 +300,8 @@ describe('NewProvisionedFolderForm', () => {
);
});
// Check if onSubmit was called
expect(props.onSubmit).toHaveBeenCalled();
// Check if onDismiss was called
expect(props.onDismiss).toHaveBeenCalled();
});
it('should create folder with branch workflow', async () => {
@@ -341,6 +343,7 @@ describe('NewProvisionedFolderForm', () => {
expect.objectContaining({
ref: 'feature/new-folder',
name: 'test-repo',
path: '/dashboards/branch-folder/',
message: 'Create folder: Branch Folder',
body: {
title: 'Branch Folder',
@@ -415,33 +418,31 @@ describe('NewProvisionedFolderForm', () => {
expect(screen.getByRole('link')).toHaveTextContent('https://github.com/grafana/grafana/pull/1234');
});
it('should call onCancel when cancel button is clicked', async () => {
it('should call onDismiss when cancel button is clicked', async () => {
const { user, props } = setup();
// Click cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
// Check if onCancel was called
expect(props.onCancel).toHaveBeenCalled();
expect(props.onDismiss).toHaveBeenCalled();
});
it('should show read-only alert when repository has no workflows', () => {
// Mock repository with empty workflows array
(useGetResourceRepositoryView as jest.Mock).mockReturnValue({
repository: {
name: 'test-repo',
title: 'Test Repository',
type: 'github',
github: {
url: 'https://github.com/grafana/grafana',
branch: 'main',
setup(
{},
{
...mockHookData,
repository: {
name: 'test-repo',
title: 'Test Repository',
type: 'github',
workflows: [],
target: 'folder',
},
workflows: [],
},
});
setup();
}
);
// Read-only alert should be visible
expect(screen.getByText('This repository is read only')).toBeInTheDocument();
@@ -1,73 +1,52 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents } from '@grafana/runtime';
import { Alert, Button, Field, Input, RadioButtonGroup, Spinner, Stack, TextArea } from '@grafana/ui';
import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { Alert, Button, Field, Input, Stack } from '@grafana/ui';
import { Folder } from 'app/api/clients/folder/v1beta1';
import { RepositoryView, useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { AnnoKeySourcePath, Resource } from 'app/features/apiserver/types';
import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git';
import { FolderDTO } from 'app/types';
type FormData = {
ref?: string;
path: string;
comment?: string;
repo: string;
workflow?: WorkflowOption;
title: string;
};
import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
interface FormProps extends Props {
initialValues: BaseProvisionedFormData;
repository?: RepositoryView;
workflowOptions: Array<{ label: string; value: string }>;
folder?: Folder;
isGitHub: boolean;
}
interface Props {
onSubmit: () => void;
onCancel: () => void;
parentFolder?: FolderDTO;
onDismiss?: () => void;
}
const initialFormValues: Partial<FormData> = {
title: '',
comment: '',
ref: `folder/${Date.now()}`,
};
// TODO: use useProvisionedFolderFormData hook to manage form data and repository state
export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) {
const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: parentFolder?.uid });
function FormContent({ initialValues, repository, workflowOptions, folder, isGitHub, onDismiss }: FormProps) {
const prURL = usePullRequestParam();
const navigate = useNavigate();
const [create, request] = useCreateRepositoryFilesWithPathMutation();
const isGitHub = Boolean(repository?.type === 'github');
const {
register,
handleSubmit,
watch,
formState: { errors },
control,
setValue,
} = useForm<FormData>({ defaultValues: { ...initialFormValues, workflow: getDefaultWorkflow(repository) } });
const methods = useForm<BaseProvisionedFormData>({
defaultValues: initialValues,
mode: 'onBlur', // Validates when user leaves the field
});
const { handleSubmit, watch, register, formState } = methods;
const [workflow, ref] = watch(['workflow', 'ref']);
useEffect(() => {
setValue('workflow', getDefaultWorkflow(repository));
}, [repository, setValue]);
// TODO: replace with useProvisionedRequestHandler hook
useEffect(() => {
const appEvents = getAppEvents();
if (request.isSuccess && repository) {
onSubmit();
onDismiss?.();
appEvents.publish({
type: AppEvents.alertSuccess.name,
@@ -101,20 +80,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
],
});
}
}, [request.isSuccess, request.isError, request.error, onSubmit, ref, request.data, workflow, navigate, repository]);
if (isLoading) {
return <Spinner />;
}
if (!repository) {
return (
<Alert
title={t('browse-dashboards.new-provisioned-folder-form.title-repository-not-found', 'Repository not found')}
severity="error"
/>
);
}
}, [request.isSuccess, request.isError, request.error, ref, request.data, workflow, navigate, repository, onDismiss]);
const validateFolderName = async (folderName: string) => {
try {
@@ -128,7 +94,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
}
};
const doSave = async ({ ref, title, workflow, comment }: FormData) => {
const doSave = async ({ ref, title, workflow, comment }: BaseProvisionedFormData) => {
const repoName = repository?.name;
if (!title || !repoName) {
return;
@@ -163,107 +129,103 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
};
return (
<form onSubmit={handleSubmit(doSave)}>
<Stack direction="column" gap={2}>
{!repository?.workflows?.length && (
<Alert
title={t(
'browse-dashboards.new-provisioned-folder-form.title-this-repository-is-read-only',
'This repository is read only'
)}
<FormProvider {...methods}>
<form onSubmit={handleSubmit(doSave)}>
<Stack direction="column" gap={2}>
{!repository?.workflows?.length && (
<Alert
title={t(
'browse-dashboards.new-provisioned-folder-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="browse-dashboards.text-this-repository-is-read-only">
If you have direct access to the target, copy the JSON and paste it there.
</Trans>
</Alert>
)}
<Field
noMargin
label={t('browse-dashboards.new-provisioned-folder-form.label-folder-name', 'Folder name')}
invalid={!!formState.errors.title}
error={formState.errors.title?.message}
>
<Trans i18nKey="browse-dashboards.text-this-repository-is-read-only">
If you have direct access to the target, copy the JSON and paste it there.
</Trans>
</Alert>
)}
<Input
{...register('title', {
required: t('browse-dashboards.new-provisioned-folder-form.error-required', 'Folder name is required'),
validate: validateFolderName,
})}
placeholder={t(
'browse-dashboards.new-provisioned-folder-form.folder-name-input-placeholder-enter-folder-name',
'Enter folder name'
)}
id="folder-name-input"
/>
</Field>
<Field
label={t('browse-dashboards.new-provisioned-folder-form.label-folder-name', 'Folder name')}
invalid={!!errors.title}
error={errors.title?.message}
>
<Input
{...register('title', {
required: t('browse-dashboards.new-provisioned-folder-form.error-required', 'Folder name is required'),
validate: validateFolderName,
})}
placeholder={t(
'browse-dashboards.new-provisioned-folder-form.folder-name-input-placeholder-enter-folder-name',
'Enter folder name'
)}
id="folder-name-input"
<ResourceEditFormSharedFields
resourceType="folder"
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
hidePath
/>
</Field>
{/* TODO: use DashboardEditFormSharedFields to replace comment and workflow input*/}
<Field label={t('browse-dashboards.new-provisioned-folder-form.label-comment', 'Comment')}>
<TextArea
{...register('comment')}
placeholder={t(
'browse-dashboards.new-provisioned-folder-form.folder-comment-input-placeholder-describe-changes-optional',
'Add a note to describe your changes (optional)'
)}
id="folder-comment-input"
rows={5}
/>
</Field>
{prURL && (
<Alert
severity="info"
title={t(
'browse-dashboards.new-provisioned-folder-form.title-pull-request-created',
'Pull request created'
)}
>
<Trans i18nKey="browse-dashboards.new-provisioned-folder-form.text-pull-request-created">
A pull request has been created with changes to this folder:
</Trans>{' '}
<a href={prURL} target="_blank" rel="noopener noreferrer">
{prURL}
</a>
</Alert>
)}
{isGitHub && (
<>
<Field label={t('browse-dashboards.new-provisioned-folder-form.label-workflow', 'Workflow')}>
<Controller
control={control}
name="workflow"
render={({ field: { ref, ...field } }) => (
<RadioButtonGroup {...field} options={getWorkflowOptions(repository)} id={'folder-workflow'} />
)}
/>
</Field>
{workflow === 'branch' && (
<Field
label={t('browse-dashboards.new-provisioned-folder-form.label-branch', 'Branch')}
description={t(
'browse-dashboards.new-provisioned-folder-form.description-branch-name-in-git-hub',
'Branch name in GitHub'
)}
invalid={!!errors?.ref}
error={errors.ref ? <BranchValidationError /> : ''}
>
<Input {...register('ref', { validate: validateBranchName })} id="branch-name-input" />
</Field>
)}
</>
)}
{prURL && (
<Alert
severity="info"
title={t(
'browse-dashboards.new-provisioned-folder-form.title-pull-request-created',
'Pull request created'
)}
>
<Trans i18nKey="browse-dashboards.new-provisioned-folder-form.text-pull-request-created">
A pull request has been created with changes to this folder:
</Trans>{' '}
<a href={prURL} target="_blank" rel="noopener noreferrer">
{prURL}
</a>
</Alert>
)}
<Stack gap={2}>
<Button variant="secondary" fill="outline" onClick={onCancel}>
<Trans i18nKey="browse-dashboards.new-provisioned-folder-form.cancel">Cancel</Trans>
</Button>
<Button type="submit" disabled={request.isLoading}>
{request.isLoading
? t('browse-dashboards.new-provisioned-folder-form.button-creating', 'Creating...')
: t('browse-dashboards.new-provisioned-folder-form.button-create', 'Create')}
</Button>
<Stack gap={2}>
<Button variant="secondary" fill="outline" onClick={onDismiss}>
<Trans i18nKey="browse-dashboards.new-provisioned-folder-form.cancel">Cancel</Trans>
</Button>
<Button type="submit" disabled={request.isLoading}>
{request.isLoading
? t('browse-dashboards.new-provisioned-folder-form.button-creating', 'Creating...')
: t('browse-dashboards.new-provisioned-folder-form.button-create', 'Create')}
</Button>
</Stack>
</Stack>
</Stack>
</form>
</form>
</FormProvider>
);
}
export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) {
const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({
folderUid: parentFolder?.uid,
action: 'create',
title: parentFolder?.title,
});
if (!initialValues) {
return null;
}
return (
<FormContent
parentFolder={parentFolder}
onDismiss={onDismiss}
initialValues={initialValues}
repository={repository}
workflowOptions={workflowOptions}
folder={folder}
isGitHub={isGitHub}
/>
);
}
@@ -5,7 +5,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { ProvisionedDashboardFormData } from '../../saving/shared';
import { DashboardEditFormSharedFields } from './DashboardEditFormSharedFields';
import { ResourceEditFormSharedFields } from './ResourceEditFormSharedFields';
// Mock the i18n hook since it's used in the component
jest.mock('@grafana/i18n', () => ({
@@ -65,13 +65,13 @@ function setup(options: SetupOptions = {}) {
user,
...render(
<FormWrapper>
<DashboardEditFormSharedFields {...componentProps} resourceType="dashboard" />
<ResourceEditFormSharedFields {...componentProps} resourceType="dashboard" />
</FormWrapper>
),
};
}
describe('DashboardEditFormSharedFields', () => {
describe('ResourceEditFormSharedFields', () => {
describe('Basic Rendering', () => {
it('should render path and comment fields by default', () => {
setup();
@@ -186,7 +186,7 @@ describe('DashboardEditFormSharedFields', () => {
return (
<FormProvider {...methods}>
<DashboardEditFormSharedFields
<ResourceEditFormSharedFields
workflowOptions={[
{ label: 'Write directly', value: 'write' },
{ label: 'Create branch', value: 'branch' },
@@ -14,10 +14,11 @@ interface DashboardEditFormSharedFieldsProps {
readOnly?: boolean;
workflow?: WorkflowOption;
isGitHub?: boolean;
hidePath?: boolean;
}
export const DashboardEditFormSharedFields = memo<DashboardEditFormSharedFieldsProps>(
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew, resourceType }) => {
export const ResourceEditFormSharedFields = memo<DashboardEditFormSharedFieldsProps>(
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew, resourceType, hidePath = false }) => {
const {
control,
register,
@@ -32,16 +33,18 @@ export const DashboardEditFormSharedFields = memo<DashboardEditFormSharedFieldsP
return (
<>
{/* Path */}
<Field
noMargin
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-path', 'Path')}
description={t(
'provisioned-resource-form.save-or-delete-resource-shared-fields.description-inside-repository',
pathText
)}
>
<Input id="dashboard-path" type="text" {...register('path')} readOnly={!isNew} />
</Field>
{!hidePath && (
<Field
noMargin
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-path', 'Path')}
description={t(
'provisioned-resource-form.save-or-delete-resource-shared-fields.description-inside-repository',
pathText
)}
>
<Input id="dashboard-path" type="text" {...register('path')} readOnly={!isNew} />
</Field>
)}
{/* Comment */}
<Field
@@ -14,7 +14,7 @@ import { validationSrv } from 'app/features/manage-dashboards/services/Validatio
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
import { DashboardEditFormSharedFields } from '../../components/Provisioned/DashboardEditFormSharedFields';
import { ResourceEditFormSharedFields } from '../../components/Provisioned/ResourceEditFormSharedFields';
import { getDashboardUrl } from '../../utils/getDashboardUrl';
import { useProvisionedRequestHandler } from '../../utils/useProvisionedRequestHandler';
import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm';
@@ -214,7 +214,7 @@ export function SaveProvisionedDashboardForm({
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
<DashboardEditFormSharedFields
<ResourceEditFormSharedFields
resourceType="dashboard"
readOnly={readOnly}
workflow={workflow}
@@ -34,8 +34,8 @@ jest.mock('react-router-dom-v5-compat', () => ({
const mockNavigate = jest.fn();
// Mock shared form components
jest.mock('../components/Provisioned/DashboardEditFormSharedFields', () => ({
DashboardEditFormSharedFields: ({ disabled }: { disabled: boolean }) => (
jest.mock('../components/Provisioned/ResourceEditFormSharedFields', () => ({
ResourceEditFormSharedFields: ({ disabled }: { disabled: boolean }) => (
<textarea data-testid="shared-fields" disabled={disabled} />
),
}));
@@ -8,7 +8,7 @@ import { Alert, Button, Drawer, Stack } from '@grafana/ui';
import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { DashboardEditFormSharedFields } from '../components/Provisioned/DashboardEditFormSharedFields';
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
import { ProvisionedDashboardFormData } from '../saving/shared';
import { DashboardScene } from '../scene/DashboardScene';
import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler';
@@ -121,7 +121,7 @@ export function DeleteProvisionedDashboardForm({
</Alert>
)}
<DashboardEditFormSharedFields
<ResourceEditFormSharedFields
resourceType="dashboard"
isNew={isNew}
readOnly={readOnly}
-6
View File
@@ -3473,18 +3473,12 @@
"button-create": "Create",
"button-creating": "Creating...",
"cancel": "Cancel",
"description-branch-name-in-git-hub": "Branch name in GitHub",
"error-invalid-folder-name": "Invalid folder name",
"error-required": "Folder name is required",
"folder-comment-input-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
"folder-name-input-placeholder-enter-folder-name": "Enter folder name",
"label-branch": "Branch",
"label-comment": "Comment",
"label-folder-name": "Folder name",
"label-workflow": "Workflow",
"text-pull-request-created": "A pull request has been created with changes to this folder:",
"title-pull-request-created": "Pull request created",
"title-repository-not-found": "Repository not found",
"title-this-repository-is-read-only": "This repository is read only"
},
"no-results": {