Compare commits

..
Author SHA1 Message Date
idastambuk 817c3cc6c1 Focus on input search when changing visualizations 2025-12-17 12:59:08 +01:00
Dominik Prokop 52205fbf4f CustomVariable: Remove dead code and incorrect label (#115340)
* CustomVariable: Remove dead code and incorrect label

* Update i18n translations
2025-12-16 15:56:16 +01:00
Kristina Demeshchik 321e60e69c Dashboards: Add missing keyboard shortcuts for new layouts (#115377)
* missing key bindings

* Collapse repeted panels
2025-12-16 09:34:10 -05:00
Kristina Demeshchik c6dda2dfc3 Dashboards: exit edit mode after saving changes modal (#115380)
Exit edit mode with unsaved changes
2025-12-16 09:33:54 -05:00
Marc M. e03f7fe878 DynamicDashboards: prevent nested repeats based on the same variable (#114953) 2025-12-16 15:11:23 +01:00
Daniele Stefano Ferru 5ecfc79e14 Provisioning: Add Connection resource (#115272)
* Provisioning: Add Connection resource

* adding some more integration tests

* updating openapi snapshot, linting

* generating FE code, fixing issue in unit tests

* addressing comments

* addressing comments

* adding more integration tests

* fixing rebase issues

* removing linting exception

* addressing comments: improving validation and tests

* adding Connection URL at mutation time, updating tests accordingly

* linting
2025-12-16 14:37:07 +01:00
Alexander AkhmetovandKonrad Lalik c0295d06a3 Alerting: Add rule_matcher filter to Prometheus rules API (#115297)
**What is this feature?**

Add `rule_matcher` filter to the Prometheus-compatible list rules API: `/api/prometheus/grafana/api/v1/rules`. It allows to filter rules by static labels (not by alert instance labels).

**Special notes:**
  - Equality (`=`) and inequality (`!=`) matchers are pushed down to the database. Regex matchers (`=~`, `!~`) are applied in-memory at the API layer.
  - SQLite: Uses GLOB pattern matching
  - MySQL / PostgreSQL: Use JSON functions to compare label values


---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
2025-12-16 14:13:50 +01:00
Oscar Kilhed c7c1dd4ead V2 Schema: Fix panel Y position when converting from tabs to legacy rows in V2 -> v1 conversion. (#115373)
* fix row y conversion

* fix only for tabs

* add tests examples
2025-12-16 13:47:26 +01:00
Ivan Ortega Alba 195bf681d1 V2: Ensure refIds for queries (#115404)
Ensure refIds for queries
2025-12-16 13:34:08 +01:00
Tobias Skarhed 18837682cc Scopes: ScopesNavigation preload functionality (#115354)
* Add devenv configs

* Initial preload functionality

* Remove support for expandOnLoad

* Add tests

* Remove unnecessary go code
2025-12-16 13:01:03 +01:00
83 changed files with 8636 additions and 289 deletions
@@ -0,0 +1,715 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "adt885j",
"namespace": "default",
"uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX",
"resourceVersion": "2",
"generation": 2,
"creationTimestamp": "2025-12-16T10:44:31Z",
"labels": {
"grafana.app/deprecatedInternalID": "2409"
},
"annotations": {
"grafana.app/createdBy": "user:u000000001",
"grafana.app/updatedBy": "user:u000000001",
"grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z"
}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "grafana",
"version": "v0",
"datasource": {
"name": "-- Grafana --"
},
"spec": {}
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"builtIn": true
}
}
],
"cursorSync": "Off",
"description": "",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Panel1",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "grafana-testdata-datasource",
"version": "v0",
"datasource": {
"name": "PD8C576611E62080A"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "12.4.0-pre",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "Panel2",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "12.4.0-pre",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-3": {
"kind": "Panel",
"spec": {
"id": 3,
"title": "Panel3",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "12.4.0-pre",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
"id": 4,
"title": "Panel4",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "12.4.0-pre",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-5": {
"kind": "Panel",
"spec": {
"id": 5,
"title": "Panel5",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "12.4.0-pre",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
"kind": "TabsLayout",
"spec": {
"tabs": [
{
"kind": "TabsLayoutTab",
"spec": {
"title": "Tab1",
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 7,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 7,
"y": 0,
"width": 8,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-2"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 15,
"y": 0,
"width": 9,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-5"
}
}
}
]
}
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Dashboard with tabs",
"variables": []
},
"status": {},
"access": {
"slug": "dashboard-with-tabs",
"url": "/d/adt885j/dashboard-with-tabs",
"isPublic": false,
"canSave": true,
"canEdit": true,
"canAdmin": true,
"canStar": true,
"canDelete": true,
"annotationsPermissions": {
"dashboard": {
"canAdd": true,
"canEdit": true,
"canDelete": true
},
"organization": {
"canAdd": true,
"canEdit": true,
"canDelete": true
}
}
}
}
@@ -0,0 +1,511 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "adt885j",
"namespace": "default",
"uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX",
"resourceVersion": "2",
"generation": 2,
"creationTimestamp": "2025-12-16T10:44:31Z",
"labels": {
"grafana.app/deprecatedInternalID": "2409"
},
"annotations": {
"grafana.app/createdBy": "user:u000000001",
"grafana.app/updatedBy": "user:u000000001",
"grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z"
}
},
"spec": {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"description": "",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": -1,
"panels": [],
"title": "Tab1",
"type": "row"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 7,
"x": 0,
"y": 1
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "PD8C576611E62080A"
},
"refId": "A"
}
],
"title": "Panel1",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 8,
"x": 7,
"y": 1
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel2",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 9,
"x": 15,
"y": 1
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel3",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel4",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel5",
"type": "timeseries"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 42,
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "browser",
"title": "Dashboard with tabs"
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v2beta1"
}
}
}
@@ -0,0 +1,511 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "adt885j",
"namespace": "default",
"uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX",
"resourceVersion": "2",
"generation": 2,
"creationTimestamp": "2025-12-16T10:44:31Z",
"labels": {
"grafana.app/deprecatedInternalID": "2409"
},
"annotations": {
"grafana.app/createdBy": "user:u000000001",
"grafana.app/updatedBy": "user:u000000001",
"grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z"
}
},
"spec": {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"description": "",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": -1,
"panels": [],
"title": "Tab1",
"type": "row"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 7,
"x": 0,
"y": 1
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "PD8C576611E62080A"
},
"refId": "A"
}
],
"title": "Panel1",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 8,
"x": 7,
"y": 1
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel2",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 9,
"x": 15,
"y": 1
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel3",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel4",
"type": "timeseries"
},
{
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0-pre",
"targets": [
{
"refId": "A"
}
],
"title": "Panel5",
"type": "timeseries"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 42,
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "browser",
"title": "Dashboard with tabs"
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v2beta1"
}
}
}
@@ -0,0 +1,683 @@
{
"kind": "DashboardWithAccessInfo",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "adt885j",
"namespace": "default",
"uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX",
"resourceVersion": "2",
"generation": 2,
"creationTimestamp": "2025-12-16T10:44:31Z",
"labels": {
"grafana.app/deprecatedInternalID": "2409"
},
"annotations": {
"grafana.app/createdBy": "user:u000000001",
"grafana.app/updatedBy": "user:u000000001",
"grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z"
}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"query": {
"kind": "grafana",
"spec": {}
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"builtIn": true
}
}
],
"cursorSync": "Off",
"description": "",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Panel1",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "grafana-testdata-datasource",
"spec": {}
},
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "12.4.0-pre",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "Panel2",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "12.4.0-pre",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-3": {
"kind": "Panel",
"spec": {
"id": 3,
"title": "Panel3",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "12.4.0-pre",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
"id": 4,
"title": "Panel4",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "12.4.0-pre",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
},
"panel-5": {
"kind": "Panel",
"spec": {
"id": 5,
"title": "Panel5",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "12.4.0-pre",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"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",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
"kind": "TabsLayout",
"spec": {
"tabs": [
{
"kind": "TabsLayoutTab",
"spec": {
"title": "Tab1",
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 7,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 7,
"y": 0,
"width": 8,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-2"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 15,
"y": 0,
"width": 9,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-3"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-4"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 8,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-5"
}
}
}
]
}
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Dashboard with tabs",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v2beta1"
}
}
}
@@ -495,6 +495,9 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash
currentY = getMaxYFromPanels(nestedPanels, currentY)
} else if tab.Spec.Layout.GridLayoutKind != nil {
// GridLayout inside tab
baseY := currentY
maxY := currentY
for _, item := range tab.Spec.Layout.GridLayoutKind.Spec.Items {
element, ok := elements[item.Spec.Element.Name]
if !ok {
@@ -502,7 +505,7 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash
}
adjustedItem := item
adjustedItem.Spec.Y = item.Spec.Y + currentY
adjustedItem.Spec.Y = item.Spec.Y + baseY
panel, err := convertPanelFromElement(&element, &adjustedItem)
if err != nil {
@@ -511,10 +514,12 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash
panels = append(panels, panel)
panelEndY := adjustedItem.Spec.Y + item.Spec.Height
if panelEndY > currentY {
currentY = panelEndY
if panelEndY > maxY {
maxY = panelEndY
}
}
currentY = maxY
} else if tab.Spec.Layout.AutoGridLayoutKind != nil {
// AutoGridLayout inside tab - convert with Y offset
autoGridPanels, err := convertAutoGridLayoutToPanelsWithOffset(elements, tab.Spec.Layout.AutoGridLayoutKind, currentY)
+73
View File
@@ -0,0 +1,73 @@
package repository
connection: {
kind: "Connection"
pluralName: "Connections"
current: "v0alpha1"
validation: {
operations: [
"CREATE",
"UPDATE",
]
}
versions: {
"v0alpha1": {
codegen: {
ts: {enabled: false}
go: {enabled: true}
}
schema: {
#GitHubConnectionConfig: {
// App-level information
// GitHub App ID
appID: int
// Installation-level information
// GitHub App installation ID
installationID: int
}
#BitbucketConnectionConfig: {
// The app clientID
clientID: string
}
#GitlabConnectionConfig: {
// The app clientID
clientID: string
}
#HealthStatus: {
// When not healthy, requests will not be executed
healthy: bool
// When the health was checked last time
checked?: int
// Summary messages (can be shown to users)
// Will only be populated when not healthy
message?: [...string]
}
spec: {
// The connection provider type
type: "github" | "bitbucket" | "gitlab"
// The connection URL
url: *"" | string
// GitHub connection configuration
// Only applicable when provider is "github"
github?: #GitHubConnectionConfig
// Bitbucket connection configuration
// Only applicable when provider is "bitbucket"
bitbucket?: #BitbucketConnectionConfig
// Gitlab connection configuration
// Only applicable when provider is "gitlab"
gitlab?: #GitlabConnectionConfig
}
status: {
// The generation of the spec last time reconciliation ran
observedGeneration?: int
// Connection state
state: "connected" | "disconnected"
// The connection health status
health: #HealthStatus
}
}
}
}
}
+2 -1
View File
@@ -5,5 +5,6 @@ manifest: {
groupOverride: "provisioning.grafana.app"
kinds: [
repository,
connection
]
}
}
@@ -0,0 +1,118 @@
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// When this code is changed, make sure to update the code generation.
// As of writing, this can be done via the hack dir in the root of the repo: ./hack/update-codegen.sh provisioning
// If you've opened the generated files in this dir at some point in VSCode, you may also have to re-open them to clear errors.
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Connection struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ConnectionSpec `json:"spec,omitempty"`
Secure ConnectionSecure `json:"secure,omitzero,omitempty"`
Status ConnectionStatus `json:"status,omitempty"`
}
type ConnectionSecure struct {
// PrivateKey is the reference to the private key used for GitHub App authentication.
// This value is stored securely and cannot be read back
PrivateKey common.InlineSecureValue `json:"privateKey,omitzero,omitempty"`
// ClientSecret is the reference to the secret used for other providers authentication,
// and Github on-behalf-of authentication.
// This value is stored securely and cannot be read back
ClientSecret common.InlineSecureValue `json:"clientSecret,omitzero,omitempty"`
// Token is the reference of the token used to act as the Connection.
// This value is stored securely and cannot be read back
Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"`
}
func (v ConnectionSecure) IsZero() bool {
return v.PrivateKey.IsZero() && v.Token.IsZero()
}
type GitHubConnectionConfig struct {
// GitHub App ID
AppID string `json:"appID"`
// GitHub App installation ID
InstallationID string `json:"installationID"`
}
type BitbucketConnectionConfig struct {
// App client ID
ClientID string `json:"clientID"`
}
type GitlabConnectionConfig struct {
// App client ID
ClientID string `json:"clientID"`
}
// ConnectionType defines the types of Connection providers
// +enum
type ConnectionType string
// ConnectionType values.
const (
GithubConnectionType ConnectionType = "github"
GitlabConnectionType ConnectionType = "gitlab"
BitbucketConnectionType ConnectionType = "bitbucket"
)
type ConnectionSpec struct {
// The connection provider type
Type ConnectionType `json:"type"`
// The connection URL
URL string `json:"url,omitempty"`
// GitHub connection configuration
// Only applicable when provider is "github"
GitHub *GitHubConnectionConfig `json:"github,omitempty"`
// Bitbucket connection configuration
// Only applicable when provider is "bitbucket"
Bitbucket *BitbucketConnectionConfig `json:"bitbucket,omitempty"`
// Gitlab connection configuration
// Only applicable when provider is "gitlab"
Gitlab *GitlabConnectionConfig `json:"gitlab,omitempty"`
}
// ConnectionState defines the state of a Connection
// +enum
type ConnectionState string
// ConnectionState values
const (
ConnectionStateConnected ConnectionState = "connected"
ConnectionStateDisconnected ConnectionState = "disconnected"
)
// The status of a Connection.
// This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually.
type ConnectionStatus struct {
// The generation of the spec last time reconciliation ran
ObservedGeneration int64 `json:"observedGeneration"`
// Connection state
State ConnectionState `json:"state"`
// The connection health status
Health HealthStatus `json:"health"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ConnectionList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
// +listType=atomic
Items []Connection `json:"items"`
}
@@ -0,0 +1,26 @@
package v0alpha1
// HealthFailureType represents different types of healthcheck failures
// +enum
type HealthFailureType string
const (
HealthFailureHook HealthFailureType = "hook"
HealthFailureHealth HealthFailureType = "health"
)
type HealthStatus struct {
// When not healthy, requests will not be executed
Healthy bool `json:"healthy"`
// The type of the error
Error HealthFailureType `json:"error,omitempty"`
// When the health was checked last time
Checked int64 `json:"checked,omitempty"`
// Summary messages (can be shown to users)
// Will only be populated when not healthy
// +listType=atomic
Message []string `json:"message,omitempty"`
}
@@ -115,6 +115,47 @@ var HistoricJobResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
},
})
var ConnectionResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"connections", "connection", "Connection",
func() runtime.Object { return &Connection{} }, // newObj
func() runtime.Object { return &ConnectionList{} }, // newList
utils.TableColumns{ // Returned by `kubectl get`. Doesn't affect disk storage.
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
{Name: "Type", Type: "string"},
{Name: "AppID", Type: "string"},
{Name: "InstallationID", Type: "string"},
{Name: "ClientID", Type: "string"},
},
Reader: func(obj any) ([]interface{}, error) {
m, ok := obj.(*Connection)
if !ok {
return nil, errors.New("expected Repository")
}
var appID, installationID, clientID string
switch m.Spec.Type {
case GithubConnectionType:
appID = m.Spec.GitHub.AppID
installationID = m.Spec.GitHub.InstallationID
case BitbucketConnectionType:
clientID = m.Spec.Bitbucket.ClientID
case GitlabConnectionType:
clientID = m.Spec.Gitlab.ClientID
}
return []interface{}{
m.Name,
m.CreationTimestamp.UTC().Format(time.RFC3339),
m.Spec.Type,
appID,
installationID,
clientID,
}, nil
},
})
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
@@ -154,6 +195,8 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error {
&RefList{},
&HistoricJob{},
&HistoricJobList{},
&Connection{},
&ConnectionList{},
)
return nil
}
@@ -315,31 +315,6 @@ type RepositoryStatus struct {
DeleteError string `json:"deleteError,omitempty"`
}
// HealthFailureType represents different types of repository failures
// +enum
type HealthFailureType string
const (
HealthFailureHook HealthFailureType = "hook"
HealthFailureHealth HealthFailureType = "health"
)
type HealthStatus struct {
// When not healthy, requests will not be executed
Healthy bool `json:"healthy"`
// The type of the error
Error HealthFailureType `json:"error,omitempty"`
// When the health was checked last time
Checked int64 `json:"checked,omitempty"`
// Summary messages (can be shown to users)
// Will only be populated when not healthy
// +listType=atomic
Message []string `json:"message,omitempty"`
}
type SyncStatus struct {
// pending, running, success, error
State JobState `json:"state"`
@@ -27,6 +27,22 @@ func (in *Author) DeepCopy() *Author {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BitbucketConnectionConfig) DeepCopyInto(out *BitbucketConnectionConfig) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitbucketConnectionConfig.
func (in *BitbucketConnectionConfig) DeepCopy() *BitbucketConnectionConfig {
if in == nil {
return nil
}
out := new(BitbucketConnectionConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BitbucketRepositoryConfig) DeepCopyInto(out *BitbucketRepositoryConfig) {
*out = *in
@@ -43,6 +59,135 @@ func (in *BitbucketRepositoryConfig) DeepCopy() *BitbucketRepositoryConfig {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Connection) DeepCopyInto(out *Connection) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
out.Secure = in.Secure
in.Status.DeepCopyInto(&out.Status)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Connection.
func (in *Connection) DeepCopy() *Connection {
if in == nil {
return nil
}
out := new(Connection)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Connection) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConnectionList) DeepCopyInto(out *ConnectionList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Connection, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionList.
func (in *ConnectionList) DeepCopy() *ConnectionList {
if in == nil {
return nil
}
out := new(ConnectionList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ConnectionList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConnectionSecure) DeepCopyInto(out *ConnectionSecure) {
*out = *in
out.PrivateKey = in.PrivateKey
out.ClientSecret = in.ClientSecret
out.Token = in.Token
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSecure.
func (in *ConnectionSecure) DeepCopy() *ConnectionSecure {
if in == nil {
return nil
}
out := new(ConnectionSecure)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConnectionSpec) DeepCopyInto(out *ConnectionSpec) {
*out = *in
if in.GitHub != nil {
in, out := &in.GitHub, &out.GitHub
*out = new(GitHubConnectionConfig)
**out = **in
}
if in.Bitbucket != nil {
in, out := &in.Bitbucket, &out.Bitbucket
*out = new(BitbucketConnectionConfig)
**out = **in
}
if in.Gitlab != nil {
in, out := &in.Gitlab, &out.Gitlab
*out = new(GitlabConnectionConfig)
**out = **in
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSpec.
func (in *ConnectionSpec) DeepCopy() *ConnectionSpec {
if in == nil {
return nil
}
out := new(ConnectionSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConnectionStatus) DeepCopyInto(out *ConnectionStatus) {
*out = *in
in.Health.DeepCopyInto(&out.Health)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionStatus.
func (in *ConnectionStatus) DeepCopy() *ConnectionStatus {
if in == nil {
return nil
}
out := new(ConnectionStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DeleteJobOptions) DeepCopyInto(out *DeleteJobOptions) {
*out = *in
@@ -148,6 +293,22 @@ func (in *FileList) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GitHubConnectionConfig) DeepCopyInto(out *GitHubConnectionConfig) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubConnectionConfig.
func (in *GitHubConnectionConfig) DeepCopy() *GitHubConnectionConfig {
if in == nil {
return nil
}
out := new(GitHubConnectionConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GitHubRepositoryConfig) DeepCopyInto(out *GitHubRepositoryConfig) {
*out = *in
@@ -196,6 +357,22 @@ func (in *GitRepositoryConfig) DeepCopy() *GitRepositoryConfig {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GitlabConnectionConfig) DeepCopyInto(out *GitlabConnectionConfig) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitlabConnectionConfig.
func (in *GitlabConnectionConfig) DeepCopy() *GitlabConnectionConfig {
if in == nil {
return nil
}
out := new(GitlabConnectionConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HealthStatus) DeepCopyInto(out *HealthStatus) {
*out = *in
@@ -15,15 +15,23 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_BitbucketConnectionConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_BitbucketRepositoryConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Connection": schema_pkg_apis_provisioning_v0alpha1_Connection(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionList": schema_pkg_apis_provisioning_v0alpha1_ConnectionList(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSecure": schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSpec": schema_pkg_apis_provisioning_v0alpha1_ConnectionSpec(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionStatus": schema_pkg_apis_provisioning_v0alpha1_ConnectionStatus(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.DeleteJobOptions": schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubConnectionConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitLabRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitLabRepositoryConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitlabConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_GitlabConnectionConfig(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus": schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJob": schema_pkg_apis_provisioning_v0alpha1_HistoricJob(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJobList": schema_pkg_apis_provisioning_v0alpha1_HistoricJobList(ref),
@@ -100,6 +108,27 @@ func schema_pkg_apis_provisioning_v0alpha1_Author(ref common.ReferenceCallback)
}
}
func schema_pkg_apis_provisioning_v0alpha1_BitbucketConnectionConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"clientID": {
SchemaProps: spec.SchemaProps{
Description: "App client ID",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"clientID"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_BitbucketRepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -142,6 +171,236 @@ func schema_pkg_apis_provisioning_v0alpha1_BitbucketRepositoryConfig(ref common.
}
}
func schema_pkg_apis_provisioning_v0alpha1_Connection(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "When this code is changed, make sure to update the code generation. As of writing, this can be done via the hack dir in the root of the repo: ./hack/update-codegen.sh provisioning If you've opened the generated files in this dir at some point in VSCode, you may also have to re-open them to clear errors.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSpec"),
},
},
"secure": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSecure"),
},
},
"status": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionStatus"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSecure", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSpec", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ConnectionList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Connection"),
},
},
},
},
},
},
Required: []string{"items"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Connection", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"privateKey": {
SchemaProps: spec.SchemaProps{
Description: "PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
},
},
"clientSecret": {
SchemaProps: spec.SchemaProps{
Description: "ClientSecret is the reference to the secret used for other providers authentication, and Github on-behalf-of authentication. This value is stored securely and cannot be read back",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
},
},
"webhook": {
SchemaProps: spec.SchemaProps{
Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ConnectionSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"type": {
SchemaProps: spec.SchemaProps{
Description: "The connection provider type\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"github\"`\n - `\"gitlab\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"bitbucket", "github", "gitlab"},
},
},
"url": {
SchemaProps: spec.SchemaProps{
Description: "The connection URL",
Type: []string{"string"},
Format: "",
},
},
"github": {
SchemaProps: spec.SchemaProps{
Description: "GitHub connection configuration Only applicable when provider is \"github\"",
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig"),
},
},
"bitbucket": {
SchemaProps: spec.SchemaProps{
Description: "Bitbucket connection configuration Only applicable when provider is \"bitbucket\"",
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketConnectionConfig"),
},
},
"gitlab": {
SchemaProps: spec.SchemaProps{
Description: "Gitlab connection configuration Only applicable when provider is \"gitlab\"",
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitlabConnectionConfig"),
},
},
},
Required: []string{"type"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketConnectionConfig", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitlabConnectionConfig"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ConnectionStatus(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "The status of a Connection. This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"observedGeneration": {
SchemaProps: spec.SchemaProps{
Description: "The generation of the spec last time reconciliation ran",
Default: 0,
Type: []string{"integer"},
Format: "int64",
},
},
"state": {
SchemaProps: spec.SchemaProps{
Description: "Connection state\n\nPossible enum values:\n - `\"connected\"`\n - `\"disconnected\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"connected", "disconnected"},
},
},
"health": {
SchemaProps: spec.SchemaProps{
Description: "The connection health status",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus"),
},
},
},
Required: []string{"observedGeneration", "state", "health"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -362,6 +621,35 @@ func schema_pkg_apis_provisioning_v0alpha1_FileList(ref common.ReferenceCallback
}
}
func schema_pkg_apis_provisioning_v0alpha1_GitHubConnectionConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"appID": {
SchemaProps: spec.SchemaProps{
Description: "GitHub App ID",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"installationID": {
SchemaProps: spec.SchemaProps{
Description: "GitHub App installation ID",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"appID", "installationID"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -481,6 +769,27 @@ func schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref common.Refere
}
}
func schema_pkg_apis_provisioning_v0alpha1_GitlabConnectionConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"clientID": {
SchemaProps: spec.SchemaProps{
Description: "App client ID",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"clientID"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -1,3 +1,4 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
@@ -20,6 +21,8 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs
API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Identity
@@ -0,0 +1,28 @@
package connection
import (
"fmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
const (
githubInstallationURL = "https://github.com/settings/installations"
)
func MutateConnection(connection *provisioning.Connection) error {
switch connection.Spec.Type {
case provisioning.GithubConnectionType:
// Do nothing in case spec.Github is nil.
// If this field is required, we should fail at validation time.
if connection.Spec.GitHub == nil {
return nil
}
connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID)
return nil
default:
// TODO: we need to setup the URL for bitbucket and gitlab.
return nil
}
}
@@ -0,0 +1,35 @@
package connection_test
import (
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestMutateConnection(t *testing.T) {
t.Run("should add URL to Github connection", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
},
}
require.NoError(t, connection.MutateConnection(c))
assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL)
})
}
@@ -0,0 +1,104 @@
package connection
import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"
)
func ValidateConnection(connection *provisioning.Connection) error {
list := field.ErrorList{}
if connection.Spec.Type == "" {
list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified"))
}
switch connection.Spec.Type {
case provisioning.GithubConnectionType:
list = append(list, validateGithubConnection(connection)...)
case provisioning.BitbucketConnectionType:
list = append(list, validateBitbucketConnection(connection)...)
case provisioning.GitlabConnectionType:
list = append(list, validateGitlabConnection(connection)...)
default:
list = append(
list, field.NotSupported(
field.NewPath("spec", "type"),
connection.Spec.Type,
[]provisioning.ConnectionType{
provisioning.GithubConnectionType,
provisioning.BitbucketConnectionType,
provisioning.GitlabConnectionType,
}),
)
}
return toError(connection.GetName(), list)
}
func validateGithubConnection(connection *provisioning.Connection) field.ErrorList {
list := field.ErrorList{}
if connection.Spec.GitHub == nil {
list = append(
list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"),
)
}
if connection.Secure.PrivateKey.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection"))
}
if !connection.Secure.ClientSecret.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection"))
}
return list
}
func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList {
list := field.ErrorList{}
if connection.Spec.Bitbucket == nil {
list = append(
list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"),
)
}
if connection.Secure.ClientSecret.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection"))
}
if !connection.Secure.PrivateKey.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection"))
}
return list
}
func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList {
list := field.ErrorList{}
if connection.Spec.Gitlab == nil {
list = append(
list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"),
)
}
if connection.Secure.ClientSecret.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection"))
}
if !connection.Secure.PrivateKey.IsZero() {
list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection"))
}
return list
}
// toError converts a field.ErrorList to an error, returning nil if the list is empty
func toError(name string, list field.ErrorList) error {
if len(list) == 0 {
return nil
}
return apierrors.NewInvalid(
provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(),
name,
list,
)
}
@@ -0,0 +1,253 @@
package connection_test
import (
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/connection"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestValidateConnection(t *testing.T) {
tests := []struct {
name string
connection *provisioning.Connection
wantErr bool
errMsg string
}{
{
name: "empty type returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{},
},
wantErr: true,
errMsg: "spec.type",
},
{
name: "invalid type returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: "invalid",
},
},
wantErr: true,
errMsg: "spec.type",
},
{
name: "github type without github config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
},
},
wantErr: true,
errMsg: "spec.github",
},
{
name: "github type without private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
},
wantErr: true,
errMsg: "secure.privateKey",
},
{
name: "github type with client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: true,
errMsg: "secure.clientSecret",
},
{
name: "github type with github config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
},
},
wantErr: false,
},
{
name: "bitbucket type without bitbucket config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
},
},
wantErr: true,
errMsg: "spec.bitbucket",
},
{
name: "bitbucket type without client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
Bitbucket: &provisioning.BitbucketConnectionConfig{
ClientID: "client-123",
},
},
},
wantErr: true,
errMsg: "secure.clientSecret",
},
{
name: "bitbucket type with private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
Bitbucket: &provisioning.BitbucketConnectionConfig{
ClientID: "client-123",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: true,
errMsg: "secure.privateKey",
},
{
name: "bitbucket type with bitbucket config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.BitbucketConnectionType,
Bitbucket: &provisioning.BitbucketConnectionConfig{
ClientID: "client-123",
},
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: false,
},
{
name: "gitlab type without gitlab config returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
},
},
wantErr: true,
errMsg: "spec.gitlab",
},
{
name: "gitlab type without client secret returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "client-456",
},
},
},
wantErr: true,
errMsg: "secure.clientSecret",
},
{
name: "gitlab type with private key returns error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "client-456",
},
},
Secure: provisioning.ConnectionSecure{
PrivateKey: common.InlineSecureValue{
Name: "test-private-key",
},
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: true,
errMsg: "secure.privateKey",
},
{
name: "gitlab type with gitlab config is valid",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
Gitlab: &provisioning.GitlabConnectionConfig{
ClientID: "client-456",
},
},
Secure: provisioning.ConnectionSecure{
ClientSecret: common.InlineSecureValue{
Name: "test-client-secret",
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := connection.ValidateConnection(tt.connection)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
// BitbucketConnectionConfigApplyConfiguration represents a declarative configuration of the BitbucketConnectionConfig type for use
// with apply.
type BitbucketConnectionConfigApplyConfiguration struct {
ClientID *string `json:"clientID,omitempty"`
}
// BitbucketConnectionConfigApplyConfiguration constructs a declarative configuration of the BitbucketConnectionConfig type for use with
// apply.
func BitbucketConnectionConfig() *BitbucketConnectionConfigApplyConfiguration {
return &BitbucketConnectionConfigApplyConfiguration{}
}
// WithClientID sets the ClientID field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the ClientID field is set to the value of the last call.
func (b *BitbucketConnectionConfigApplyConfiguration) WithClientID(value string) *BitbucketConnectionConfigApplyConfiguration {
b.ClientID = &value
return b
}
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
types "k8s.io/apimachinery/pkg/types"
v1 "k8s.io/client-go/applyconfigurations/meta/v1"
)
// ConnectionApplyConfiguration represents a declarative configuration of the Connection type for use
// with apply.
type ConnectionApplyConfiguration struct {
v1.TypeMetaApplyConfiguration `json:",inline"`
*v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"`
Spec *ConnectionSpecApplyConfiguration `json:"spec,omitempty"`
Secure *ConnectionSecureApplyConfiguration `json:"secure,omitempty"`
Status *ConnectionStatusApplyConfiguration `json:"status,omitempty"`
}
// Connection constructs a declarative configuration of the Connection type for use with
// apply.
func Connection(name, namespace string) *ConnectionApplyConfiguration {
b := &ConnectionApplyConfiguration{}
b.WithName(name)
b.WithNamespace(namespace)
b.WithKind("Connection")
b.WithAPIVersion("provisioning.grafana.app/v0alpha1")
return b
}
func (b ConnectionApplyConfiguration) IsApplyConfiguration() {}
// WithKind sets the Kind field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Kind field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithKind(value string) *ConnectionApplyConfiguration {
b.TypeMetaApplyConfiguration.Kind = &value
return b
}
// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the APIVersion field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithAPIVersion(value string) *ConnectionApplyConfiguration {
b.TypeMetaApplyConfiguration.APIVersion = &value
return b
}
// WithName sets the Name field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Name field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithName(value string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.Name = &value
return b
}
// WithGenerateName sets the GenerateName field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the GenerateName field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithGenerateName(value string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.GenerateName = &value
return b
}
// WithNamespace sets the Namespace field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Namespace field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithNamespace(value string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.Namespace = &value
return b
}
// WithUID sets the UID field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the UID field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithUID(value types.UID) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.UID = &value
return b
}
// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the ResourceVersion field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithResourceVersion(value string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.ResourceVersion = &value
return b
}
// WithGeneration sets the Generation field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Generation field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithGeneration(value int64) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.Generation = &value
return b
}
// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the CreationTimestamp field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithCreationTimestamp(value metav1.Time) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.CreationTimestamp = &value
return b
}
// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the DeletionTimestamp field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value
return b
}
// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value
return b
}
// WithLabels puts the entries into the Labels field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, the entries provided by each call will be put on the Labels field,
// overwriting an existing map entries in Labels field with the same key.
func (b *ConnectionApplyConfiguration) WithLabels(entries map[string]string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 {
b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries))
}
for k, v := range entries {
b.ObjectMetaApplyConfiguration.Labels[k] = v
}
return b
}
// WithAnnotations puts the entries into the Annotations field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, the entries provided by each call will be put on the Annotations field,
// overwriting an existing map entries in Annotations field with the same key.
func (b *ConnectionApplyConfiguration) WithAnnotations(entries map[string]string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 {
b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries))
}
for k, v := range entries {
b.ObjectMetaApplyConfiguration.Annotations[k] = v
}
return b
}
// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the OwnerReferences field.
func (b *ConnectionApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
for i := range values {
if values[i] == nil {
panic("nil value passed to WithOwnerReferences")
}
b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i])
}
return b
}
// WithFinalizers adds the given value to the Finalizers field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Finalizers field.
func (b *ConnectionApplyConfiguration) WithFinalizers(values ...string) *ConnectionApplyConfiguration {
b.ensureObjectMetaApplyConfigurationExists()
for i := range values {
b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i])
}
return b
}
func (b *ConnectionApplyConfiguration) ensureObjectMetaApplyConfigurationExists() {
if b.ObjectMetaApplyConfiguration == nil {
b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{}
}
}
// WithSpec sets the Spec field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Spec field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithSpec(value *ConnectionSpecApplyConfiguration) *ConnectionApplyConfiguration {
b.Spec = value
return b
}
// WithSecure sets the Secure field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Secure field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithSecure(value *ConnectionSecureApplyConfiguration) *ConnectionApplyConfiguration {
b.Secure = value
return b
}
// WithStatus sets the Status field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Status field is set to the value of the last call.
func (b *ConnectionApplyConfiguration) WithStatus(value *ConnectionStatusApplyConfiguration) *ConnectionApplyConfiguration {
b.Status = value
return b
}
// GetKind retrieves the value of the Kind field in the declarative configuration.
func (b *ConnectionApplyConfiguration) GetKind() *string {
return b.TypeMetaApplyConfiguration.Kind
}
// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration.
func (b *ConnectionApplyConfiguration) GetAPIVersion() *string {
return b.TypeMetaApplyConfiguration.APIVersion
}
// GetName retrieves the value of the Name field in the declarative configuration.
func (b *ConnectionApplyConfiguration) GetName() *string {
b.ensureObjectMetaApplyConfigurationExists()
return b.ObjectMetaApplyConfiguration.Name
}
// GetNamespace retrieves the value of the Namespace field in the declarative configuration.
func (b *ConnectionApplyConfiguration) GetNamespace() *string {
b.ensureObjectMetaApplyConfigurationExists()
return b.ObjectMetaApplyConfiguration.Namespace
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
import (
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// ConnectionSecureApplyConfiguration represents a declarative configuration of the ConnectionSecure type for use
// with apply.
type ConnectionSecureApplyConfiguration struct {
PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"`
ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"`
Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"`
}
// ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with
// apply.
func ConnectionSecure() *ConnectionSecureApplyConfiguration {
return &ConnectionSecureApplyConfiguration{}
}
// WithPrivateKey sets the PrivateKey field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the PrivateKey field is set to the value of the last call.
func (b *ConnectionSecureApplyConfiguration) WithPrivateKey(value commonv0alpha1.InlineSecureValue) *ConnectionSecureApplyConfiguration {
b.PrivateKey = &value
return b
}
// WithClientSecret sets the ClientSecret field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the ClientSecret field is set to the value of the last call.
func (b *ConnectionSecureApplyConfiguration) WithClientSecret(value commonv0alpha1.InlineSecureValue) *ConnectionSecureApplyConfiguration {
b.ClientSecret = &value
return b
}
// WithToken sets the Token field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Token field is set to the value of the last call.
func (b *ConnectionSecureApplyConfiguration) WithToken(value commonv0alpha1.InlineSecureValue) *ConnectionSecureApplyConfiguration {
b.Token = &value
return b
}
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
import (
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// ConnectionSpecApplyConfiguration represents a declarative configuration of the ConnectionSpec type for use
// with apply.
type ConnectionSpecApplyConfiguration struct {
Type *provisioningv0alpha1.ConnectionType `json:"type,omitempty"`
URL *string `json:"url,omitempty"`
GitHub *GitHubConnectionConfigApplyConfiguration `json:"github,omitempty"`
Bitbucket *BitbucketConnectionConfigApplyConfiguration `json:"bitbucket,omitempty"`
Gitlab *GitlabConnectionConfigApplyConfiguration `json:"gitlab,omitempty"`
}
// ConnectionSpecApplyConfiguration constructs a declarative configuration of the ConnectionSpec type for use with
// apply.
func ConnectionSpec() *ConnectionSpecApplyConfiguration {
return &ConnectionSpecApplyConfiguration{}
}
// WithType sets the Type field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Type field is set to the value of the last call.
func (b *ConnectionSpecApplyConfiguration) WithType(value provisioningv0alpha1.ConnectionType) *ConnectionSpecApplyConfiguration {
b.Type = &value
return b
}
// WithURL sets the URL field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the URL field is set to the value of the last call.
func (b *ConnectionSpecApplyConfiguration) WithURL(value string) *ConnectionSpecApplyConfiguration {
b.URL = &value
return b
}
// WithGitHub sets the GitHub field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the GitHub field is set to the value of the last call.
func (b *ConnectionSpecApplyConfiguration) WithGitHub(value *GitHubConnectionConfigApplyConfiguration) *ConnectionSpecApplyConfiguration {
b.GitHub = value
return b
}
// WithBitbucket sets the Bitbucket field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Bitbucket field is set to the value of the last call.
func (b *ConnectionSpecApplyConfiguration) WithBitbucket(value *BitbucketConnectionConfigApplyConfiguration) *ConnectionSpecApplyConfiguration {
b.Bitbucket = value
return b
}
// WithGitlab sets the Gitlab field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Gitlab field is set to the value of the last call.
func (b *ConnectionSpecApplyConfiguration) WithGitlab(value *GitlabConnectionConfigApplyConfiguration) *ConnectionSpecApplyConfiguration {
b.Gitlab = value
return b
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
import (
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// ConnectionStatusApplyConfiguration represents a declarative configuration of the ConnectionStatus type for use
// with apply.
type ConnectionStatusApplyConfiguration struct {
ObservedGeneration *int64 `json:"observedGeneration,omitempty"`
State *provisioningv0alpha1.ConnectionState `json:"state,omitempty"`
Health *HealthStatusApplyConfiguration `json:"health,omitempty"`
}
// ConnectionStatusApplyConfiguration constructs a declarative configuration of the ConnectionStatus type for use with
// apply.
func ConnectionStatus() *ConnectionStatusApplyConfiguration {
return &ConnectionStatusApplyConfiguration{}
}
// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the ObservedGeneration field is set to the value of the last call.
func (b *ConnectionStatusApplyConfiguration) WithObservedGeneration(value int64) *ConnectionStatusApplyConfiguration {
b.ObservedGeneration = &value
return b
}
// WithState sets the State field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the State field is set to the value of the last call.
func (b *ConnectionStatusApplyConfiguration) WithState(value provisioningv0alpha1.ConnectionState) *ConnectionStatusApplyConfiguration {
b.State = &value
return b
}
// WithHealth sets the Health field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Health field is set to the value of the last call.
func (b *ConnectionStatusApplyConfiguration) WithHealth(value *HealthStatusApplyConfiguration) *ConnectionStatusApplyConfiguration {
b.Health = value
return b
}
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
// GitHubConnectionConfigApplyConfiguration represents a declarative configuration of the GitHubConnectionConfig type for use
// with apply.
type GitHubConnectionConfigApplyConfiguration struct {
AppID *string `json:"appID,omitempty"`
InstallationID *string `json:"installationID,omitempty"`
}
// GitHubConnectionConfigApplyConfiguration constructs a declarative configuration of the GitHubConnectionConfig type for use with
// apply.
func GitHubConnectionConfig() *GitHubConnectionConfigApplyConfiguration {
return &GitHubConnectionConfigApplyConfiguration{}
}
// WithAppID sets the AppID field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the AppID field is set to the value of the last call.
func (b *GitHubConnectionConfigApplyConfiguration) WithAppID(value string) *GitHubConnectionConfigApplyConfiguration {
b.AppID = &value
return b
}
// WithInstallationID sets the InstallationID field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the InstallationID field is set to the value of the last call.
func (b *GitHubConnectionConfigApplyConfiguration) WithInstallationID(value string) *GitHubConnectionConfigApplyConfiguration {
b.InstallationID = &value
return b
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
// GitlabConnectionConfigApplyConfiguration represents a declarative configuration of the GitlabConnectionConfig type for use
// with apply.
type GitlabConnectionConfigApplyConfiguration struct {
ClientID *string `json:"clientID,omitempty"`
}
// GitlabConnectionConfigApplyConfiguration constructs a declarative configuration of the GitlabConnectionConfig type for use with
// apply.
func GitlabConnectionConfig() *GitlabConnectionConfigApplyConfiguration {
return &GitlabConnectionConfigApplyConfiguration{}
}
// WithClientID sets the ClientID field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the ClientID field is set to the value of the last call.
func (b *GitlabConnectionConfigApplyConfiguration) WithClientID(value string) *GitlabConnectionConfigApplyConfiguration {
b.ClientID = &value
return b
}
@@ -18,14 +18,28 @@ import (
func ForKind(kind schema.GroupVersionKind) interface{} {
switch kind {
// Group=provisioning.grafana.app, Version=v0alpha1
case v0alpha1.SchemeGroupVersion.WithKind("BitbucketConnectionConfig"):
return &provisioningv0alpha1.BitbucketConnectionConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("BitbucketRepositoryConfig"):
return &provisioningv0alpha1.BitbucketRepositoryConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("Connection"):
return &provisioningv0alpha1.ConnectionApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("ConnectionSecure"):
return &provisioningv0alpha1.ConnectionSecureApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("ConnectionSpec"):
return &provisioningv0alpha1.ConnectionSpecApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("ConnectionStatus"):
return &provisioningv0alpha1.ConnectionStatusApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("DeleteJobOptions"):
return &provisioningv0alpha1.DeleteJobOptionsApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("ExportJobOptions"):
return &provisioningv0alpha1.ExportJobOptionsApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("GitHubConnectionConfig"):
return &provisioningv0alpha1.GitHubConnectionConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("GitHubRepositoryConfig"):
return &provisioningv0alpha1.GitHubRepositoryConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("GitlabConnectionConfig"):
return &provisioningv0alpha1.GitlabConnectionConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("GitLabRepositoryConfig"):
return &provisioningv0alpha1.GitLabRepositoryConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("GitRepositoryConfig"):
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by client-gen. DO NOT EDIT.
package v0alpha1
import (
context "context"
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
applyconfigurationprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1"
scheme "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/scheme"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
types "k8s.io/apimachinery/pkg/types"
watch "k8s.io/apimachinery/pkg/watch"
gentype "k8s.io/client-go/gentype"
)
// ConnectionsGetter has a method to return a ConnectionInterface.
// A group's client should implement this interface.
type ConnectionsGetter interface {
Connections(namespace string) ConnectionInterface
}
// ConnectionInterface has methods to work with Connection resources.
type ConnectionInterface interface {
Create(ctx context.Context, connection *provisioningv0alpha1.Connection, opts v1.CreateOptions) (*provisioningv0alpha1.Connection, error)
Update(ctx context.Context, connection *provisioningv0alpha1.Connection, opts v1.UpdateOptions) (*provisioningv0alpha1.Connection, error)
// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
UpdateStatus(ctx context.Context, connection *provisioningv0alpha1.Connection, opts v1.UpdateOptions) (*provisioningv0alpha1.Connection, error)
Delete(ctx context.Context, name string, opts v1.DeleteOptions) error
DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error
Get(ctx context.Context, name string, opts v1.GetOptions) (*provisioningv0alpha1.Connection, error)
List(ctx context.Context, opts v1.ListOptions) (*provisioningv0alpha1.ConnectionList, error)
Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)
Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *provisioningv0alpha1.Connection, err error)
Apply(ctx context.Context, connection *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration, opts v1.ApplyOptions) (result *provisioningv0alpha1.Connection, err error)
// Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus().
ApplyStatus(ctx context.Context, connection *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration, opts v1.ApplyOptions) (result *provisioningv0alpha1.Connection, err error)
ConnectionExpansion
}
// connections implements ConnectionInterface
type connections struct {
*gentype.ClientWithListAndApply[*provisioningv0alpha1.Connection, *provisioningv0alpha1.ConnectionList, *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration]
}
// newConnections returns a Connections
func newConnections(c *ProvisioningV0alpha1Client, namespace string) *connections {
return &connections{
gentype.NewClientWithListAndApply[*provisioningv0alpha1.Connection, *provisioningv0alpha1.ConnectionList, *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration](
"connections",
c.RESTClient(),
scheme.ParameterCodec,
namespace,
func() *provisioningv0alpha1.Connection { return &provisioningv0alpha1.Connection{} },
func() *provisioningv0alpha1.ConnectionList { return &provisioningv0alpha1.ConnectionList{} },
),
}
}
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by client-gen. DO NOT EDIT.
package fake
import (
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1"
typedprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
gentype "k8s.io/client-go/gentype"
)
// fakeConnections implements ConnectionInterface
type fakeConnections struct {
*gentype.FakeClientWithListAndApply[*v0alpha1.Connection, *v0alpha1.ConnectionList, *provisioningv0alpha1.ConnectionApplyConfiguration]
Fake *FakeProvisioningV0alpha1
}
func newFakeConnections(fake *FakeProvisioningV0alpha1, namespace string) typedprovisioningv0alpha1.ConnectionInterface {
return &fakeConnections{
gentype.NewFakeClientWithListAndApply[*v0alpha1.Connection, *v0alpha1.ConnectionList, *provisioningv0alpha1.ConnectionApplyConfiguration](
fake.Fake,
namespace,
v0alpha1.SchemeGroupVersion.WithResource("connections"),
v0alpha1.SchemeGroupVersion.WithKind("Connection"),
func() *v0alpha1.Connection { return &v0alpha1.Connection{} },
func() *v0alpha1.ConnectionList { return &v0alpha1.ConnectionList{} },
func(dst, src *v0alpha1.ConnectionList) { dst.ListMeta = src.ListMeta },
func(list *v0alpha1.ConnectionList) []*v0alpha1.Connection { return gentype.ToPointerSlice(list.Items) },
func(list *v0alpha1.ConnectionList, items []*v0alpha1.Connection) {
list.Items = gentype.FromPointerSlice(items)
},
),
fake,
}
}
@@ -14,6 +14,10 @@ type FakeProvisioningV0alpha1 struct {
*testing.Fake
}
func (c *FakeProvisioningV0alpha1) Connections(namespace string) v0alpha1.ConnectionInterface {
return newFakeConnections(c, namespace)
}
func (c *FakeProvisioningV0alpha1) HistoricJobs(namespace string) v0alpha1.HistoricJobInterface {
return newFakeHistoricJobs(c, namespace)
}
@@ -4,6 +4,8 @@
package v0alpha1
type ConnectionExpansion interface{}
type HistoricJobExpansion interface{}
type JobExpansion interface{}
@@ -14,6 +14,7 @@ import (
type ProvisioningV0alpha1Interface interface {
RESTClient() rest.Interface
ConnectionsGetter
HistoricJobsGetter
JobsGetter
RepositoriesGetter
@@ -24,6 +25,10 @@ type ProvisioningV0alpha1Client struct {
restClient rest.Interface
}
func (c *ProvisioningV0alpha1Client) Connections(namespace string) ConnectionInterface {
return newConnections(c, namespace)
}
func (c *ProvisioningV0alpha1Client) HistoricJobs(namespace string) HistoricJobInterface {
return newHistoricJobs(c, namespace)
}
@@ -39,6 +39,8 @@ func (f *genericInformer) Lister() cache.GenericLister {
func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) {
switch resource {
// Group=provisioning.grafana.app, Version=v0alpha1
case v0alpha1.SchemeGroupVersion.WithResource("connections"):
return &genericInformer{resource: resource.GroupResource(), informer: f.Provisioning().V0alpha1().Connections().Informer()}, nil
case v0alpha1.SchemeGroupVersion.WithResource("historicjobs"):
return &genericInformer{resource: resource.GroupResource(), informer: f.Provisioning().V0alpha1().HistoricJobs().Informer()}, nil
case v0alpha1.SchemeGroupVersion.WithResource("jobs"):
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by informer-gen. DO NOT EDIT.
package v0alpha1
import (
context "context"
time "time"
apisprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
versioned "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
internalinterfaces "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/internalinterfaces"
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
watch "k8s.io/apimachinery/pkg/watch"
cache "k8s.io/client-go/tools/cache"
)
// ConnectionInformer provides access to a shared informer and lister for
// Connections.
type ConnectionInformer interface {
Informer() cache.SharedIndexInformer
Lister() provisioningv0alpha1.ConnectionLister
}
type connectionInformer struct {
factory internalinterfaces.SharedInformerFactory
tweakListOptions internalinterfaces.TweakListOptionsFunc
namespace string
}
// NewConnectionInformer constructs a new informer for Connection type.
// Always prefer using an informer factory to get a shared informer instead of getting an independent
// one. This reduces memory footprint and number of connections to the server.
func NewConnectionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {
return NewFilteredConnectionInformer(client, namespace, resyncPeriod, indexers, nil)
}
// NewFilteredConnectionInformer constructs a new informer for Connection type.
// Always prefer using an informer factory to get a shared informer instead of getting an independent
// one. This reduces memory footprint and number of connections to the server.
func NewFilteredConnectionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {
return cache.NewSharedIndexInformer(
&cache.ListWatch{
ListFunc: func(options v1.ListOptions) (runtime.Object, error) {
if tweakListOptions != nil {
tweakListOptions(&options)
}
return client.ProvisioningV0alpha1().Connections(namespace).List(context.Background(), options)
},
WatchFunc: func(options v1.ListOptions) (watch.Interface, error) {
if tweakListOptions != nil {
tweakListOptions(&options)
}
return client.ProvisioningV0alpha1().Connections(namespace).Watch(context.Background(), options)
},
ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) {
if tweakListOptions != nil {
tweakListOptions(&options)
}
return client.ProvisioningV0alpha1().Connections(namespace).List(ctx, options)
},
WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) {
if tweakListOptions != nil {
tweakListOptions(&options)
}
return client.ProvisioningV0alpha1().Connections(namespace).Watch(ctx, options)
},
},
&apisprovisioningv0alpha1.Connection{},
resyncPeriod,
indexers,
)
}
func (f *connectionInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {
return NewFilteredConnectionInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)
}
func (f *connectionInformer) Informer() cache.SharedIndexInformer {
return f.factory.InformerFor(&apisprovisioningv0alpha1.Connection{}, f.defaultInformer)
}
func (f *connectionInformer) Lister() provisioningv0alpha1.ConnectionLister {
return provisioningv0alpha1.NewConnectionLister(f.Informer().GetIndexer())
}
@@ -10,6 +10,8 @@ import (
// Interface provides access to all the informers in this group version.
type Interface interface {
// Connections returns a ConnectionInformer.
Connections() ConnectionInformer
// HistoricJobs returns a HistoricJobInformer.
HistoricJobs() HistoricJobInformer
// Jobs returns a JobInformer.
@@ -29,6 +31,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList
return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
}
// Connections returns a ConnectionInformer.
func (v *version) Connections() ConnectionInformer {
return &connectionInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
}
// HistoricJobs returns a HistoricJobInformer.
func (v *version) HistoricJobs() HistoricJobInformer {
return &historicJobInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by lister-gen. DO NOT EDIT.
package v0alpha1
import (
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
labels "k8s.io/apimachinery/pkg/labels"
listers "k8s.io/client-go/listers"
cache "k8s.io/client-go/tools/cache"
)
// ConnectionLister helps list Connections.
// All objects returned here must be treated as read-only.
type ConnectionLister interface {
// List lists all Connections in the indexer.
// Objects returned here must be treated as read-only.
List(selector labels.Selector) (ret []*provisioningv0alpha1.Connection, err error)
// Connections returns an object that can list and get Connections.
Connections(namespace string) ConnectionNamespaceLister
ConnectionListerExpansion
}
// connectionLister implements the ConnectionLister interface.
type connectionLister struct {
listers.ResourceIndexer[*provisioningv0alpha1.Connection]
}
// NewConnectionLister returns a new ConnectionLister.
func NewConnectionLister(indexer cache.Indexer) ConnectionLister {
return &connectionLister{listers.New[*provisioningv0alpha1.Connection](indexer, provisioningv0alpha1.Resource("connection"))}
}
// Connections returns an object that can list and get Connections.
func (s *connectionLister) Connections(namespace string) ConnectionNamespaceLister {
return connectionNamespaceLister{listers.NewNamespaced[*provisioningv0alpha1.Connection](s.ResourceIndexer, namespace)}
}
// ConnectionNamespaceLister helps list and get Connections.
// All objects returned here must be treated as read-only.
type ConnectionNamespaceLister interface {
// List lists all Connections in the indexer for a given namespace.
// Objects returned here must be treated as read-only.
List(selector labels.Selector) (ret []*provisioningv0alpha1.Connection, err error)
// Get retrieves the Connection from the indexer for a given namespace and name.
// Objects returned here must be treated as read-only.
Get(name string) (*provisioningv0alpha1.Connection, error)
ConnectionNamespaceListerExpansion
}
// connectionNamespaceLister implements the ConnectionNamespaceLister
// interface.
type connectionNamespaceLister struct {
listers.ResourceIndexer[*provisioningv0alpha1.Connection]
}
@@ -4,6 +4,14 @@
package v0alpha1
// ConnectionListerExpansion allows custom methods to be added to
// ConnectionLister.
type ConnectionListerExpansion interface{}
// ConnectionNamespaceListerExpansion allows custom methods to be added to
// ConnectionNamespaceLister.
type ConnectionNamespaceListerExpansion interface{}
// HistoricJobListerExpansion allows custom methods to be added to
// HistoricJobLister.
type HistoricJobListerExpansion interface{}
+2
View File
@@ -125,6 +125,7 @@ navigationTree:
url: /d/_5rDmaQiz
scope: shoe-org
subScope: shoes
preLoadSubScopeChildren: true
children:
- name: shoes-overview
title: Overview
@@ -141,6 +142,7 @@ navigationTree:
url: /d/edediimbjhdz4b
scope: shoes
subScope: frontend
preLoadSubScopeChildren: true
children:
- name: frontend-api
title: API Metrics
+10 -6
View File
@@ -83,6 +83,7 @@ type NavigationConfig struct {
Title string `yaml:"title"` // Display title
Groups []string `yaml:"groups"` // Optional groups for categorization
DisableSubScopeSelection bool `yaml:"disableSubScopeSelection"` // Makes the subscope not selectable
PreLoadSubScopeChildren bool `yaml:"preLoadSubScopeChildren"` // Preload children of subScope without updating UI
}
// NavigationTreeNode represents a node in the navigation tree structure
@@ -94,6 +95,7 @@ type NavigationTreeNode struct {
SubScope string `yaml:"subScope,omitempty"`
Groups []string `yaml:"groups,omitempty"`
DisableSubScopeSelection bool `yaml:"disableSubScopeSelection,omitempty"`
PreLoadSubScopeChildren bool `yaml:"preLoadSubScopeChildren,omitempty"` // Preload children of subScope without updating UI
Children []NavigationTreeNode `yaml:"children,omitempty"`
}
@@ -318,6 +320,7 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error
URL: nav.URL,
Scope: prefixedScope,
DisableSubScopeSelection: nav.DisableSubScopeSelection,
PreLoadSubScopeChildren: nav.PreLoadSubScopeChildren,
}
if nav.SubScope != "" {
@@ -353,14 +356,14 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error
return err
}
// Get the created resource to retrieve its resourceVersion for status update
createdNav, err := c.getScopeNavigation(prefixedName)
if err != nil {
return fmt.Errorf("failed to get created navigation: %w", err)
}
// Update status in a second request (status is a subresource)
if nav.Title != "" || len(nav.Groups) > 0 {
// Get the created resource to retrieve its resourceVersion and existing spec
createdNav, err := c.getScopeNavigation(prefixedName)
if err != nil {
return fmt.Errorf("failed to get created navigation: %w", err)
}
statusResource := v0alpha1.ScopeNavigation{
TypeMeta: metav1.TypeMeta{
APIVersion: apiVersion,
@@ -411,6 +414,7 @@ func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCo
Scope: node.Scope,
Title: node.Title,
DisableSubScopeSelection: node.DisableSubScopeSelection,
PreLoadSubScopeChildren: node.PreLoadSubScopeChildren,
}
if node.SubScope != "" {
nav.SubScope = node.SubScope
@@ -0,0 +1,59 @@
import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: true,
dashboardNewLayouts: true,
},
});
test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] }, () => {
test.use({
viewport: { width: 1280, height: 1080 },
});
test('should collapse and expand all rows', async ({ gotoDashboardPage, page, selectors }) => {
const dashboardPage = await gotoDashboardPage({ uid: 'Repeating-rows-uid/repeating-rows' });
const panelContents = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.content);
await expect(panelContents).toHaveCount(5);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('server = A, pod = Bob'))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('server = B, pod = Bob'))
).toBeVisible();
// Collapse all rows using keyboard shortcut: d + Shift+C
await page.keyboard.press('d');
await page.keyboard.press('Shift+C');
await expect(panelContents).toHaveCount(0);
await expect(page.getByText('server = A, pod = Bob')).toBeHidden();
await expect(page.getByText('server = B, pod = Bob')).toBeHidden();
// Expand all rows using keyboard shortcut: d + Shift+E
await page.keyboard.press('d');
await page.keyboard.press('Shift+E');
await expect(panelContents).toHaveCount(6);
await expect(page.getByText('server = A, pod = Bob')).toBeVisible();
await expect(page.getByText('server = B, pod = Bob')).toBeVisible();
});
test('should open panel inspect', async ({ gotoDashboardPage, page, selectors }) => {
const dashboardPage = await gotoDashboardPage({ uid: 'edediimbjhdz4b/a-tall-dashboard' });
// Find Panel #1 and press 'i' to open inspector
const panel1 = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel #1'));
await expect(panel1).toBeVisible();
await panel1.press('i');
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible();
// Press Escape to close inspector
await page.keyboard.press('Escape');
await expect(page.getByTestId(selectors.components.PanelInspector.Json.content)).toBeHidden();
});
});
@@ -1,5 +1,5 @@
import { api } from './baseAPI';
export const addTagTypes = ['API Discovery', 'Job', 'Repository', 'Provisioning'] as const;
export const addTagTypes = ['API Discovery', 'Connection', 'Job', 'Repository', 'Provisioning'] as const;
const injectedRtkApi = api
.enhanceEndpoints({
addTagTypes,
@@ -10,6 +10,156 @@ const injectedRtkApi = api
query: () => ({ url: `/` }),
providesTags: ['API Discovery'],
}),
listConnection: build.query<ListConnectionApiResponse, ListConnectionApiArg>({
query: (queryArg) => ({
url: `/connections`,
params: {
pretty: queryArg.pretty,
allowWatchBookmarks: queryArg.allowWatchBookmarks,
continue: queryArg['continue'],
fieldSelector: queryArg.fieldSelector,
labelSelector: queryArg.labelSelector,
limit: queryArg.limit,
resourceVersion: queryArg.resourceVersion,
resourceVersionMatch: queryArg.resourceVersionMatch,
sendInitialEvents: queryArg.sendInitialEvents,
timeoutSeconds: queryArg.timeoutSeconds,
watch: queryArg.watch,
},
}),
providesTags: ['Connection'],
}),
createConnection: build.mutation<CreateConnectionApiResponse, CreateConnectionApiArg>({
query: (queryArg) => ({
url: `/connections`,
method: 'POST',
body: queryArg.connection,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['Connection'],
}),
deletecollectionConnection: build.mutation<
DeletecollectionConnectionApiResponse,
DeletecollectionConnectionApiArg
>({
query: (queryArg) => ({
url: `/connections`,
method: 'DELETE',
params: {
pretty: queryArg.pretty,
continue: queryArg['continue'],
dryRun: queryArg.dryRun,
fieldSelector: queryArg.fieldSelector,
gracePeriodSeconds: queryArg.gracePeriodSeconds,
ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential,
labelSelector: queryArg.labelSelector,
limit: queryArg.limit,
orphanDependents: queryArg.orphanDependents,
propagationPolicy: queryArg.propagationPolicy,
resourceVersion: queryArg.resourceVersion,
resourceVersionMatch: queryArg.resourceVersionMatch,
sendInitialEvents: queryArg.sendInitialEvents,
timeoutSeconds: queryArg.timeoutSeconds,
},
}),
invalidatesTags: ['Connection'],
}),
getConnection: build.query<GetConnectionApiResponse, GetConnectionApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}`,
params: {
pretty: queryArg.pretty,
},
}),
providesTags: ['Connection'],
}),
replaceConnection: build.mutation<ReplaceConnectionApiResponse, ReplaceConnectionApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}`,
method: 'PUT',
body: queryArg.connection,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['Connection'],
}),
deleteConnection: build.mutation<DeleteConnectionApiResponse, DeleteConnectionApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}`,
method: 'DELETE',
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
gracePeriodSeconds: queryArg.gracePeriodSeconds,
ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential,
orphanDependents: queryArg.orphanDependents,
propagationPolicy: queryArg.propagationPolicy,
},
}),
invalidatesTags: ['Connection'],
}),
updateConnection: build.mutation<UpdateConnectionApiResponse, UpdateConnectionApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}`,
method: 'PATCH',
body: queryArg.patch,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
force: queryArg.force,
},
}),
invalidatesTags: ['Connection'],
}),
getConnectionStatus: build.query<GetConnectionStatusApiResponse, GetConnectionStatusApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}/status`,
params: {
pretty: queryArg.pretty,
},
}),
providesTags: ['Connection'],
}),
replaceConnectionStatus: build.mutation<ReplaceConnectionStatusApiResponse, ReplaceConnectionStatusApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}/status`,
method: 'PUT',
body: queryArg.connection,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
},
}),
invalidatesTags: ['Connection'],
}),
updateConnectionStatus: build.mutation<UpdateConnectionStatusApiResponse, UpdateConnectionStatusApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}/status`,
method: 'PATCH',
body: queryArg.patch,
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
force: queryArg.force,
},
}),
invalidatesTags: ['Connection'],
}),
listJob: build.query<ListJobApiResponse, ListJobApiArg>({
query: (queryArg) => ({
url: `/jobs`,
@@ -411,6 +561,208 @@ const injectedRtkApi = api
export { injectedRtkApi as generatedAPI };
export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList;
export type GetApiResourcesApiArg = void;
export type ListConnectionApiResponse = /** status 200 OK */ ConnectionList;
export type ListConnectionApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */
allowWatchBookmarks?: boolean;
/** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key".
This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */
continue?: string;
/** A selector to restrict the list of returned objects by their fields. Defaults to everything. */
fieldSelector?: string;
/** A selector to restrict the list of returned objects by their labels. Defaults to everything. */
labelSelector?: string;
/** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.
The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */
limit?: number;
/** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersion?: string;
/** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersionMatch?: string;
/** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.
When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan
is interpreted as "data at least as new as the provided `resourceVersion`"
and the bookmark event is send when the state is synced
to a `resourceVersion` at least as fresh as the one provided by the ListOptions.
If `resourceVersion` is unset, this is interpreted as "consistent read" and the
bookmark event is send when the state is synced at least to the moment
when request started being processed.
- `resourceVersionMatch` set to any other value or unset
Invalid error is returned.
Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */
sendInitialEvents?: boolean;
/** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */
timeoutSeconds?: number;
/** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */
watch?: boolean;
};
export type CreateConnectionApiResponse = /** status 200 OK */
| Connection
| /** status 201 Created */ Connection
| /** status 202 Accepted */ Connection;
export type CreateConnectionApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
connection: Connection;
};
export type DeletecollectionConnectionApiResponse = /** status 200 OK */ Status;
export type DeletecollectionConnectionApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key".
This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */
continue?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** A selector to restrict the list of returned objects by their fields. Defaults to everything. */
fieldSelector?: string;
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */
ignoreStoreReadErrorWithClusterBreakingPotential?: boolean;
/** A selector to restrict the list of returned objects by their labels. Defaults to everything. */
labelSelector?: string;
/** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.
The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */
limit?: number;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
/** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersion?: string;
/** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
Defaults to unset */
resourceVersionMatch?: string;
/** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.
When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan
is interpreted as "data at least as new as the provided `resourceVersion`"
and the bookmark event is send when the state is synced
to a `resourceVersion` at least as fresh as the one provided by the ListOptions.
If `resourceVersion` is unset, this is interpreted as "consistent read" and the
bookmark event is send when the state is synced at least to the moment
when request started being processed.
- `resourceVersionMatch` set to any other value or unset
Invalid error is returned.
Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */
sendInitialEvents?: boolean;
/** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */
timeoutSeconds?: number;
};
export type GetConnectionApiResponse = /** status 200 OK */ Connection;
export type GetConnectionApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
};
export type ReplaceConnectionApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection;
export type ReplaceConnectionApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
connection: Connection;
};
export type DeleteConnectionApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status;
export type DeleteConnectionApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
gracePeriodSeconds?: number;
/** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */
ignoreStoreReadErrorWithClusterBreakingPotential?: boolean;
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
orphanDependents?: boolean;
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
propagationPolicy?: string;
};
export type UpdateConnectionApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection;
export type UpdateConnectionApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
/** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */
force?: boolean;
patch: Patch;
};
export type GetConnectionStatusApiResponse = /** status 200 OK */ Connection;
export type GetConnectionStatusApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
};
export type ReplaceConnectionStatusApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection;
export type ReplaceConnectionStatusApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
connection: Connection;
};
export type UpdateConnectionStatusApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection;
export type UpdateConnectionStatusApiArg = {
/** name of the Connection */
name: string;
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
pretty?: string;
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
dryRun?: string;
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */
fieldManager?: string;
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
fieldValidation?: string;
/** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */
force?: boolean;
patch: Patch;
};
export type ListJobApiResponse = /** status 200 OK */ JobList;
export type ListJobApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
@@ -1053,6 +1405,169 @@ export type ObjectMeta = {
Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
uid?: string;
};
export type InlineSecureValue =
| {
/** Create a secure value -- this is only used for POST/PUT */
create?: string;
/** Name in the secret service (reference) */
name: string;
/** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */
remove?: boolean;
}
| {
/** Create a secure value -- this is only used for POST/PUT */
create: string;
/** Name in the secret service (reference) */
name?: string;
/** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */
remove?: boolean;
}
| {
/** Create a secure value -- this is only used for POST/PUT */
create?: string;
/** Name in the secret service (reference) */
name?: string;
/** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */
remove: boolean;
};
export type ConnectionSecure = {
/** ClientSecret is the reference to the secret used for other providers authentication, and Github on-behalf-of authentication. This value is stored securely and cannot be read back */
clientSecret?: InlineSecureValue;
/** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */
privateKey?: InlineSecureValue;
/** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */
webhook?: InlineSecureValue;
};
export type BitbucketConnectionConfig = {
/** App client ID */
clientID: string;
};
export type GitHubConnectionConfig = {
/** GitHub App ID */
appID: string;
/** GitHub App installation ID */
installationID: string;
};
export type GitlabConnectionConfig = {
/** App client ID */
clientID: string;
};
export type ConnectionSpec = {
/** Bitbucket connection configuration Only applicable when provider is "bitbucket" */
bitbucket?: BitbucketConnectionConfig;
/** GitHub connection configuration Only applicable when provider is "github" */
github?: GitHubConnectionConfig;
/** Gitlab connection configuration Only applicable when provider is "gitlab" */
gitlab?: GitlabConnectionConfig;
/** The connection provider type
Possible enum values:
- `"bitbucket"`
- `"github"`
- `"gitlab"` */
type: 'bitbucket' | 'github' | 'gitlab';
/** The connection URL */
url?: string;
};
export type HealthStatus = {
/** When the health was checked last time */
checked?: number;
/** The type of the error
Possible enum values:
- `"health"`
- `"hook"` */
error?: 'health' | 'hook';
/** When not healthy, requests will not be executed */
healthy: boolean;
/** Summary messages (can be shown to users) Will only be populated when not healthy */
message?: string[];
};
export type ConnectionStatus = {
/** The connection health status */
health: HealthStatus;
/** The generation of the spec last time reconciliation ran */
observedGeneration: number;
/** Connection state
Possible enum values:
- `"connected"`
- `"disconnected"` */
state: 'connected' | 'disconnected';
};
export type Connection = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
metadata?: ObjectMeta;
secure?: ConnectionSecure;
spec?: ConnectionSpec;
status?: ConnectionStatus;
};
export type ListMeta = {
/** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */
continue?: string;
/** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */
remainingItemCount?: number;
/** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */
resourceVersion?: string;
/** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */
selfLink?: string;
};
export type ConnectionList = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
items: Connection[];
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
metadata?: ListMeta;
};
export type StatusCause = {
/** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.
Examples:
"name" - the field "name" on the current resource
"items[0].name" - the field "name" on the first array entry in "items" */
field?: string;
/** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */
message?: string;
/** A machine-readable description of the cause of the error. If this value is empty there is no information available. */
reason?: string;
};
export type StatusDetails = {
/** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */
causes?: StatusCause[];
/** The group attribute of the resource associated with the status StatusReason. */
group?: string;
/** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */
name?: string;
/** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */
retryAfterSeconds?: number;
/** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
uid?: string;
};
export type Status = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** Suggested HTTP return code for this status, 0 if not set. */
code?: number;
/** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */
details?: StatusDetails;
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** A human-readable description of the status of this operation. */
message?: string;
/** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
metadata?: ListMeta;
/** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */
reason?: string;
/** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */
status?: string;
};
export type Patch = object;
export type ResourceRef = {
/** Group is the group of the resource, such as "dashboard.grafana.app". */
group?: string;
@@ -1190,16 +1705,6 @@ export type Job = {
spec?: JobSpec;
status?: JobStatus;
};
export type ListMeta = {
/** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */
continue?: string;
/** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */
remainingItemCount?: number;
/** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */
resourceVersion?: string;
/** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */
selfLink?: string;
};
export type JobList = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
@@ -1208,76 +1713,6 @@ export type JobList = {
kind?: string;
metadata?: ListMeta;
};
export type StatusCause = {
/** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.
Examples:
"name" - the field "name" on the current resource
"items[0].name" - the field "name" on the first array entry in "items" */
field?: string;
/** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */
message?: string;
/** A machine-readable description of the cause of the error. If this value is empty there is no information available. */
reason?: string;
};
export type StatusDetails = {
/** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */
causes?: StatusCause[];
/** The group attribute of the resource associated with the status StatusReason. */
group?: string;
/** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */
name?: string;
/** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */
retryAfterSeconds?: number;
/** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
uid?: string;
};
export type Status = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** Suggested HTTP return code for this status, 0 if not set. */
code?: number;
/** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */
details?: StatusDetails;
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** A human-readable description of the status of this operation. */
message?: string;
/** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
metadata?: ListMeta;
/** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */
reason?: string;
/** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */
status?: string;
};
export type Patch = object;
export type InlineSecureValue =
| {
/** Create a secure value -- this is only used for POST/PUT */
create?: string;
/** Name in the secret service (reference) */
name: string;
/** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */
remove?: boolean;
}
| {
/** Create a secure value -- this is only used for POST/PUT */
create: string;
/** Name in the secret service (reference) */
name?: string;
/** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */
remove?: boolean;
}
| {
/** Create a secure value -- this is only used for POST/PUT */
create?: string;
/** Name in the secret service (reference) */
name?: string;
/** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */
remove: boolean;
};
export type SecureValues = {
/** Token used to connect the configured repository */
token?: InlineSecureValue;
@@ -1374,20 +1809,6 @@ export type RepositorySpec = {
/** UI driven Workflow that allow changes to the contends of the repository. The order is relevant for defining the precedence of the workflows. When empty, the repository does not support any edits (eg, readonly) */
workflows: ('branch' | 'write')[];
};
export type HealthStatus = {
/** When the health was checked last time */
checked?: number;
/** The type of the error
Possible enum values:
- `"health"`
- `"hook"` */
error?: 'health' | 'hook';
/** When not healthy, requests will not be executed */
healthy: boolean;
/** Summary messages (can be shown to users) Will only be populated when not healthy */
message?: string[];
};
export type ResourceCount = {
count: number;
group: string;
@@ -1648,6 +2069,19 @@ export type ResourceStats = {
export const {
useGetApiResourcesQuery,
useLazyGetApiResourcesQuery,
useListConnectionQuery,
useLazyListConnectionQuery,
useCreateConnectionMutation,
useDeletecollectionConnectionMutation,
useGetConnectionQuery,
useLazyGetConnectionQuery,
useReplaceConnectionMutation,
useDeleteConnectionMutation,
useUpdateConnectionMutation,
useGetConnectionStatusQuery,
useLazyGetConnectionStatusQuery,
useReplaceConnectionStatusMutation,
useUpdateConnectionStatusMutation,
useListJobQuery,
useLazyListJobQuery,
useCreateJobMutation,
-3
View File
@@ -10,9 +10,6 @@ import (
"github.com/grafana/grafana/pkg/expr"
)
// Get results as raw protobuf
const PROTOBUF_CONTENT_TYPE = "application/vnd.grafana.pluginv2.QueryDataResponse"
// Generic query request with shared time across all values
// Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+13 -37
View File
@@ -6,16 +6,15 @@ import (
"fmt"
"net/http"
"google.golang.org/protobuf/proto"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-plugin-sdk-go/backend"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/web"
)
@@ -94,49 +93,26 @@ func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Ob
Headers: map[string]string{},
})
code := query.GetResponseCode(rsp)
// all errors get converted into k8 errors when sent in responder.Error and lose important context like downstream info
var e errutil.Error
if errors.As(err, &e) && e.Source == errutil.SourceDownstream {
err = nil
rsp = &backend.QueryDataResponse{Responses: map[string]backend.DataResponse{
"A": {
Error: errors.New(e.LogMessage),
ErrorSource: backend.ErrorSourceDownstream,
Status: backend.StatusBadRequest,
},
}}
responder.Object(int(backend.StatusBadRequest),
&query.QueryDataResponse{QueryDataResponse: backend.QueryDataResponse{Responses: map[string]backend.DataResponse{
"A": {
Error: errors.New(e.LogMessage),
ErrorSource: backend.ErrorSourceDownstream,
Status: backend.StatusBadRequest,
},
}}},
)
return
}
if err != nil {
responder.Error(err)
return
}
// Respond with raw protobuf when requested
for _, accept := range req.Header.Values("Accept") {
if accept == query.PROTOBUF_CONTENT_TYPE { // pluginv2.QueryDataResponse
p, err := backend.ToProto().QueryDataResponse(rsp)
if err != nil {
responder.Error(err)
return
}
data, err := proto.Marshal(p)
if err != nil {
responder.Error(err)
return
}
w.Header().Add("Content-Type", query.PROTOBUF_CONTENT_TYPE)
w.WriteHeader(code)
_, err = w.Write(data)
if err != nil {
logging.FromContext(ctx).Warn("unable to write protobuf result", "err", err)
}
return
}
}
responder.Object(code,
responder.Object(query.GetResponseCode(rsp),
&query.QueryDataResponse{QueryDataResponse: *rsp},
)
}), nil
@@ -39,6 +39,10 @@ func (m mockProvisioningV0alpha1Interface) Jobs(namespace string) client.JobInte
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Connections(namespace string) client.ConnectionInterface {
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Repositories(namespace string) client.RepositoryInterface {
if m.repositoriesFunc != nil {
return m.repositoriesFunc(namespace)
@@ -31,6 +31,7 @@ import (
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
@@ -354,6 +355,8 @@ func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attribu
return b.authorizeSettings(id)
case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName():
return b.authorizeJobs(id)
case provisioning.ConnectionResourceInfo.GetName():
return b.authorizeConnectionSubresource(a, id)
default:
return b.authorizeDefault(id)
}
@@ -437,6 +440,28 @@ func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision,
return authorizer.DecisionDeny, "admin role is required", nil
}
// authorizeRepositorySubresource handles authorization for connections subresources.
func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
switch a.GetSubresource() {
case "":
// Doing something with the connection itself.
if id.GetOrgRole().Includes(identity.RoleAdmin) {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "admin role is required", nil
case "status":
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "users cannot update the status of a connection", nil
default:
if id.GetIsGrafanaAdmin() {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
}
}
// authorizeDefault handles authorization for unmapped resources.
func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) {
// We haven't bothered with this kind yet.
@@ -520,10 +545,19 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage[provisioning.HistoricJobResourceInfo.StoragePath()] = historicJobStore
}
connectionsStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.ConnectionResourceInfo, opts.OptsGetter)
if err != nil {
return fmt.Errorf("failed to create connection storage: %w", err)
}
connectionStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, connectionsStore)
storage[provisioning.JobResourceInfo.StoragePath()] = jobStore
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
storage[provisioning.ConnectionResourceInfo.StoragePath()] = connectionsStore
storage[provisioning.ConnectionResourceInfo.StoragePath("status")] = connectionStatusStorage
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories))
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access)
@@ -566,6 +600,11 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
if ok {
return nil
}
// TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700
c, ok := obj.(*provisioning.Connection)
if ok {
return connectionvalidation.MutateConnection(c)
}
r, ok := obj.(*provisioning.Repository)
if !ok {
@@ -615,6 +654,11 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
return nil
}
connection, ok := obj.(*provisioning.Connection)
if ok {
return connectionvalidation.ValidateConnection(connection)
}
// Validate Jobs
job, ok := obj.(*provisioning.Job)
if ok {
@@ -2886,6 +2886,189 @@ func TestRouteGetRuleStatuses(t *testing.T) {
})
}
})
t.Run("with rule_matcher filter", func(t *testing.T) {
fakeStore, fakeAIM, api := setupAPI(t)
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "alerting", "severity": "critical"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "Alerting", "severity": "warning"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "platform", "severity": "critical"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule4"), gen.WithLabels(map[string]string{"env": "production"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_special"), gen.WithLabels(map[string]string{"key": `value"with"quotes`}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_empty"), gen.WithLabels(map[string]string{"empty": ""}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_nonempty"), gen.WithLabels(map[string]string{"empty": "nonempty"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_multiline"), gen.WithLabels(map[string]string{"description": "line1\nline2\\end\"quote"}), gen.WithNoNotificationSettings())
testCases := []struct {
name string
matchers []string
expectedUIDs []string
}{
{
name: "equality matcher filters by team=alerting",
matchers: []string{`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule1"},
},
{
name: "inequality matcher filters severity!=warning",
matchers: []string{`{"name":"severity","value":"warning","isRegex":false,"isEqual":false}`},
expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
{
name: "regex matcher filters team=~plat.*",
matchers: []string{`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`},
expectedUIDs: []string{"rule3"},
},
{
name: "not-regex matcher filters severity!~warn.*",
matchers: []string{`{"name":"severity","value":"warn.*","isRegex":true,"isEqual":false}`},
expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
{
name: "multiple matchers are ANDed",
matchers: []string{
`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`,
`{"name":"severity","value":"critical","isRegex":false,"isEqual":true}`,
},
expectedUIDs: []string{"rule1"},
},
{
name: "matcher with non-existent label returns no rules",
matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{},
},
{
name: "equality matcher is case-sensitive",
matchers: []string{`{"name":"team","value":"Alerting","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule2"},
},
{
name: "quotes in label value are handled correctly",
matchers: []string{`{"name":"key","value":"value\"with\"quotes","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule_special"},
},
{
name: "no matchers returns all rules",
matchers: []string{},
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
{
name: "empty string value matches correctly",
matchers: []string{`{"name":"empty","value":"","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_multiline"},
},
{
name: "special characters in label value are handled correctly",
matchers: []string{`{"name":"description","value":"line1\nline2\\end\"quote","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule_multiline"},
},
{
name: "inequality matcher on non-existent label matches all rules",
matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":false}`},
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reqURL := "/api/v1/rules"
for i, matcher := range tc.matchers {
if i == 0 {
reqURL += "?rule_matcher=" + url.QueryEscape(matcher)
} else {
reqURL += "&rule_matcher=" + url.QueryEscape(matcher)
}
}
req, err := http.NewRequest("GET", reqURL, nil)
require.NoError(t, err)
ctx := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions},
}
resp := api.RouteGetRuleStatuses(ctx)
require.Equal(t, http.StatusOK, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Equal(t, "success", res.Status)
actualUIDs := []string{}
for _, group := range res.Data.RuleGroups {
for _, rule := range group.Rules {
actualUIDs = append(actualUIDs, rule.UID)
}
}
require.ElementsMatch(t, tc.expectedUIDs, actualUIDs)
})
}
})
t.Run("pagination with rule_matcher in-memory filtering", func(t *testing.T) {
fakeStore, fakeAIM, api := setupAPI(t)
// Create 3 groups with 2 rules each:
// Group 1 & 2: team=backend (won't match filter)
// Group 3: team=frontend (will match filter)
// This tests that pagination continues fetching when early pages are filtered out
group1Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace1", RuleGroup: "group1"}
group2Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace2", RuleGroup: "group2"}
group3Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace3", RuleGroup: "group3"}
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule4"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule5"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule6"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings())
// Request with regex rule_matcher filter for team=~"alerting" and group_limit=1 to force pagination
matcher := `{"name":"team","value":"alerting","isRegex":true,"isEqual":true}`
reqURL := "/api/v1/rules?rule_matcher=" + url.QueryEscape(matcher) + "&group_limit=1"
req, err := http.NewRequest("GET", reqURL, nil)
require.NoError(t, err)
ctx := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions},
}
resp := api.RouteGetRuleStatuses(ctx)
require.Equal(t, http.StatusOK, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Equal(t, "success", res.Status)
actualUIDs := []string{}
for _, group := range res.Data.RuleGroups {
for _, rule := range group.Rules {
actualUIDs = append(actualUIDs, rule.UID)
}
}
// Should return group3 rules (rule5, rule6), pagination should continue past filtered groups
require.ElementsMatch(t, []string{"rule5", "rule6"}, actualUIDs)
})
}
func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) {
@@ -33,6 +33,12 @@ import (
"go.opentelemetry.io/otel/trace"
)
const (
queryIncludeInternalLabels = "includeInternalLabels"
queryRuleMatcher = "rule_matcher"
queryInstanceMatcher = "matcher"
)
type RuleStoreReader interface {
GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error)
ListAlertRulesStoreV2
@@ -62,6 +68,20 @@ type PrometheusSrv struct {
// Package-level OpenTelemetry tracer per Grafana instrumentation conventions.
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/ngalert/api/prometheus")
// badRequestError returns a Prometheus-compatible error response for bad request data.
func badRequestError(err error) apimodels.RuleResponse {
return apimodels.RuleResponse{
DiscoveryBase: apimodels.DiscoveryBase{
Status: "error",
Error: err.Error(),
ErrorType: apiv1.ErrBadData,
},
Data: apimodels.RuleDiscovery{
RuleGroups: []apimodels.RuleGroup{},
},
}
}
func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status StatusReader, store RuleStoreReader, authz RuleGroupAccessControlService, provenanceStore ProvenanceStore) *PrometheusSrv {
return &PrometheusSrv{
log,
@@ -73,8 +93,6 @@ func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status
}
}
const queryIncludeInternalLabels = "includeInternalLabels"
func getBoolWithDefault(vals url.Values, field string, d bool) bool {
f := vals.Get(field)
if f == "" {
@@ -188,15 +206,15 @@ func getPanelIDFromQuery(v url.Values) (int64, error) {
return 0, nil
}
func getMatchersFromQuery(v url.Values) (labels.Matchers, error) {
func getMatchersFromQuery(v url.Values, paramName string) (labels.Matchers, error) {
var matchers labels.Matchers
for _, s := range v["matcher"] {
for _, s := range v[paramName] {
var m labels.Matcher
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, err
}
if len(m.Name) == 0 {
return nil, errors.New("bad matcher: the name cannot be blank")
return nil, fmt.Errorf("bad %s: the name cannot be blank", paramName)
}
matchers = append(matchers, &m)
}
@@ -454,6 +472,7 @@ type paginationContext struct {
stateFilterSet map[eval.State]struct{}
healthFilterSet map[string]struct{}
matchers labels.Matchers
ruleLabelMatchers labels.Matchers
labelOptions []ngmodels.LabelOption
limitAlertsPerRule int64
limitRulesPerGroup int64
@@ -476,6 +495,9 @@ func accumulateTotals(dest, source map[string]int64) {
// fetchAndFilterPage fetches one page from the store and applies filters
func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlertRulesStoreV2, span trace.Span, token string, remainingGroups, remainingRules int64) (pageResult, error) {
// Split matchers: only equality/inequality are supported by the store
storeMatchers := filterOutRegexMatchers(ctx.ruleLabelMatchers)
byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{
OrgID: ctx.opts.OrgID,
@@ -488,6 +510,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
DataSourceUIDs: ctx.dataSourceUIDs,
SearchTitle: ctx.title,
SearchRuleGroup: ctx.searchRuleGroup,
LabelMatchers: storeMatchers,
},
RuleType: ctx.ruleType,
Limit: remainingGroups,
@@ -534,6 +557,8 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
filterRulesByHealth(ruleGroup, ctx.healthFilterSet)
}
filterRulesByLabelMatchers(ruleGroup, ctx.ruleLabelMatchers)
if ctx.limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > ctx.limitRulesPerGroup {
ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup]
}
@@ -546,6 +571,17 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
return result, nil
}
func filterOutRegexMatchers(matchers labels.Matchers) labels.Matchers {
var result labels.Matchers
for _, m := range matchers {
if m.Type == labels.MatchEqual || m.Type == labels.MatchNotEqual {
result = append(result, m)
}
}
return result
}
// paginateRuleGroups fetches pages until limits are satisfied applying filters at each step
func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *paginationContext, span trace.Span, maxGroups, maxRules int64, startToken string) ([]apimodels.RuleGroup, map[string]int64, string, error) {
allGroups := []apimodels.RuleGroup{}
@@ -644,21 +680,30 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
attribute.Int64("limit_rules", limitRulesPerGroup),
attribute.Int64("limit_alerts", limitAlertsPerRule),
)
matchers, err := getMatchersFromQuery(opts.Query)
matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
span.SetAttributes(attribute.Int("matcher_count", len(matchers)))
ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher)
if err != nil {
return badRequestError(err)
}
regexCount := 0
for _, m := range ruleLabelMatchers {
if m.Type == labels.MatchRegexp || m.Type == labels.MatchNotRegexp {
regexCount++
}
}
span.SetAttributes(
attribute.Int("rule_matcher_count", len(ruleLabelMatchers)),
attribute.Int("rule_matcher_regex_count", regexCount),
)
stateFilterSet, err := GetStatesFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
span.SetAttributes(
attribute.Int("state_filter_count", len(stateFilterSet)),
@@ -667,10 +712,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
healthFilterSet, err := GetHealthFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
span.SetAttributes(
attribute.Int("health_filter_count", len(healthFilterSet)),
@@ -808,6 +850,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
stateFilterSet: stateFilterSet,
healthFilterSet: healthFilterSet,
matchers: matchers,
ruleLabelMatchers: ruleLabelMatchers,
labelOptions: labelOptions,
limitAlertsPerRule: limitAlertsPerRule,
limitRulesPerGroup: limitRulesPerGroup,
@@ -833,6 +876,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
return ruleResponse
}
// nolint:gocyclo
func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse {
ruleResponse := apimodels.RuleResponse{
DiscoveryBase: apimodels.DiscoveryBase{
@@ -846,41 +890,30 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
dashboardUID := opts.Query.Get("dashboard_uid")
panelID, err := getPanelIDFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = fmt.Sprintf("invalid panel_id: %s", err.Error())
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(fmt.Errorf("invalid panel_id: %w", err))
}
if dashboardUID == "" && panelID != 0 {
ruleResponse.Status = "error"
ruleResponse.Error = "panel_id must be set with dashboard_uid"
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(errors.New("panel_id must be set with dashboard_uid"))
}
limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1)
limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1)
matchers, err := getMatchersFromQuery(opts.Query)
matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher)
if err != nil {
return badRequestError(err)
}
stateFilterSet, err := GetStatesFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
healthFilterSet, err := GetHealthFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
var labelOptions []ngmodels.LabelOption
@@ -913,6 +946,9 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
dataSourceUIDs := opts.Query["datasource_uid"]
searchRuleGroup := opts.Query.Get("search.rule_group")
// Split matchers: only equality/inequality are supported by the store
storeMatchers := filterOutRegexMatchers(ruleLabelMatchers)
alertRuleQuery := ngmodels.ListAlertRulesQuery{
OrgID: opts.OrgID,
NamespaceUIDs: namespaceUIDs,
@@ -924,6 +960,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
SearchTitle: title,
SearchRuleGroup: searchRuleGroup,
DataSourceUIDs: dataSourceUIDs,
LabelMatchers: storeMatchers,
}
ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery)
if err != nil {
@@ -978,6 +1015,10 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
filterRulesByHealth(ruleGroup, healthFilterSet)
}
if len(ruleLabelMatchers) > 0 {
filterRulesByLabelMatchers(ruleGroup, ruleLabelMatchers)
}
if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup {
ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup]
}
@@ -1105,6 +1146,30 @@ func filterRulesByHealth(ruleGroup *apimodels.RuleGroup, withHealthFast map[stri
ruleGroup.Rules = filteredRules
}
func filterRulesByLabelMatchers(ruleGroup *apimodels.RuleGroup, matchers labels.Matchers) {
if len(matchers) == 0 {
return
}
filteredRules := make([]apimodels.AlertingRule, 0, len(ruleGroup.Rules))
for _, rule := range ruleGroup.Rules {
ruleLabels := rule.Labels.Map()
matches := true
for _, m := range matchers {
if !m.Matches(ruleLabels[m.Name]) {
matches = false
break
}
}
if matches {
filteredRules = append(filteredRules, rule)
}
}
ruleGroup.Rules = filteredRules
}
// This is the same as matchers.Matches but avoids the need to create a LabelSet
func matchersMatch(matchers []*labels.Matcher, labels map[string]string) bool {
for _, m := range matchers {
@@ -462,4 +462,10 @@ type GetGrafanaRuleStatusesParams struct {
// in: query
// required: false
Matchers []string `json:"matcher"`
// Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {"type":0,"name":"severity","value":"critical"}).
// For equality matchers with empty string values (e.g., name=""), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).
// in: query
// required: false
RuleLabelMatchers []string `json:"rule_matcher"`
}
@@ -7561,6 +7561,15 @@
},
"name": "matcher",
"type": "array"
},
{
"description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).",
"in": "query",
"items": {
"type": "string"
},
"name": "rule_matcher",
"type": "array"
}
],
"responses": {
@@ -1947,6 +1947,15 @@
"description": "Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}). Provide one matcher per query string value.",
"name": "matcher",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).",
"name": "rule_matcher",
"in": "query"
}
],
"responses": {
@@ -18,6 +18,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/prometheus/alertmanager/pkg/labels"
prommodels "github.com/prometheus/common/model"
"github.com/grafana/grafana-plugin-sdk-go/data"
@@ -1012,6 +1013,10 @@ type ListAlertRulesQuery struct {
SearchRuleGroup string
HasPrometheusRuleDefinition *bool
// LabelMatchers filters rules by their labels.
// Only equality and inequality matchers are supported, no regex operators.
LabelMatchers labels.Matchers
}
type ListAlertRulesExtendedQuery struct {
+39
View File
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/google/uuid"
"github.com/prometheus/alertmanager/pkg/labels"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/util/xorm"
@@ -802,6 +803,15 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L
return result, nextToken, err
}
func matchersMatchLabels(matchers labels.Matchers, lbls map[string]string) bool {
for _, m := range matchers {
if !m.Matches(lbls[m.Name]) {
return false
}
}
return true
}
// nolint:gocyclo
func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) {
q = sess.Table("alert_rule")
@@ -920,6 +930,13 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis
}
}
if len(query.LabelMatchers) > 0 {
q, err = st.filterByLabelMatchers(query.LabelMatchers, q)
if err != nil {
return nil, groupsSet, err
}
}
// FIXME: record is nullable but we don't save it as null when it's nil
switch query.RuleType {
case ngmodels.RuleTypeFilterAlerting:
@@ -967,6 +984,11 @@ func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesE
return nil, false
}
}
if len(query.LabelMatchers) > 0 { // remove false-positive hits from the result
if !matchersMatchLabels(query.LabelMatchers, converted.Labels) {
return nil, false
}
}
// MySQL (and potentially other databases) can use case-insensitive comparison.
// This code makes sure we return groups that only exactly match the filter.
if groupsSet != nil {
@@ -1355,6 +1377,23 @@ func (st DBstore) filterWithPrometheusRuleDefinition(value bool, sess *xorm.Sess
), nil
}
// filterByLabelMatchers adds filtering for equality and inequality label matchers.
// Returns error if regex matchers are passed.
func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Session) (*xorm.Session, error) {
for _, m := range matchers {
if m.Type != labels.MatchEqual && m.Type != labels.MatchNotEqual {
return nil, fmt.Errorf("matcher %q %s %q is not supported", m.Name, m.Type, m.Value)
}
sql, args, err := buildLabelMatcherCondition(st.SQLStore.GetDialect(), "labels", m)
if err != nil {
return nil, err
}
sess = sess.And(sql, args...)
}
return sess, nil
}
func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) {
// fetch entire rules because Update method requires it because it copies rules to version table
rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
@@ -0,0 +1,51 @@
package store
import (
"fmt"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// buildLabelMatcherCondition builds SQL for a label matcher with Prometheus semantics.
// For MySQL/PostgreSQL, it uses JSON functions, and
// for SQLite, it uses GLOB patterns to find matching labels.
func buildLabelMatcherCondition(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) {
if dialect.DriverName() == migrator.SQLite {
return buildLabelMatcherGlob(column, m)
}
return buildLabelMatcherJSON(dialect, column, m)
}
func buildLabelMatcherGlob(column string, m *labels.Matcher) (string, []any, error) {
switch {
case m.Type == labels.MatchEqual && m.Value == "":
eqSQL, eqArgs, _ := globEquals(column, m.Name, "")
missingSQL, missingArgs, _ := globKeyMissing(column, m.Name)
return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil
case m.Type == labels.MatchEqual:
return globEquals(column, m.Name, m.Value)
case m.Type == labels.MatchNotEqual:
return globNotEquals(column, m.Name, m.Value)
default:
return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type)
}
}
func buildLabelMatcherJSON(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) {
switch {
case m.Type == labels.MatchEqual && m.Value == "":
eqSQL, eqArgs := jsonEquals(dialect, column, m.Name, "")
missingSQL, missingArgs := jsonKeyMissing(dialect, column, m.Name)
return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil
case m.Type == labels.MatchEqual:
sql, args := jsonEquals(dialect, column, m.Name, m.Value)
return sql, args, nil
case m.Type == labels.MatchNotEqual:
sql, args := jsonNotEquals(dialect, column, m.Name, m.Value)
return sql, args, nil
default:
return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type)
}
}
@@ -0,0 +1,136 @@
package store
import (
"testing"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
func TestBuildLabelMatcherGlob(t *testing.T) {
tests := []struct {
name string
matcher *labels.Matcher
wantSQL string
wantArgs []any
wantErr bool
errContains string
}{
{
name: "MatchEqual with non-empty value",
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "labels GLOB ?",
wantArgs: []any{`*"team":"alerting"*`},
},
{
name: "MatchEqual with empty value (Prometheus semantics)",
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: `(labels GLOB ? OR labels NOT GLOB ?)`,
wantArgs: []any{`*"team":""*`, `*"team":*`},
},
{
name: "MatchNotEqual",
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "labels NOT GLOB ?",
wantArgs: []any{`*"team":"alerting"*`},
},
{
name: "unsupported matcher type",
matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"},
wantErr: true,
errContains: "unsupported matcher type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args, err := buildLabelMatcherGlob("labels", tt.matcher)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestBuildLabelMatcherJSON(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
matcher *labels.Matcher
wantSQL string
wantArgs []any
wantErr bool
errContains string
}{
{
name: "MySQL MatchEqual with non-empty value",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "MySQL MatchEqual with empty value",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)",
wantArgs: []any{"team", "", "team"},
},
{
name: "MySQL MatchNotEqual",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "PostgreSQL MatchEqual with non-empty value",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "PostgreSQL MatchEqual with empty value",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)",
wantArgs: []any{"team", "", "team"},
},
{
name: "PostgreSQL MatchNotEqual",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "unsupported matcher type",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"},
wantErr: true,
errContains: "unsupported matcher type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args, err := buildLabelMatcherJSON(tt.dialect, "labels", tt.matcher)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
@@ -13,6 +13,7 @@ import (
"github.com/benbjohnson/clock"
"github.com/google/uuid"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -2385,6 +2386,157 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
})
t.Run("filter by LabelMatchers", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
ruleLower := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"team": "alerting", "severity": "warning"}),
ruleGen.WithTitle("rule_lowercase")))
ruleUpper := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"team": "Alerting", "severity": "critical"}),
ruleGen.WithTitle("rule_uppercase")))
ruleSpecial := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"key": `value"with"quotes`}),
ruleGen.WithTitle("rule_special")))
ruleGlob := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"glob": "*[?]"}),
ruleGen.WithTitle("rule_glob")))
ruleSpecialChars := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"json": "line1\nline2\\end\"quote"}),
ruleGen.WithTitle("rule_special_chars")))
ruleEmpty := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"empty": ""}),
ruleGen.WithTitle("rule_empty")))
ruleNonempty := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"empty": "nonempty"}),
ruleGen.WithTitle("rule_nonempty")))
tc := []struct {
name string
labelMatchers labels.Matchers
expectedRules []*models.AlertRule
}{
{
name: "equality matcher is case-sensitive",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleLower},
},
{
name: "equality matcher matches uppercase when specified",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper},
},
{
name: "inequality matcher is case-sensitive",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty},
},
{
name: "special characters in labels are handled correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher {
m, _ := labels.NewMatcher(labels.MatchEqual, "key", `value"with"quotes`)
return m
}(),
},
expectedRules: []*models.AlertRule{ruleSpecial},
},
{
name: "matcher with non-existent label returns no rules",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "nonexistent", "value"); return m }(),
},
expectedRules: []*models.AlertRule{},
},
{
name: "multiple matchers are ANDed",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(),
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "severity", "critical"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper},
},
{
name: "GLOB special characters are escaped correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "glob", "*[?]"); return m }(),
},
expectedRules: []*models.AlertRule{ruleGlob},
},
{
name: "JSON escape characters are handled correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher {
m, _ := labels.NewMatcher(labels.MatchEqual, "json", "line1\nline2\\end\"quote")
return m
}(),
},
expectedRules: []*models.AlertRule{ruleSpecialChars},
},
{
name: "empty string value matches correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(),
},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty},
},
{
name: "inequality matcher on non-existent label matches all rules",
labelMatchers: labels.Matchers{
func() *labels.Matcher {
m, _ := labels.NewMatcher(labels.MatchNotEqual, "nonexistent", "value")
return m
}(),
},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
LabelMatchers: tt.labelMatchers,
}
result, err := store.ListAlertRules(context.Background(), query)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedRules, result)
})
}
t.Run("regex matcher returns error from store", func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
LabelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchRegexp, "team", "alert.*"); return m }(),
},
}
_, err := store.ListAlertRules(context.Background(), query)
require.Error(t, err)
require.ErrorContains(t, err, "is not supported")
})
t.Run("not-regex matcher returns error from store", func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
LabelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotRegexp, "team", "alert.*"); return m }(),
},
}
_, err := store.ListAlertRules(context.Background(), query)
require.Error(t, err)
require.ErrorContains(t, err, "is not supported")
})
})
}
func TestIntegration_ListAlertRulesPaginated(t *testing.T) {
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"encoding/json"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// JSON functions for MySQL/PostgreSQL
func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) {
switch dialect.DriverName() {
case migrator.MySQL:
return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value}
case migrator.Postgres:
return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value}
default:
return "", nil
}
}
func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, []any) {
var jx string
switch dialect.DriverName() {
case migrator.MySQL:
jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column)
case migrator.Postgres:
jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column)
default:
return "", nil
}
return fmt.Sprintf("(%s IS NULL OR %s != ?)", jx, jx), []any{key, key, value}
}
func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) {
switch dialect.DriverName() {
case migrator.MySQL:
return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key}
case migrator.Postgres:
return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key}
default:
return "", nil
}
}
// GLOB functions for SQLite
func globEquals(column, key, value string) (string, []any, error) {
pattern, err := buildGlobPattern(key, value)
if err != nil {
return "", nil, err
}
return column + " GLOB ?", []any{"*" + pattern + "*"}, nil
}
func globNotEquals(column, key, value string) (string, []any, error) {
pattern, err := buildGlobPattern(key, value)
if err != nil {
return "", nil, err
}
return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil
}
func globKeyMissing(column, key string) (string, []any, error) {
pattern, err := buildGlobKeyPattern(key)
if err != nil {
return "", nil, err
}
return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil
}
// Search for `"key":"value"`
func buildGlobPattern(key, value string) (string, error) {
keyJSON, err := json.Marshal(key)
if err != nil {
return "", fmt.Errorf("failed to marshal key: %w", err)
}
valueJSON, err := json.Marshal(value)
if err != nil {
return "", fmt.Errorf("failed to marshal value: %w", err)
}
return escapeGlobPattern(fmt.Sprintf(`%s:%s`, string(keyJSON), string(valueJSON))), nil
}
// Search for `"key":`
func buildGlobKeyPattern(key string) (string, error) {
keyJSON, err := json.Marshal(key)
if err != nil {
return "", fmt.Errorf("failed to marshal key: %w", err)
}
return escapeGlobPattern(string(keyJSON) + ":"), nil
}
func escapeGlobPattern(pattern string) string {
pattern = strings.ReplaceAll(pattern, "[", "[[]")
pattern = strings.ReplaceAll(pattern, "*", "[*]")
pattern = strings.ReplaceAll(pattern, "?", "[?]")
return pattern
}
+185
View File
@@ -0,0 +1,185 @@
package store
import (
"testing"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/stretchr/testify/require"
)
func TestJsonEquals(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
column string
key string
value string
wantSQL string
wantArgs []any
}{
{
name: "MySQL",
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "PostgreSQL",
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?",
wantArgs: []any{"team", "alerting"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args := jsonEquals(tt.dialect, tt.column, tt.key, tt.value)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestJsonNotEquals(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
column string
key string
value string
wantSQL string
wantArgs []any
}{
{
name: "MySQL",
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "PostgreSQL",
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args := jsonNotEquals(tt.dialect, tt.column, tt.key, tt.value)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestJsonKeyMissing(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
column string
key string
wantSQL string
wantArgs []any
}{
{
name: "MySQL",
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL",
wantArgs: []any{"team"},
},
{
name: "PostgreSQL",
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL",
wantArgs: []any{"team"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args := jsonKeyMissing(tt.dialect, tt.column, tt.key)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestGlobEquals(t *testing.T) {
sql, args, err := globEquals("labels", "team", "alerting")
require.NoError(t, err)
require.Equal(t, "labels GLOB ?", sql)
require.Equal(t, []any{`*"team":"alerting"*`}, args)
}
func TestGlobNotEquals(t *testing.T) {
sql, args, err := globNotEquals("labels", "team", "alerting")
require.NoError(t, err)
require.Equal(t, "labels NOT GLOB ?", sql)
require.Equal(t, []any{`*"team":"alerting"*`}, args)
}
func TestGlobKeyMissing(t *testing.T) {
sql, args, err := globKeyMissing("labels", "team")
require.NoError(t, err)
require.Equal(t, "labels NOT GLOB ?", sql)
require.Equal(t, []any{`*"team":*`}, args)
}
func TestBuildGlobPattern(t *testing.T) {
tests := []struct {
name string
key string
value string
expected string
}{
{
name: "simple key-value",
key: "team",
value: "alerting",
expected: `"team":"alerting"`,
},
{
name: "empty value",
key: "empty",
value: "",
expected: `"empty":""`,
},
{
name: "special GLOB chars are escaped",
key: "key",
value: "*[?]",
expected: `"key":"[*][[][?]]"`,
},
{
name: "special chars are escaped",
key: "key",
value: "line1\nline2\\end\"quote",
expected: `"key":"line1\nline2\\end\"quote"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pattern, err := buildGlobPattern(tt.key, tt.value)
require.NoError(t, err)
require.Equal(t, tt.expected, pattern)
})
}
}
+15
View File
@@ -219,6 +219,7 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert
RuleUIDs: q.RuleUIDs,
ReceiverName: q.ReceiverName,
HasPrometheusRuleDefinition: q.HasPrometheusRuleDefinition,
LabelMatchers: q.LabelMatchers,
}
ruleList, err := f.listAlertRules(query)
@@ -355,6 +356,20 @@ func (f *RuleStore) listAlertRules(q *models.ListAlertRulesQuery) (models.RulesG
if q.ReceiverName != "" && (len(r.NotificationSettings) < 1 || r.NotificationSettings[0].Receiver != q.ReceiverName) {
continue
}
if len(q.LabelMatchers) > 0 {
matches := true
for _, m := range q.LabelMatchers {
if !m.Matches(r.Labels[m.Name]) {
matches = false
break
}
}
if !matches {
continue
}
}
copyR := models.CopyRule(r)
ruleList = append(ruleList, copyR)
}
+65 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"testing"
"time"
@@ -364,8 +365,6 @@ func TestIntegrationPrometheusRules(t *testing.T) {
func TestIntegrationPrometheusRulesPagination(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
testinfra.SQLiteIntegrationTest(t)
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
@@ -388,23 +387,30 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) {
require.NoError(t, err)
// Create 3 rule groups with different numbers of rules
// Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules)
// Group 1: 5 rules with team=backend
// Group 2: 3 rules with team=frontend
// Group 3: 2 rules with team=platform
for groupIdx := 1; groupIdx <= 3; groupIdx++ {
var rulesCount int
var team string
switch groupIdx {
case 1:
rulesCount = 5
team = "backend"
case 2:
rulesCount = 3
team = "frontend"
case 3:
rulesCount = 2
team = "platform"
}
rules := make([]apimodels.PostableExtendedRuleNode, rulesCount)
for i := 0; i < rulesCount; i++ {
rules[i] = apimodels.PostableExtendedRuleNode{
ApiRuleNode: &apimodels.ApiRuleNode{
For: &interval,
For: &interval,
Labels: map[string]string{"team": team},
},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1),
@@ -514,6 +520,61 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, result.Data.RuleGroups, 0, "should return no groups")
})
t.Run("with rule_matcher filter returns only matching rules", func(t *testing.T) {
matcher := url.QueryEscape(`{"name":"team","value":"frontend","isRegex":false,"isEqual":true}`)
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Should only return group-2 (team=frontend, 3 rules)
foundGroups := []string{}
total := 0
for _, group := range result.Data.RuleGroups {
foundGroups = append(foundGroups, group.Name)
total += len(group.Rules)
}
require.Equal(t, []string{"group-2"}, foundGroups)
require.Equal(t, 3, total)
})
t.Run("with rule_matcher regex filter", func(t *testing.T) {
// Filter with regex team=~plat.* (should match group-3 with team=platform)
matcher := url.QueryEscape(`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`)
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Should only return group-3 (team=platform matches plat.*)
foundGroups := []string{}
total := 0
for _, group := range result.Data.RuleGroups {
foundGroups = append(foundGroups, group.Name)
total += len(group.Rules)
}
require.Equal(t, []string{"group-3"}, foundGroups)
require.Equal(t, 2, total)
})
}
func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,413 @@
package provisioning
import (
"context"
"errors"
"testing"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
t.Run("should perform CRUDL requests on connection", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
// CREATE
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.NoError(t, err, "failed to create resource")
// READ
output, err := helper.Connections.Resource.Get(ctx, "connection", metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
assert.Equal(t, "connection", output.GetName(), "name should be equal")
assert.Equal(t, "default", output.GetNamespace(), "namespace should be equal")
spec := output.Object["spec"].(map[string]any)
assert.Equal(t, "github", spec["type"], "type should be equal")
assert.Equal(t, "https://github.com/settings/installations/454545", spec["url"], "url should be equal")
require.Contains(t, spec, "github")
githubInfo := spec["github"].(map[string]any)
assert.Equal(t, "123456", githubInfo["appID"], "appID should be equal")
assert.Equal(t, "454545", githubInfo["installationID"], "installationID should be equal")
require.Contains(t, output.Object, "secure", "object should contain secure")
assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey")
// LIST
list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list resource")
assert.Equal(t, 1, len(list.Items), "should have one connection")
assert.Equal(t, "connection", list.Items[0].GetName(), "name should be equal")
// UPDATE
updatedConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "456789",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{})
require.NoError(t, err, "failed to update resource")
spec = res.Object["spec"].(map[string]any)
require.Contains(t, spec, "github")
githubInfo = spec["github"].(map[string]any)
assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated")
// DELETE
require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource")
list, err = helper.Connections.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list resources")
assert.Equal(t, 0, len(list.Items), "should have no connections")
})
t.Run("viewer can't create or get connection", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
result := helper.ViewerREST.Post().
Namespace("default").
Resource("connections").
Body(connection).
Do(t.Context())
require.NotNil(t, result.Error())
err := &k8serrors.StatusError{}
require.True(t, errors.As(result.Error(), &err))
assert.Equal(t, metav1.StatusReasonForbidden, err.Status().Reason)
assert.Contains(t, err.Status().Message, "User \"viewer\" cannot create resource \"connections\"")
assert.Contains(t, err.Status().Message, "admin role is required")
result = helper.ViewerREST.Get().
Namespace("default").
Resource("connections").
Name("connection").
Do(t.Context())
require.NotNil(t, result.Error())
err = &k8serrors.StatusError{}
require.True(t, errors.As(result.Error(), &err))
assert.Equal(t, metav1.StatusReasonForbidden, err.Status().Reason)
assert.Contains(t, err.Status().Message, "User \"viewer\" cannot get resource \"connections\"")
assert.Contains(t, err.Status().Message, "admin role is required")
})
}
func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
t.Run("should fail when type is empty", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "type must be specified")
})
t.Run("should fail when type is invalid", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "some-invalid-type",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"")
})
t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "github info must be specified for GitHub connection")
})
t.Run("should fail when type is github but private key is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection")
})
t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection")
})
t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "bitbucket",
},
"secure": map[string]any{
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection")
})
t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "bitbucket",
"bitbucket": map[string]any{
"clientID": "123456",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "clientSecret must be specified for Bitbucket connection")
})
t.Run("should fail when type is bitbucket but a private key is specified", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "bitbucket",
"bitbucket": map[string]any{
"clientID": "123456",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "privateKey is forbidden in Bitbucket connection")
})
t.Run("should fail when type is gitlab but 'gitlab' field is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "gitlab",
},
"secure": map[string]any{
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection")
})
t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "gitlab",
"gitlab": map[string]any{
"clientID": "123456",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "clientSecret must be specified for Gitlab connection")
})
t.Run("should fail when type is gitlab but a private key is specified", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "gitlab",
"gitlab": map[string]any{
"clientID": "123456",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "privateKey is forbidden in Gitlab connection")
})
}
@@ -53,6 +53,7 @@ type provisioningTestHelper struct {
ProvisioningPath string
Repositories *apis.K8sResourceClient
Connections *apis.K8sResourceClient
Jobs *apis.K8sResourceClient
Folders *apis.K8sResourceClient
DashboardsV0 *apis.K8sResourceClient
@@ -703,6 +704,11 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
Namespace: "default", // actually org1
GVR: provisioning.RepositoryResourceInfo.GroupVersionResource(),
})
connections := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: provisioning.ConnectionResourceInfo.GroupVersionResource(),
})
jobs := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
@@ -763,6 +769,7 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
K8sTestHelper: helper,
Repositories: repositories,
Connections: connections,
AdminREST: adminClient,
EditorREST: editorClient,
ViewerREST: viewerClient,
@@ -47,6 +47,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
title?: string;
searchGroupName?: string;
type?: 'alerting' | 'recording';
ruleMatchers?: string[];
};
export const prometheusApi = alertingApi.injectEndpoints({
@@ -102,6 +103,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
datasources,
searchGroupName,
dashboardUid,
ruleMatchers,
}) => ({
url: `api/prometheus/grafana/api/v1/rules`,
params: {
@@ -120,6 +122,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
'search.rule_name': title,
'search.rule_group': searchGroupName,
dashboard_uid: dashboardUid,
rule_matcher: ruleMatchers,
},
}),
providesTags: (_result, _error, { folderUid, groupName, ruleName }) => {
@@ -1,9 +1,12 @@
import { testWithFeatureToggles } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto';
import { mockGrafanaPromAlertingRule, mockPromRecordingRule } from '../../mocks';
import { RuleHealth } from '../../search/rulesSearchParser';
import { pluginMeta, pluginMetaToPluginConfig } from '../../testSetup/plugins';
import { SupportedPlugin } from '../../types/pluginBridges';
import { Annotation } from '../../utils/constants';
import { getDatasourceAPIUid } from '../../utils/datasource';
import { getFilter } from '../../utils/search';
@@ -416,19 +419,40 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should include ruleMatchers in backend filter when labels are provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(backendFilter.ruleMatchers).toBeDefined();
expect(backendFilter.ruleMatchers).toHaveLength(1);
expect(backendFilter.ruleMatchers).toEqual([
'{"name":"severity","value":"critical","isRegex":false,"isEqual":true}',
]);
});
it('should still apply other frontend filters', () => {
const rule = mockGrafanaPromAlertingRule({
// Set up test plugin as installed
config.apps[SupportedPlugin.Slo] = pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]);
const regularRule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
labels: { severity: 'critical', team: 'ops' },
alerts: [],
});
// Label filter should still work on frontend
const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(frontendFilter.ruleMatches(rule)).toBe(false);
const pluginRule = mockGrafanaPromAlertingRule({
name: 'Plugin Rule',
labels: { __grafana_origin: `plugin/${SupportedPlugin.Slo}` },
alerts: [],
});
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(frontendFilter2.ruleMatches(rule)).toBe(true);
// Plugins filter should still work on frontend
const { frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' }));
// Non-plugin rules should pass through
expect(frontendFilter.ruleMatches(regularRule)).toBe(true);
// Plugin-provided rules should be filtered out
expect(frontendFilter.ruleMatches(pluginRule)).toBe(false);
});
});
@@ -681,20 +705,7 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should still apply always-frontend filters (labels, namespace)', () => {
const rule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
labels: { severity: 'critical' },
alerts: [],
});
// Labels filter should still work
const { frontendFilter: labelFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(labelFilter.ruleMatches(rule)).toBe(false);
const { frontendFilter: labelFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(labelFilter2.ruleMatches(rule)).toBe(true);
it('should still apply always-frontend filters (namespace)', () => {
// Namespace filter should still work
const group: PromRuleGroupDTO = {
name: 'Test Group',
@@ -791,9 +802,13 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
});
it('should return false for labels (handled by backend when feature toggle is enabled)', () => {
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true);
});
it('should return false for backend-only filters (state, health, contactPoint)', () => {
@@ -816,12 +831,13 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: frontend-handled filters
// Should return true for: frontend-handled filters (labels, namespace, plugins)
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true);
});
});
@@ -840,12 +856,13 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: always-frontend filters only (namespace, labels)
// Should return true for: always-frontend filters only (namespace, plugins)
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true);
// Should return false for: backend-handled dataSourceNames when feature toggles are enabled
// Should return false for: backend-handled filters when both feature toggles are enabled
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
});
});
});
@@ -1,8 +1,11 @@
import { attempt, isError } from 'lodash';
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaPromRulesOptions } from '../../api/prometheusApi';
import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../../featureToggles';
import { RulesFilter } from '../../search/rulesSearchParser';
import { parseMatcher } from '../../utils/matchers';
import { buildTitleSearch, normalizeFilterState } from './filterNormalization';
import {
@@ -75,6 +78,12 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
hasInvalidDataSourceNames = datasourceUids.length === 0;
}
// Convert labels to JSON-encoded matchers for backend filtering
const ruleMatchersBackendFilter: string[] | undefined =
ruleFilterConfig.labels || normalizedFilterState.labels.length === 0
? undefined
: labelMatchersToBackendFormat(normalizedFilterState.labels);
const backendFilter: GrafanaPromRulesOptions = {
state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [],
health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [],
@@ -85,6 +94,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid,
searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName,
datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids,
ruleMatchers: ruleMatchersBackendFilter,
};
return {
@@ -115,7 +125,7 @@ function buildGrafanaFilterConfigs() {
ruleState: null,
ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter,
dataSourceNames: useBackendFilters || useFullyCompatibleBackendFilters ? null : dataSourceNamesFilter,
labels: labelsFilter,
labels: useBackendFilters ? null : labelsFilter,
ruleHealth: null,
dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter,
plugins: pluginsFilter,
@@ -129,3 +139,21 @@ function buildGrafanaFilterConfigs() {
return { ruleFilterConfig, groupFilterConfig };
}
/**
* Converts label matchers to JSON-encoded strings for backend filtering.
* Invalid matchers are logged and filtered out.
*/
function labelMatchersToBackendFormat(labels: string[]): string[] {
return labels.reduce<string[]>((acc, label) => {
const result = attempt(() => JSON.stringify(parseMatcher(label)));
if (isError(result)) {
console.warn('Failed to parse label matcher:', label, result);
} else {
acc.push(result);
}
return acc;
}, []);
}
@@ -74,6 +74,7 @@ describe('paginationLimits', () => {
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -86,7 +87,6 @@ describe('paginationLimits', () => {
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ labels: ['severity=critical'] },
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
@@ -157,6 +157,7 @@ describe('paginationLimits', () => {
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -167,7 +168,7 @@ describe('paginationLimits', () => {
}
);
it.each<Partial<RulesFilter>>([{ namespace: 'production' }, { labels: ['severity=critical'] }])(
it.each<Partial<RulesFilter>>([{ namespace: 'production' }])(
'should return large limits for both when frontend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useCallback, useId, useMemo, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { useSessionStorage } from 'react-use';
import { GrafanaTheme2, PanelData } from '@grafana/data';
@@ -45,6 +45,13 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
const styles = useStyles2(getStyles);
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
const filterId = useId();
const [searchInputRef, setSearchInputRef] = useState<HTMLInputElement | null>(null);
useEffect(() => {
if (searchInputRef) {
searchInputRef.focus();
}
}, [searchInputRef]);
/** SEARCH */
const [searchQuery, setSearchQuery] = useState('');
@@ -124,6 +131,9 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
)}
<FilterInput
id={filterId}
ref={(ref) => {
setSearchInputRef(ref);
}}
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
@@ -30,8 +30,10 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DashboardEventAction } from 'app/features/live/dashboard/types';
import { VariablesChanged } from 'app/features/variables/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { createWorker } from '../saving/createDetectChangesWorker';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
@@ -39,6 +41,7 @@ import { historySrv } from '../settings/version-history/HistorySrv';
import { getCloneKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
import * as utils from '../utils/utils';
import { DashboardControls } from './DashboardControls';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
@@ -162,6 +165,38 @@ describe('DashboardScene', () => {
expect(scene.state.meta.version).toEqual(2);
});
it('Should exit edit mode after saving from unsaved changes modal when dashboardNewLayouts is enabled', () => {
const originalFeatureToggle = config.featureToggles.dashboardNewLayouts;
config.featureToggles.dashboardNewLayouts = true;
const publishSpy = jest.spyOn(appEvents, 'publish');
const hasActualSaveChangesSpy = jest.spyOn(utils, 'hasActualSaveChanges').mockReturnValue(true);
scene.setState({ title: 'Updated title' });
expect(scene.state.isDirty).toBe(true);
scene.exitEditMode({ skipConfirm: false });
const modalCall = publishSpy.mock.calls.find((call) => call[0] instanceof ShowConfirmModalEvent);
expect(modalCall).toBeDefined();
const modalEvent = modalCall![0] as ShowConfirmModalEvent;
expect(modalEvent.payload.altActionText).toBeDefined();
modalEvent.payload.onAltAction?.();
expect(scene.state.overlay).toBeDefined();
const overlay = scene.state.overlay as SaveDashboardDrawer;
expect(overlay.state.onSaveSuccess).toBeDefined();
overlay.state.onSaveSuccess!();
expect(scene.state.isEditing).toBe(false);
publishSpy.mockRestore();
hasActualSaveChangesSpy.mockRestore();
config.featureToggles.dashboardNewLayouts = originalFeatureToggle;
});
it('Should start the detect changes worker', () => {
expect(worker.onmessage).toBeDefined();
});
@@ -335,7 +335,11 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
yesText: t('dashboard-scene.dashboard-scene.modal.discard', 'Discard'),
yesButtonVariant: 'destructive',
onAltAction: () => {
this.openSaveDrawer({});
this.openSaveDrawer({
onSaveSuccess: () => {
this.exitEditModeConfirmed(false);
},
});
},
onConfirm: () => {
this.exitEditModeConfirmed();
@@ -18,6 +18,7 @@ import { getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { onRemovePanel, toggleVizPanelLegend } from './PanelMenuBehavior';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { RowsLayoutManager } from './layout-rows/RowsLayoutManager';
export function setupKeyboardShortcuts(scene: DashboardScene) {
const keybindings = new KeybindingSet();
@@ -265,6 +266,8 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => {
if (scene.state.body instanceof DefaultGridLayoutManager) {
scene.state.body.collapseAllRows();
} else if (scene.state.body instanceof RowsLayoutManager) {
scene.state.body.collapseAllRows();
}
},
});
@@ -275,6 +278,8 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => {
if (scene.state.body instanceof DefaultGridLayoutManager) {
scene.state.body.expandAllRows();
} else if (scene.state.body instanceof RowsLayoutManager) {
scene.state.body.expandAllRows();
}
},
});
@@ -107,7 +107,7 @@ function RowRepeatSelect({ row, dashboard, id }: { row: SceneGridRow; dashboard:
<>
<RepeatRowSelect2
id={id}
sceneContext={dashboard}
sceneContext={row}
repeat={repeatBehavior?.state.variableName}
onChange={(repeat) => {
if (repeat) {
@@ -8,7 +8,7 @@ import { Icon, useStyles2 } from '@grafana/ui';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getQueryRunnerFor, useDashboard, useDashboardState } from '../../../utils/utils';
import { getQueryRunnerFor, useDashboardState } from '../../../utils/utils';
import { DashboardGridItem } from '../DashboardGridItem';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
@@ -18,7 +18,6 @@ import { RowOptionsButton } from './RowOptionsButton';
export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
const row = model.getParent();
const { title, children } = row.useState();
const dashboard = useDashboard(model);
const { meta, isEditing } = useDashboardState(model);
const styles = useStyles2(getStyles);
@@ -53,7 +52,7 @@ export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
<RowOptionsButton
title={title}
repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined}
parent={dashboard}
parent={row}
onUpdate={(title, repeat) => model.onUpdate(title, repeat)}
isUsingDashboardDS={isUsingDashboardDS}
/>
@@ -2,7 +2,7 @@ import { useId, useMemo, useRef } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Alert, Input, Switch, TextLink, Field } from '@grafana/ui';
import { Alert, Field, Input, Switch, TextLink } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
@@ -11,7 +11,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks/useConditionalRenderingEditor';
import { dashboardEditActions } from '../../edit-pane/shared';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { getQueryRunnerFor } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils';
@@ -128,7 +128,6 @@ function FillScreenSwitch({ row, id }: { row: RowItem; id?: string }) {
function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) {
const { layout } = row.useState();
const dashboard = useDashboard(row);
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
@@ -143,7 +142,7 @@ function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) {
<>
<RepeatRowSelect2
id={id}
sceneContext={dashboard}
sceneContext={row}
repeat={row.state.repeatByVariable}
onChange={(repeat) => row.onChangeRepeat(repeat)}
/>
@@ -390,4 +390,30 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return duplicateTitles;
}
public collapseAllRows() {
this.state.rows.forEach((row) => {
if (!row.getCollapsedState()) {
row.setCollapsedState(true);
}
row.state.repeatedRows?.forEach((repeatedRow) => {
if (!repeatedRow.getCollapsedState()) {
repeatedRow.setCollapsedState(true);
}
});
});
}
public expandAllRows() {
this.state.rows.forEach((row) => {
if (row.getCollapsedState()) {
row.setCollapsedState(false);
}
row.state.repeatedRows?.forEach((repeatedRow) => {
if (repeatedRow.getCollapsedState()) {
repeatedRow.setCollapsedState(false);
}
});
});
}
}
@@ -2,7 +2,7 @@ import { useMemo, useRef } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Alert, Input, Field, TextLink } from '@grafana/ui';
import { Alert, Field, Input, TextLink } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
@@ -11,7 +11,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks/useConditionalRenderingEditor';
import { dashboardEditActions } from '../../edit-pane/shared';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { getQueryRunnerFor } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils';
@@ -99,7 +99,6 @@ function TabTitleInput({ tab, isNewElement, id }: { tab: TabItem; isNewElement:
function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) {
const { layout } = tab.useState();
const dashboard = useDashboard(tab);
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
@@ -114,7 +113,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) {
<>
<RepeatRowSelect2
id={id}
sceneContext={dashboard}
sceneContext={tab}
repeat={tab.state.repeatByVariable}
onChange={(repeat) => tab.onChangeRepeat(repeat)}
/>
@@ -1,6 +1,6 @@
import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { getRuntimePanelDataSource } from './utils';
import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils';
describe('getRuntimePanelDataSource', () => {
it('should return uid and type when explicit datasource UID is provided', () => {
@@ -140,3 +140,76 @@ describe('getRuntimePanelDataSource', () => {
expect(result).toBeUndefined();
});
});
describe('ensureUniqueRefIds', () => {
const createQuery = (refId: string): PanelQueryKind => ({
kind: 'PanelQuery',
spec: {
refId,
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'prometheus',
spec: {},
},
},
});
it('should assign unique refIds to queries without refIds', () => {
const queries: PanelQueryKind[] = [createQuery(''), createQuery(''), createQuery('')];
const result = ensureUniqueRefIds(queries);
expect(result[0].spec.refId).toBe('A');
expect(result[1].spec.refId).toBe('B');
expect(result[2].spec.refId).toBe('C');
});
it('should preserve existing refIds and fill gaps', () => {
const queries: PanelQueryKind[] = [createQuery('A'), createQuery(''), createQuery('D'), createQuery('')];
const result = ensureUniqueRefIds(queries);
expect(result[0].spec.refId).toBe('A');
expect(result[1].spec.refId).toBe('B');
expect(result[2].spec.refId).toBe('D');
expect(result[3].spec.refId).toBe('C');
});
it('should handle all queries having existing refIds', () => {
const queries: PanelQueryKind[] = [createQuery('A'), createQuery('B'), createQuery('C')];
const result = ensureUniqueRefIds(queries);
expect(result[0].spec.refId).toBe('A');
expect(result[1].spec.refId).toBe('B');
expect(result[2].spec.refId).toBe('C');
});
it('should only modify queries without refIds', () => {
const queries: PanelQueryKind[] = [createQuery('A'), createQuery(''), createQuery('C')];
const result = ensureUniqueRefIds(queries);
// Existing refIds should be preserved
expect(result[0].spec.refId).toBe('A');
expect(result[2].spec.refId).toBe('C');
// Missing refId should be assigned
expect(result[1].spec.refId).toBe('B');
});
it('should handle empty array', () => {
const result = ensureUniqueRefIds([]);
expect(result).toEqual([]);
});
it('should handle single query without refId', () => {
const queries: PanelQueryKind[] = [createQuery('')];
const result = ensureUniqueRefIds(queries);
expect(result[0].spec.refId).toBe('A');
});
});
@@ -1,3 +1,4 @@
import { getNextRefId } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
SceneDataProvider,
@@ -164,12 +165,15 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider
return undefined;
}
// Ensure all queries have unique refIds before converting to scene queries
const queriesWithUniqueRefIds = ensureUniqueRefIds(targets);
let dataProvider: SceneDataProvider | undefined = undefined;
const datasource = getPanelDataSource(panelKind);
dataProvider = new SceneQueryRunner({
datasource,
queries: targets.map(panelQueryKindToSceneQuery),
queries: queriesWithUniqueRefIds.map(panelQueryKindToSceneQuery),
maxDataPoints: panel.data.spec.queryOptions.maxDataPoints ?? undefined,
maxDataPointsFromWidth: true,
cacheTimeout: panel.data.spec.queryOptions.cacheTimeout,
@@ -341,6 +345,21 @@ export function getDataSourceForQuery(querySpecDS: DataSourceRef | undefined | n
};
}
export function ensureUniqueRefIds(queries: PanelQueryKind[]): PanelQueryKind[] {
// Adapter to make PanelQueryKind[] work with getNextRefId (which expects { refId }[])
const refIdAdapter = queries.map((q) => ({ refId: q.spec.refId }));
for (let i = 0; i < queries.length; i++) {
if (!queries[i].spec.refId) {
const newRefId = getNextRefId(refIdAdapter);
queries[i] = { ...queries[i], spec: { ...queries[i].spec, refId: newRefId } };
refIdAdapter[i] = { refId: newRefId };
}
}
return queries;
}
function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery {
// Add datasource to match Go backend V2→V1 conversion:
// - If explicit UID (datasource.name) exists → add { uid, type }
@@ -1,13 +1,9 @@
import { FormEvent, useCallback } from 'react';
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { CustomVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { CustomVariableForm } from '../../components/CustomVariableForm';
import { PaneItem } from './PaneItem';
interface CustomVariableEditorProps {
variable: CustomVariable;
onRunQuery: () => void;
@@ -67,17 +63,3 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
/>
);
}
export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] {
if (!(variable instanceof CustomVariable)) {
return [];
}
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
];
}
@@ -1,4 +1,3 @@
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneObject, sceneGraph } from '@grafana/scenes';
import { LocalValueVariable, SceneObject, sceneGraph } from '@grafana/scenes';
import { Combobox, ComboboxOption, Select } from '@grafana/ui';
import { useSelector } from 'app/types/store';
@@ -59,10 +59,18 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2)
const variables = sceneVars.useState().variables;
const variableOptions = useMemo(() => {
const options: ComboboxOption[] = variables.map((item) => ({
label: item.state.name,
value: item.state.name,
}));
const options: ComboboxOption[] = variables
.filter((item) => {
if (sceneContext.parent) {
// filter out local value variables (which are only set on repeated items)
return !(sceneGraph.lookupVariable(item.state.name, sceneContext.parent) instanceof LocalValueVariable);
}
return true;
})
.map((item) => ({
label: item.state.name,
value: item.state.name,
}));
options.unshift({
label: t('dashboard.repeat-row-select2.variable-options.label.disable-repeating', 'Disable repeating'),
@@ -70,7 +78,7 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2)
});
return options;
}, [variables]);
}, [sceneContext, variables]);
const onSelectChange = useCallback((value: ComboboxOption | null) => value && onChange(value.value), [onChange]);
@@ -79,7 +87,7 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2)
return (
<Combobox
id={id}
value={repeat}
value={repeat || ''}
onChange={onSelectChange}
options={variableOptions}
disabled={isDisabled}
@@ -888,6 +888,454 @@ describe('ScopesDashboardsService', () => {
});
});
describe('preLoadSubScopeChildren', () => {
beforeEach(() => {
config.featureToggles.useScopesNavigationEndpoint = true;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
});
afterEach(() => {
config.featureToggles.useScopesNavigationEndpoint = false;
});
it('should set preLoadSubScopeChildren on folder when navigation has it set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
preLoadSubScopeChildren: true,
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.preLoadSubScopeChildren).toBe(true);
expect(folder.subScopeName).toBe('subScope1');
}
});
it('should set preLoadSubScopeChildren to false when navigation has it set to false', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
preLoadSubScopeChildren: false,
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.preLoadSubScopeChildren).toBe(false);
}
});
it('should set preLoadSubScopeChildren to undefined when navigation does not have it', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.preLoadSubScopeChildren).toBeUndefined();
}
});
it('should automatically fetch subScope items for folders with preLoadSubScopeChildren set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'mimir',
preLoadSubScopeChildren: true,
},
status: {
title: 'Mimir Dashboards',
},
metadata: {
name: 'nav1',
},
},
];
// Mock items returned when fetching 'mimir' subScope
const mimirItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('mimir')) {
return Promise.resolve(mimirItems);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for the preload to complete
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify that fetchScopeNavigations was called for the subScope
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['mimir']);
// Verify the folder now has content from the preloaded items
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('mimir'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
// The preloaded items should be in the folder
expect(folder.folders['General']).toBeDefined();
expect(folder.folders['General'].suggestedNavigations['/d/mimir-dashboard-1']).toBeDefined();
}
});
it('should not fetch subScope items for folders without preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'mimir',
// preLoadSubScopeChildren is not set
},
status: {
title: 'Mimir Dashboards',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
// Wait to ensure no additional fetch happens
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify that fetchScopeNavigations was only called once (for the initial fetch)
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledTimes(1);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['scope1']);
});
it('should recursively preload nested folders with preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'level1',
preLoadSubScopeChildren: true,
},
status: {
title: 'Level 1 Folder',
},
metadata: {
name: 'nav1',
},
},
];
// Level 1 items include a folder with preLoadSubScopeChildren
const level1Items: ScopeNavigation[] = [
{
metadata: { name: 'level2-nav' },
spec: {
scope: 'level1',
subScope: 'level2',
url: '/d/level2-dashboard',
preLoadSubScopeChildren: true,
},
status: {
title: 'Level 2 Folder',
},
},
];
// Level 2 items
const level2Items: ScopeNavigation[] = [
{
metadata: { name: 'level2-item-1' },
spec: {
scope: 'level2',
url: '/d/level2-dashboard-1',
},
status: {
title: 'Level 2 Dashboard 1',
groups: ['Deep'],
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('level1')) {
return Promise.resolve(level1Items);
}
if (scopeNames.includes('level2')) {
return Promise.resolve(level2Items);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for all preloads to complete (need to wait for both levels)
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify all levels were fetched
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['level1']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['level2']);
});
it('should handle multiple folders with preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'mimir',
preLoadSubScopeChildren: true,
},
status: {
title: 'Mimir Dashboards',
},
metadata: {
name: 'nav1',
},
},
{
spec: {
url: '/d/dashboard2',
scope: 'scope1',
subScope: 'loki',
preLoadSubScopeChildren: true,
},
status: {
title: 'Loki Dashboards',
},
metadata: {
name: 'nav2',
},
},
];
const mimirItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' },
status: { title: 'Mimir Dashboard 1', groups: ['General'] },
},
];
const lokiItems: ScopeNavigation[] = [
{
metadata: { name: 'loki-item-1' },
spec: { scope: 'loki', url: '/d/loki-dashboard-1' },
status: { title: 'Loki Dashboard 1', groups: ['General'] },
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('mimir')) {
return Promise.resolve(mimirItems);
}
if (scopeNames.includes('loki')) {
return Promise.resolve(lokiItems);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for preloads to complete
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify both subScopes were fetched
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['mimir']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['loki']);
});
it('should handle preload errors gracefully', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'failing-scope',
preLoadSubScopeChildren: true,
},
status: {
title: 'Failing Folder',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('failing-scope')) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve([]);
});
// Should not throw
await service.fetchDashboards(['scope1']);
// Wait for preload to attempt
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify the folder was still created (even though preload failed)
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('failing-scope'));
expect(folderKey).toBeDefined();
});
it('should preload children after fetching subScope items when parent folder has items with preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'parent',
preLoadSubScopeChildren: true,
},
status: {
title: 'Parent Folder',
},
metadata: {
name: 'nav1',
},
},
];
// Parent items include a child with preLoadSubScopeChildren
const parentItems: ScopeNavigation[] = [
{
metadata: { name: 'child-nav' },
spec: {
scope: 'parent',
subScope: 'child',
url: '/d/child-dashboard',
preLoadSubScopeChildren: true,
},
status: {
title: 'Child Folder',
},
},
];
const childItems: ScopeNavigation[] = [
{
metadata: { name: 'child-item-1' },
spec: {
scope: 'child',
url: '/d/child-dashboard-1',
},
status: {
title: 'Child Dashboard 1',
groups: ['Nested'],
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('parent')) {
return Promise.resolve(parentItems);
}
if (scopeNames.includes('child')) {
return Promise.resolve(childItems);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for cascading preloads
await new Promise((resolve) => setTimeout(resolve, 20));
// Verify the chain of preloads occurred
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['scope1']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['parent']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['child']);
});
});
describe('disableSubScopeSelection', () => {
it('should set disableSubScopeSelection on folder when navigation has it set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
@@ -253,9 +253,14 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
...currentFilteredFolder.suggestedNavigations,
...rootSubScopeFolder.suggestedNavigations,
};
}
this.updateState({ folders, filteredFolders });
this.updateState({ folders, filteredFolders });
// Preload children for any newly added folders with preLoadSubScopeChildren
this.preloadSubScopeChildren(rootSubScopeFolder.folders, path);
} else {
this.updateState({ folders, filteredFolders });
}
};
// Helper to get a folder at a given path
@@ -316,6 +321,25 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
loading: false,
drawerOpened: res.length > 0,
});
// Preload children for folders with preLoadSubScopeChildren set
this.preloadSubScopeChildren(folders[''].folders, ['']);
}
};
/**
* Preloads children for folders that have preLoadSubScopeChildren set to true.
* This fetches the subScope items immediately when the navigation is first loaded,
* or when a parent subScope folder is fetched.
* @param foldersToCheck - The folders to check for preLoadSubScopeChildren
* @param basePath - The path to prepend when building the full path for each folder
*/
private preloadSubScopeChildren = (foldersToCheck: SuggestedNavigationsFoldersMap, basePath: string[]) => {
for (const [folderKey, folder] of Object.entries(foldersToCheck)) {
if (folder.preLoadSubScopeChildren && folder.subScopeName) {
const path = [...basePath, folderKey];
this.fetchSubScopeItems(path, folder.subScopeName);
}
}
};
@@ -391,6 +415,10 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
if ('disableSubScopeSelection' in navigation.spec) {
disableSubScopeSelection = navigation.spec.disableSubScopeSelection;
}
let preLoadSubScopeChildren: ScopeNavigationSpec['preLoadSubScopeChildren'] = undefined;
if ('preLoadSubScopeChildren' in navigation.spec) {
preLoadSubScopeChildren = navigation.spec.preLoadSubScopeChildren;
}
rootNode.folders[folderKey] = {
title: navigationTitle,
expanded,
@@ -398,6 +426,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
suggestedNavigations: {},
subScopeName: subScope,
disableSubScopeSelection,
preLoadSubScopeChildren,
};
}
if (expanded && !rootNode.folders[folderKey].expanded) {
@@ -44,6 +44,7 @@ export interface SuggestedNavigationsFolder {
subScopeName?: string;
loading?: boolean;
disableSubScopeSelection?: boolean;
preLoadSubScopeChildren?: boolean;
}
export type SuggestedNavigationsFoldersMap = Record<string, SuggestedNavigationsFolder>;
+1 -2
View File
@@ -4821,8 +4821,7 @@
"apply": "Apply",
"change-value": "Change variable value",
"discard": "Discard",
"modal-title": "Custom Variable",
"values": "Values separated by comma"
"modal-title": "Custom Variable"
},
"datasource-options": {
"name-filter": "Name filter",