Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+79
-78
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -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' },
|
||||
+15
-12
@@ -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
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user