Compare commits

..

5 Commits

Author SHA1 Message Date
Nic Westvold
cd7e052723 Explore: Rename PrometheusQueryResults directory back to RawPrometheus
Reverts the directory rename to fix the codeowners-validator CI check.
The CODEOWNERS file references /public/app/features/explore/RawPrometheus/
which must match the actual directory path.
2026-01-09 13:46:31 -05:00
Nic Westvold
b1057b3cbf Explore: Expose Prometheus query results component for plugins
Add grafana/prometheus-query-results/v1 exposed component to allow
plugins to display Prometheus instant query results with Table/Raw toggle.
2026-01-08 15:03:18 -05:00
Haris Rozajac
a79cda3328 Dashboard Conversion: Handle legacy string ds ref in panel queries datasources in V1-> V2 conversion (#116032) 2026-01-08 12:17:03 -07:00
Gilles De Mey
65cdf6cd45 Alerting: Align redux toolkit versions (#116016) 2026-01-08 17:26:33 +01:00
Roberto Jiménez Sánchez
7be93d9af4 Provisioning: add /connections/{name}/repositories endpoint (#116020)
* feat(provisioning): add /connections/{name}/repositories endpoint

Add a new subresource endpoint to list external repositories from git
providers (GitHub, GitLab, Bitbucket) accessible through a connection.

Changes:
- Add ExternalRepositoryList and ExternalRepository types with Name, Owner, and URL fields
- Create connection_repositories.go connector (returns 'not implemented' for now)
- Register storage and authorization for the repositories subresource
- Update OpenAPI documentation
- Regenerate code (deepcopy, openapi, client)

The endpoint is accessible at /apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/repositories
and requires admin read access.

Related: #TBD

* test(provisioning): add unit and integration tests for connection repositories endpoint

- Add unit tests for connection_repositories connector
- Add integration tests for authorization and endpoint behavior
- Tests verify not implemented response and proper authorization

* Fix generation

* fix(tests): fix test compilation and assertions

- Remove unused import in unit test
- Fix integration test Raw() usage
- Fix ExternalRepositoryList type verification test

* Format code

* fix(provisioning): fix ineffectual assignment in connection_repositories connector

- Add debug log statement to use logger variable
- Fixes linter error about ineffectual assignment to ctx
2026-01-08 16:14:19 +00:00
29 changed files with 2496 additions and 164 deletions

View File

@@ -0,0 +1,287 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "legacy-ds-ref"
},
"spec": {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"panels": [
{
"datasource": "${datasource}",
"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"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Minimum cluster size"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [10, 10],
"fill": "dash"
}
},
{
"id": "custom.lineWidth",
"value": 1
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 0
},
"id": 16,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": "${datasource}",
"editorMode": "code",
"expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})",
"instant": false,
"legendFormat": "{{version}}",
"range": true,
"refId": "B"
}
],
"title": "Number of Alloy Instances",
"type": "timeseries"
},
{
"datasource": "${datasource}",
"description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n",
"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"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percentunit"
},
"overrides": [
{
"__systemRef": "hideSeriesFrom",
"matcher": {
"id": "byNames",
"options": {
"mode": "exclude",
"names": [
"Total"
],
"prefix": "All except:",
"readOnly": true
}
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": false,
"tooltip": true,
"viz": true
}
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 0
},
"id": 17,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": "${datasource}",
"expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n",
"hide": true,
"instant": false,
"legendFormat": "{{instance}}",
"range": true,
"refId": "A"
},
{
"datasource": "${datasource}",
"editorMode": "code",
"expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "Total",
"range": true,
"refId": "B"
}
],
"title": "CPU usage",
"type": "timeseries"
}
],
"time": {
"from": "now-90m",
"to": "now"
},
"timezone": "utc",
"title": "Legacy DS Panel Query Ref",
"weekStart": ""
}
}

View File

@@ -0,0 +1,294 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "legacy-ds-ref"
},
"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"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"panels": [
{
"datasource": "${datasource}",
"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"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Minimum cluster size"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.lineWidth",
"value": 1
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 0
},
"id": 16,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": "${datasource}",
"editorMode": "code",
"expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})",
"instant": false,
"legendFormat": "{{version}}",
"range": true,
"refId": "B"
}
],
"title": "Number of Alloy Instances",
"type": "timeseries"
},
{
"datasource": "${datasource}",
"description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n",
"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"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percentunit"
},
"overrides": [
{
"__systemRef": "hideSeriesFrom",
"matcher": {
"id": "byNames",
"options": {
"mode": "exclude",
"names": [
"Total"
],
"prefix": "All except:",
"readOnly": true
}
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": false,
"tooltip": true,
"viz": true
}
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 0
},
"id": 17,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": "${datasource}",
"expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n",
"hide": true,
"instant": false,
"legendFormat": "{{instance}}",
"range": true,
"refId": "A"
},
{
"datasource": "${datasource}",
"editorMode": "code",
"expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "Total",
"range": true,
"refId": "B"
}
],
"title": "CPU usage",
"type": "timeseries"
}
],
"time": {
"from": "now-90m",
"to": "now"
},
"timezone": "utc",
"title": "Legacy DS Panel Query Ref",
"weekStart": ""
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -0,0 +1,405 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "legacy-ds-ref"
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"query": {
"kind": "grafana",
"spec": {}
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"builtIn": true,
"legacyOptions": {
"type": "dashboard"
}
}
}
],
"cursorSync": "Off",
"editable": true,
"elements": {
"panel-16": {
"kind": "Panel",
"spec": {
"id": 16,
"title": "Number of Alloy Instances",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {
"editorMode": "code",
"expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})",
"instant": false,
"legendFormat": "{{version}}",
"range": true
}
},
"datasource": {
"type": "",
"uid": "${datasource}"
},
"refId": "B",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"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": [
{
"matcher": {
"id": "byName",
"options": "Minimum cluster size"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.lineWidth",
"value": 1
}
]
}
]
}
}
}
}
},
"panel-17": {
"kind": "Panel",
"spec": {
"id": 17,
"title": "CPU usage",
"description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {
"expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n",
"instant": false,
"legendFormat": "{{instance}}",
"range": true
}
},
"datasource": {
"type": "",
"uid": "${datasource}"
},
"refId": "A",
"hidden": true
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "",
"spec": {
"editorMode": "code",
"expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "Total",
"range": true
}
},
"datasource": {
"type": "",
"uid": "${datasource}"
},
"refId": "B",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"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": [
{
"__systemRef": "hideSeriesFrom",
"matcher": {
"id": "byNames",
"options": {
"mode": "exclude",
"names": [
"Total"
],
"prefix": "All except:",
"readOnly": true
}
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": false,
"tooltip": true,
"viz": true
}
}
]
}
]
}
}
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 8,
"height": 9,
"element": {
"kind": "ElementReference",
"name": "panel-16"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 8,
"y": 0,
"width": 8,
"height": 9,
"element": {
"kind": "ElementReference",
"name": "panel-17"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"timezone": "utc",
"from": "now-90m",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Legacy DS Panel Query Ref",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -0,0 +1,411 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "legacy-ds-ref"
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "grafana",
"version": "v0",
"datasource": {
"name": "-- Grafana --"
},
"spec": {}
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"builtIn": true,
"legacyOptions": {
"type": "dashboard"
}
}
}
],
"cursorSync": "Off",
"editable": true,
"elements": {
"panel-16": {
"kind": "Panel",
"spec": {
"id": 16,
"title": "Number of Alloy Instances",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"datasource": {
"name": "${datasource}"
},
"spec": {
"editorMode": "code",
"expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})",
"instant": false,
"legendFormat": "{{version}}",
"range": true
}
},
"refId": "B",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"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": [
{
"matcher": {
"id": "byName",
"options": "Minimum cluster size"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.lineWidth",
"value": 1
}
]
}
]
}
}
}
}
},
"panel-17": {
"kind": "Panel",
"spec": {
"id": 17,
"title": "CPU usage",
"description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"datasource": {
"name": "${datasource}"
},
"spec": {
"expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n",
"instant": false,
"legendFormat": "{{instance}}",
"range": true
}
},
"refId": "A",
"hidden": true
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "",
"version": "v0",
"datasource": {
"name": "${datasource}"
},
"spec": {
"editorMode": "code",
"expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "Total",
"range": true
}
},
"refId": "B",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "",
"spec": {
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"timeCompare": false,
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"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": [
{
"__systemRef": "hideSeriesFrom",
"matcher": {
"id": "byNames",
"options": {
"mode": "exclude",
"names": [
"Total"
],
"prefix": "All except:",
"readOnly": true
}
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": false,
"tooltip": true,
"viz": true
}
}
]
}
]
}
}
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 8,
"height": 9,
"element": {
"kind": "ElementReference",
"name": "panel-16"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 8,
"y": 0,
"width": 8,
"height": 9,
"element": {
"kind": "ElementReference",
"name": "panel-17"
}
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"timezone": "utc",
"from": "now-90m",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "Legacy DS Panel Query Ref",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}

View File

@@ -88,6 +88,11 @@ func ConvertDashboard_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard,
// Which means that we have schemaVersion: 42 dashboards where datasource variable references are still strings
normalizeTemplateVariableDatasources(out.Spec.Object)
// Normalize panel and target datasources from string to object format
// This handles legacy dashboards where panels/targets have datasource: "$datasource" (string)
// instead of datasource: { uid: "$datasource" } (object)
normalizePanelDatasources(out.Spec.Object)
return nil
}
@@ -134,3 +139,62 @@ func isTemplateVariableRef(s string) bool {
}
return strings.HasPrefix(s, "$") || strings.HasPrefix(s, "${")
}
// normalizePanelDatasources converts panel and target string datasources to object format.
// Legacy dashboards may have panels/targets with datasource: "$datasource" (string).
// This normalizes them to datasource: { uid: "$datasource" } for consistent V1→V2 conversion.
func normalizePanelDatasources(dashboard map[string]interface{}) {
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return
}
normalizePanelsDatasources(panels)
}
// normalizePanelsDatasources normalizes datasources in a list of panels (including nested row panels)
func normalizePanelsDatasources(panels []interface{}) {
for _, panel := range panels {
panelMap, ok := panel.(map[string]interface{})
if !ok {
continue
}
// Handle row panels with nested panels
if panelType, _ := panelMap["type"].(string); panelType == "row" {
if nestedPanels, ok := panelMap["panels"].([]interface{}); ok {
normalizePanelsDatasources(nestedPanels)
}
}
// Normalize panel-level datasource
if ds := panelMap["datasource"]; ds != nil {
if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) {
panelMap["datasource"] = map[string]interface{}{
"uid": dsStr,
}
}
}
// Normalize target-level datasources
targets, ok := panelMap["targets"].([]interface{})
if !ok {
continue
}
for _, target := range targets {
targetMap, ok := target.(map[string]interface{})
if !ok {
continue
}
if ds := targetMap["datasource"]; ds != nil {
if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) {
targetMap["datasource"] = map[string]interface{}{
"uid": dsStr,
}
}
}
}
}
}

View File

@@ -2059,6 +2059,12 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{},
Uid: &dsUID,
}
}
} else if dsStr, ok := ds.(string); ok && isTemplateVariable(dsStr) {
// Handle legacy panel datasource as string (template variable reference e.g., "$datasource")
// Only process template variables - other string values are not supported in V2 format
panelDatasource = &dashv2alpha1.DashboardDataSourceRef{
Uid: &dsStr,
}
}
}
@@ -2145,6 +2151,10 @@ func transformSingleQuery(ctx context.Context, targetMap map[string]interface{},
// Resolve Grafana datasource UID when type is "datasource" and UID is empty
queryDatasourceUID = resolveGrafanaDatasourceUID(queryDatasourceType, queryDatasourceUID)
}
} else if dsStr, ok := targetMap["datasource"].(string); ok && isTemplateVariable(dsStr) {
// Handle legacy target datasource as string (template variable reference e.g., "$datasource")
// Only process template variables - other string values are not supported in V2 format
queryDatasourceUID = dsStr
}
// Use panel datasource if target datasource is missing or empty

View File

@@ -116,3 +116,26 @@ type ConnectionList struct {
// +listType=atomic
Items []Connection `json:"items"`
}
// ExternalRepositoryList lists repositories from an external git provider
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ExternalRepositoryList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
// +listType=atomic
Items []ExternalRepository `json:"items"`
}
type ExternalRepository struct {
// Name of the repository
Name string `json:"name"`
// Owner is the user, organization, or workspace that owns the repository
// For GitHub: organization or user
// For GitLab: namespace (user or group)
// For Bitbucket: workspace
// For pure Git: empty
Owner string `json:"owner,omitempty"`
// URL of the repository
URL string `json:"url"`
}

View File

@@ -197,6 +197,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error {
&HistoricJobList{},
&Connection{},
&ConnectionList{},
&ExternalRepositoryList{},
)
return nil
}

View File

@@ -262,6 +262,53 @@ func (in *ExportJobOptions) DeepCopy() *ExportJobOptions {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExternalRepository) DeepCopyInto(out *ExternalRepository) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRepository.
func (in *ExternalRepository) DeepCopy() *ExternalRepository {
if in == nil {
return nil
}
out := new(ExternalRepository)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExternalRepositoryList) DeepCopyInto(out *ExternalRepositoryList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ExternalRepository, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRepositoryList.
func (in *ExternalRepositoryList) DeepCopy() *ExternalRepositoryList {
if in == nil {
return nil
}
out := new(ExternalRepositoryList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ExternalRepositoryList) 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 *FileItem) DeepCopyInto(out *FileItem) {
*out = *in

View File

@@ -26,6 +26,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"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.ExternalRepository": schema_pkg_apis_provisioning_v0alpha1_ExternalRepository(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepositoryList": schema_pkg_apis_provisioning_v0alpha1_ExternalRepositoryList(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),
@@ -544,6 +546,96 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference
}
}
func schema_pkg_apis_provisioning_v0alpha1_ExternalRepository(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"name": {
SchemaProps: spec.SchemaProps{
Description: "Name of the repository",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"owner": {
SchemaProps: spec.SchemaProps{
Description: "Owner is the user, organization, or workspace that owns the repository For GitHub: organization or user For GitLab: namespace (user or group) For Bitbucket: workspace For pure Git: empty",
Type: []string{"string"},
Format: "",
},
},
"url": {
SchemaProps: spec.SchemaProps{
Description: "URL of the repository",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"name", "url"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ExternalRepositoryList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "ExternalRepositoryList lists repositories from an external git provider",
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.ExternalRepository"),
},
},
},
},
},
},
Required: []string{"items"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_FileItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{

View File

@@ -1,6 +1,7 @@
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,ExternalRepositoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors

View File

@@ -96,7 +96,7 @@
"@faker-js/faker": "^9.8.0",
"@grafana/api-clients": "12.4.0-pre",
"@grafana/i18n": "12.4.0-pre",
"@reduxjs/toolkit": "^2.9.0",
"@reduxjs/toolkit": "2.10.1",
"fishery": "^2.3.1",
"lodash": "^4.17.21",
"tinycolor2": "^1.6.0"

View File

@@ -170,7 +170,7 @@
},
"peerDependencies": {
"@grafana/runtime": ">=11.6 <= 12.x",
"@reduxjs/toolkit": "^2.8.0",
"@reduxjs/toolkit": "^2.10.0",
"rxjs": "7.8.2"
}
}

View File

@@ -122,6 +122,10 @@ const injectedRtkApi = api
}),
invalidatesTags: ['Connection'],
}),
getConnectionRepositories: build.query<GetConnectionRepositoriesApiResponse, GetConnectionRepositoriesApiArg>({
query: (queryArg) => ({ url: `/connections/${queryArg.name}/repositories` }),
providesTags: ['Connection'],
}),
getConnectionStatus: build.query<GetConnectionStatusApiResponse, GetConnectionStatusApiArg>({
query: (queryArg) => ({
url: `/connections/${queryArg.name}/status`,
@@ -726,6 +730,18 @@ export type UpdateConnectionApiArg = {
force?: boolean;
patch: Patch;
};
export type GetConnectionRepositoriesApiResponse = /** status 200 OK */ {
/** 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: any[];
/** 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?: any;
};
export type GetConnectionRepositoriesApiArg = {
/** name of the ExternalRepositoryList */
name: string;
};
export type GetConnectionStatusApiResponse = /** status 200 OK */ Connection;
export type GetConnectionStatusApiArg = {
/** name of the Connection */
@@ -2079,6 +2095,8 @@ export const {
useReplaceConnectionMutation,
useDeleteConnectionMutation,
useUpdateConnectionMutation,
useGetConnectionRepositoriesQuery,
useLazyGetConnectionRepositoriesQuery,
useGetConnectionStatusQuery,
useLazyGetConnectionStatusQuery,
useReplaceConnectionStatusMutation,

View File

@@ -599,6 +599,7 @@ export {
type PluginExtensionResourceAttributesContext,
type CentralAlertHistorySceneV1Props,
} from './types/pluginExtensions';
export { type PrometheusQueryResultsV1Props } from './types/exposedComponentProps';
export {
type ScopeDashboardBindingSpec,
type ScopeDashboardBindingStatus,

View File

@@ -0,0 +1,27 @@
import { LoadingState } from './data';
import { DataFrame } from './dataFrame';
import { DataLinkPostProcessor } from './fieldOverrides';
import { TimeZone } from './time';
/**
* Props for the PrometheusQueryResults exposed component.
* @see PluginExtensionExposedComponents.PrometheusQueryResultsV1
*/
export type PrometheusQueryResultsV1Props = {
/** Raw DataFrames to display (processing handled internally). Defaults to empty array. */
tableResult?: DataFrame[];
/** Width of the container in pixels. Defaults to 800. */
width?: number;
/** Timezone for value formatting. Defaults to 'browser'. */
timeZone?: TimeZone;
/** Loading state for panel chrome indicator */
loading?: LoadingState;
/** Aria label for accessibility */
ariaLabel?: string;
/** Start in Raw view instead of Table view. When true, shows toggle. */
showRawPrometheus?: boolean;
/** Callback when user adds a cell filter */
onCellFilterAdded?: (filter: { key: string; value: string; operator: '=' | '!=' }) => void;
/** Optional post-processor for data links (used by Explore for split view) */
dataLinkPostProcessor?: DataLinkPostProcessor;
};

View File

@@ -245,6 +245,7 @@ export enum PluginExtensionPointPatterns {
export enum PluginExtensionExposedComponents {
CentralAlertHistorySceneV1 = 'grafana/central-alert-history-scene/v1',
AddToDashboardFormV1 = 'grafana/add-to-dashboard-form/v1',
PrometheusQueryResultsV1 = 'grafana/prometheus-query-results/v1',
}
export type PluginExtensionPanelContext = {

View File

@@ -170,56 +170,42 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run
if !ok {
return nil, storewrapper.ErrUnauthenticated
}
r.logger.Debug("filtering resource permissions list with auth info",
"namespace", authInfo.GetNamespace(),
"identity Subject", authInfo.GetSubject(),
"identity UID", authInfo.GetUID(),
"identity type", authInfo.GetIdentityType(),
)
switch l := list.(type) {
case *iamv0.ResourcePermissionList:
r.logger.Debug("filtering list of length", "length", len(l.Items))
var (
filteredItems []iamv0.ResourcePermission
err error
canViewFuncs = map[schema.GroupResource]types.ItemChecker{}
)
for _, item := range l.Items {
target := item.Spec.Resource
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
r.logger.Debug("target resource",
"group", target.ApiGroup,
"resource", target.Resource,
"name", target.Name,
)
gr := schema.GroupResource{
Group: item.Spec.Resource.ApiGroup,
Resource: item.Spec.Resource.Resource,
}
// Reuse the same canView for items with the same resource
canView, found := canViewFuncs[targetGR]
canView, found := canViewFuncs[gr]
if !found {
listReq := types.ListRequest{
Namespace: item.Namespace,
Group: target.ApiGroup,
Resource: target.Resource,
Group: item.Spec.Resource.ApiGroup,
Resource: item.Spec.Resource.Resource,
Verb: utils.VerbGetPermissions,
}
r.logger.Debug("compiling list request",
"namespace", item.Namespace,
"group", target.ApiGroup,
"resource", target.Resource,
"verb", utils.VerbGetPermissions,
)
canView, _, err = r.accessClient.Compile(ctx, authInfo, listReq)
if err != nil {
return nil, err
}
canViewFuncs[targetGR] = canView
canViewFuncs[gr] = canView
}
target := item.Spec.Resource
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
parent := ""
// Fetch the parent of the resource
// It's not efficient to do for every item in the list, but it's a good starting point.
@@ -237,13 +223,6 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run
)
continue
}
r.logger.Debug("fetched parent",
"parent", p,
"namespace", item.Namespace,
"group", target.ApiGroup,
"resource", target.Resource,
"name", target.Name,
)
parent = p
}

View File

@@ -0,0 +1,69 @@
package provisioning
import (
"context"
"net/http"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
type connectionRepositoriesConnector struct{}
func NewConnectionRepositoriesConnector() *connectionRepositoriesConnector {
return &connectionRepositoriesConnector{}
}
func (*connectionRepositoriesConnector) New() runtime.Object {
return &provisioning.ExternalRepositoryList{}
}
func (*connectionRepositoriesConnector) Destroy() {}
func (*connectionRepositoriesConnector) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
}
func (*connectionRepositoriesConnector) ProducesObject(verb string) any {
return &provisioning.ExternalRepositoryList{}
}
func (*connectionRepositoriesConnector) ConnectMethods() []string {
return []string{http.MethodGet}
}
func (*connectionRepositoriesConnector) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (c *connectionRepositoriesConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
logger := logging.FromContext(ctx).With("logger", "connection-repositories-connector", "connection_name", name)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
responder.Error(apierrors.NewMethodNotSupported(provisioning.ConnectionResourceInfo.GroupResource(), r.Method))
return
}
logger.Debug("repositories endpoint called but not yet implemented")
// TODO: Implement repository listing from external git provider
// This will require:
// 1. Get the Connection object using logging.Context(r.Context(), logger)
// 2. Use the connection credentials to authenticate with the git provider
// 3. List repositories from the provider (GitHub, GitLab, Bitbucket)
// 4. Return ExternalRepositoryList with Name, Owner, and URL for each repository
responder.Error(apierrors.NewMethodNotSupported(provisioning.ConnectionResourceInfo.GroupResource(), "repositories endpoint not yet implemented"))
}), nil
}
var (
_ rest.Storage = (*connectionRepositoriesConnector)(nil)
_ rest.Connecter = (*connectionRepositoriesConnector)(nil)
_ rest.StorageMetadata = (*connectionRepositoriesConnector)(nil)
)

View File

@@ -0,0 +1,101 @@
package provisioning
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
func TestConnectionRepositoriesConnector(t *testing.T) {
connector := NewConnectionRepositoriesConnector()
t.Run("New returns ExternalRepositoryList", func(t *testing.T) {
obj := connector.New()
require.IsType(t, &provisioning.ExternalRepositoryList{}, obj)
})
t.Run("ProducesMIMETypes returns application/json", func(t *testing.T) {
types := connector.ProducesMIMETypes("GET")
require.Equal(t, []string{"application/json"}, types)
})
t.Run("ProducesObject returns ExternalRepositoryList", func(t *testing.T) {
obj := connector.ProducesObject("GET")
require.IsType(t, &provisioning.ExternalRepositoryList{}, obj)
})
t.Run("ConnectMethods returns GET", func(t *testing.T) {
methods := connector.ConnectMethods()
require.Equal(t, []string{http.MethodGet}, methods)
})
t.Run("NewConnectOptions returns no path component", func(t *testing.T) {
obj, hasPath, path := connector.NewConnectOptions()
require.Nil(t, obj)
require.False(t, hasPath)
require.Empty(t, path)
})
t.Run("Connect returns handler that rejects non-GET methods", func(t *testing.T) {
ctx := context.Background()
responder := &mockResponder{}
handler, err := connector.Connect(ctx, "test-connection", nil, responder)
require.NoError(t, err)
require.NotNil(t, handler)
// Test POST method (should be rejected)
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.True(t, responder.called)
require.NotNil(t, responder.err)
require.True(t, apierrors.IsMethodNotSupported(responder.err))
})
t.Run("Connect returns handler that returns not implemented for GET", func(t *testing.T) {
ctx := context.Background()
responder := &mockResponder{}
handler, err := connector.Connect(ctx, "test-connection", nil, responder)
require.NoError(t, err)
require.NotNil(t, handler)
// Test GET method (should return not implemented)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.True(t, responder.called)
require.NotNil(t, responder.err)
require.True(t, apierrors.IsMethodNotSupported(responder.err))
require.Contains(t, responder.err.Error(), "not yet implemented")
})
}
// mockResponder implements rest.Responder for testing
type mockResponder struct {
called bool
err error
obj runtime.Object
code int
}
func (m *mockResponder) Object(statusCode int, obj runtime.Object) {
m.called = true
m.code = statusCode
m.obj = obj
}
func (m *mockResponder) Error(err error) {
m.called = true
m.err = err
}

View File

@@ -480,6 +480,16 @@ func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a autho
Namespace: a.GetNamespace(),
}, ""))
// Repositories is read-only
case "repositories":
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
Verb: apiutils.VerbGet,
Group: provisioning.GROUP,
Resource: provisioning.ConnectionResourceInfo.GetName(),
Name: a.GetName(),
Namespace: a.GetNamespace(),
}, ""))
default:
id, err := identity.GetRequester(ctx)
if err != nil {
@@ -603,6 +613,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage[provisioning.ConnectionResourceInfo.StoragePath()] = connectionsStore
storage[provisioning.ConnectionResourceInfo.StoragePath("status")] = connectionStatusStorage
storage[provisioning.ConnectionResourceInfo.StoragePath("repositories")] = NewConnectionRepositoriesConnector()
// 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))
@@ -1247,6 +1258,23 @@ spec:
oas.Paths.Paths[repoprefix+"/jobs/{uid}"] = sub
}
// Document connection repositories endpoint
connectionprefix := root + "namespaces/{namespace}/connections/{name}"
sub = oas.Paths.Paths[connectionprefix+"/repositories"]
if sub != nil {
sub.Get.Description = "List repositories available from the external git provider through this connection"
sub.Get.Summary = "List external repositories"
sub.Get.Parameters = []*spec3.Parameter{}
sub.Post = nil
sub.Put = nil
sub.Delete = nil
// Replace the content type for this response
mt := sub.Get.Responses.StatusCodeResponses[200].Content
s := defs[defsBase+"ExternalRepositoryList"].Schema
mt["*/*"].Schema = &s
}
// Run all extra post-processors.
for _, extra := range b.extras {
if err := extra.PostProcessOpenAPI(oas); err != nil {

View File

@@ -866,6 +866,80 @@
}
]
},
"/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/repositories": {
"get": {
"tags": [
"Connection"
],
"summary": "List external repositories",
"description": "List repositories available from the external git provider through this connection",
"operationId": "getConnectionRepositories",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"description": "ExternalRepositoryList lists repositories from an external git provider",
"type": "object",
"required": [
"items"
],
"properties": {
"apiVersion": {
"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"
},
"items": {
"type": "array",
"items": {
"default": {}
},
"x-kubernetes-list-type": "atomic"
},
"kind": {
"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"
},
"metadata": {
"default": {}
}
}
}
}
}
}
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "provisioning.grafana.app",
"version": "v0alpha1",
"kind": "ExternalRepositoryList"
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the ExternalRepositoryList",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/status": {
"get": {
"tags": [
@@ -4645,6 +4719,73 @@
}
}
},
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepository": {
"type": "object",
"required": [
"name",
"url"
],
"properties": {
"name": {
"description": "Name of the repository",
"type": "string",
"default": ""
},
"owner": {
"description": "Owner is the user, organization, or workspace that owns the repository For GitHub: organization or user For GitLab: namespace (user or group) For Bitbucket: workspace For pure Git: empty",
"type": "string"
},
"url": {
"description": "URL of the repository",
"type": "string",
"default": ""
}
}
},
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepositoryList": {
"description": "ExternalRepositoryList lists repositories from an external git provider",
"type": "object",
"required": [
"items"
],
"properties": {
"apiVersion": {
"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"
},
"items": {
"type": "array",
"items": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepository"
}
]
},
"x-kubernetes-list-type": "atomic"
},
"kind": {
"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"
},
"metadata": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta"
}
]
}
},
"x-kubernetes-group-version-kind": [
{
"group": "provisioning.grafana.app",
"kind": "ExternalRepositoryList",
"version": "v0alpha1"
}
]
},
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.FileItem": {
"type": "object",
"required": [

View File

@@ -0,0 +1,172 @@
package provisioning
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
// Create a connection for testing
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection-repositories-test",
"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",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.NoError(t, err, "failed to create connection")
t.Run("endpoint returns not implemented", func(t *testing.T) {
var statusCode int
result := helper.AdminREST.Get().
Namespace("default").
Resource("connections").
Name("connection-repositories-test").
SubResource("repositories").
Do(ctx).
StatusCode(&statusCode)
require.Error(t, result.Error(), "should return error for not implemented endpoint")
require.Equal(t, http.StatusMethodNotAllowed, statusCode, "should return 405 Method Not Allowed")
require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported")
})
t.Run("admin can access endpoint (gets not implemented)", func(t *testing.T) {
var statusCode int
result := helper.AdminREST.Get().
Namespace("default").
Resource("connections").
Name("connection-repositories-test").
SubResource("repositories").
Do(ctx).StatusCode(&statusCode)
// Endpoint exists but returns not implemented
require.Error(t, result.Error(), "should return error")
require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported")
// Status code should be 405 (Method Not Allowed) for method not supported
require.Equal(t, http.StatusMethodNotAllowed, statusCode)
})
t.Run("editor cannot access endpoint", func(t *testing.T) {
var statusCode int
result := helper.EditorREST.Get().
Namespace("default").
Resource("connections").
Name("connection-repositories-test").
SubResource("repositories").
Do(ctx).StatusCode(&statusCode)
require.Error(t, result.Error(), "editor should not be able to access repositories endpoint")
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
})
t.Run("viewer cannot access endpoint", func(t *testing.T) {
var statusCode int
result := helper.ViewerREST.Get().
Namespace("default").
Resource("connections").
Name("connection-repositories-test").
SubResource("repositories").
Do(ctx).StatusCode(&statusCode)
require.Error(t, result.Error(), "viewer should not be able to access repositories endpoint")
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
})
t.Run("non-GET methods are rejected", func(t *testing.T) {
configBytes, _ := json.Marshal(map[string]any{})
var statusCode int
result := helper.AdminREST.Post().
Namespace("default").
Resource("connections").
Name("connection-repositories-test").
SubResource("repositories").
Body(configBytes).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&statusCode)
require.Error(t, result.Error(), "POST should not be allowed")
require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported")
})
}
func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
// Create a connection for testing
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection-repositories-type-test",
"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",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.NoError(t, err, "failed to create connection")
t.Run("verify ExternalRepositoryList type exists in API", func(t *testing.T) {
// Verify the type is registered and can be instantiated
list := &provisioning.ExternalRepositoryList{}
require.NotNil(t, list)
// Verify it has the expected structure (Items is a slice, nil by default is fine)
require.IsType(t, []provisioning.ExternalRepository{}, list.Items)
// Can create items
list.Items = []provisioning.ExternalRepository{
{Name: "test", Owner: "owner", URL: "https://example.com/repo"},
}
require.Len(t, list.Items, 1)
require.Equal(t, "test", list.Items[0].Name)
})
}

View File

@@ -1,10 +1,9 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame, LoadingState } from '@grafana/data';
import { FieldType, InternalTimeZones, toDataFrame, LoadingState } from '@grafana/data';
import { getTemplateSrv } from 'app/features/templating/template_srv';
import { TABLE_RESULTS_STYLE } from 'app/types/explore';
import { RawPrometheusContainer } from './RawPrometheusContainer';
import { PrometheusQueryResultsContainer } from './PrometheusQueryResultsContainer';
function getTable(): HTMLElement {
return screen.getAllByRole('table')[0];
@@ -52,27 +51,30 @@ const dataFrame = toDataFrame({
});
const defaultProps = {
exploreId: 'left',
loading: LoadingState.NotStarted,
width: 800,
onCellFilterAdded: jest.fn(),
tableResult: [dataFrame],
splitOpenFn: () => {},
range: getDefaultTimeRange(),
timeZone: InternalTimeZones.utc,
resultsStyle: TABLE_RESULTS_STYLE.raw,
showRawPrometheus: false,
};
describe('RawPrometheusContainer', () => {
describe('PrometheusQueryResultsContainer', () => {
beforeAll(() => {
getTemplateSrv();
});
it('should render component for prometheus', () => {
render(<RawPrometheusContainer {...defaultProps} showRawPrometheus={true} />);
it('should render table with data and toggle when showRawPrometheus is true', async () => {
render(<PrometheusQueryResultsContainer {...defaultProps} showRawPrometheus={true} />);
// Wait for lazy-loaded component to render
await waitFor(() => {
expect(screen.queryAllByRole('table').length).toBe(1);
});
// Toggle should be visible
expect(screen.queryAllByRole('radio').length).toBeGreaterThan(0);
expect(screen.queryAllByRole('table').length).toBe(1);
fireEvent.click(getTableToggle());
expect(getTable()).toBeInTheDocument();
@@ -85,4 +87,25 @@ describe('RawPrometheusContainer', () => {
{ time: '2021-01-01 02:00:00', text: 'test_string_4' },
]);
});
it('should render table without toggle when showRawPrometheus is false', async () => {
render(<PrometheusQueryResultsContainer {...defaultProps} showRawPrometheus={false} />);
// Wait for lazy-loaded component to render
await waitFor(() => {
expect(screen.queryAllByRole('table').length).toBe(1);
});
// Toggle should NOT be visible
expect(screen.queryAllByRole('radio').length).toBe(0);
});
it('should render empty state when no data', async () => {
render(<PrometheusQueryResultsContainer {...defaultProps} tableResult={[]} showRawPrometheus={true} />);
// Wait for lazy-loaded component to render
await waitFor(() => {
expect(screen.getByText('0 series returned')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,70 @@
import { cloneDeep } from 'lodash';
import { lazy, Suspense, useMemo } from 'react';
import { applyFieldOverrides, PrometheusQueryResultsV1Props } from '@grafana/data';
import { config, getTemplateSrv } from '@grafana/runtime';
const RawPrometheusContainerPureLazy = lazy(() =>
import('./RawPrometheusContainerPure').then((m) => ({ default: m.RawPrometheusContainerPure }))
);
/**
* EXPOSED COMPONENT (stable): grafana/prometheus-query-results/v1
*
* This component is exposed to plugins via the Plugin Extensions system.
* Treat its props and user-visible behavior as a stable contract. Do not make
* breaking changes in-place. If you need to change the API or behavior in a
* breaking way, create a new versioned component (e.g. PrometheusQueryResultsV2)
* and register it under a new ID: "grafana/prometheus-query-results/v2".
*
* Displays Prometheus query results with Table/Raw toggle.
* Pass raw DataFrames - processing (applyFieldOverrides) is handled internally.
*
* Example usage in a plugin:
* ```typescript
* import { usePluginComponent } from '@grafana/runtime';
* import { PluginExtensionExposedComponents } from '@grafana/data';
*
* const { component: PrometheusQueryResults } = usePluginComponent(
* PluginExtensionExposedComponents.PrometheusQueryResultsV1
* );
*
* // Render - just pass raw data
* <PrometheusQueryResults tableResult={rawDataFrames} width={800} timeZone="browser" />
* ```
*/
export const PrometheusQueryResultsContainer = (props: PrometheusQueryResultsV1Props) => {
const width = props.width ?? 800;
const timeZone = props.timeZone ?? 'browser';
// Memoize cloneDeep + applyFieldOverrides to avoid expensive operations on every render
// cloneDeep is needed to avoid mutating frozen props from plugin extension system
const processedData = useMemo(() => {
const tableResult = props.tableResult ?? [];
const cloned = cloneDeep(tableResult);
if (cloned?.length) {
return applyFieldOverrides({
data: cloned,
timeZone,
theme: config.theme2,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
fieldConfig: { defaults: {}, overrides: [] },
dataLinkPostProcessor: props.dataLinkPostProcessor,
});
}
return cloned;
}, [props.tableResult, timeZone, props.dataLinkPostProcessor]);
return (
<Suspense fallback={null}>
<RawPrometheusContainerPureLazy
tableResult={processedData}
width={width}
loading={props.loading}
ariaLabel={props.ariaLabel}
showRawPrometheus={props.showRawPrometheus}
onCellFilterAdded={props.onCellFilterAdded}
/>
</Suspense>
);
};

View File

@@ -1,31 +1,31 @@
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { memo, useMemo } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { DataFrame, SplitOpen } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { AdHocFilterItem } from '@grafana/ui';
import { ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types/store';
import { MetaInfoText } from '../MetaInfoText';
import RawListContainer from '../PrometheusListView/RawListContainer';
import { exploreDataLinkPostProcessorFactory } from '../utils/links';
interface RawPrometheusContainerProps {
import { PrometheusQueryResultsContainer } from './PrometheusQueryResultsContainer';
// ============================================================================
// Redux-connected Component - Used by Explore
// ============================================================================
interface ExploreRawPrometheusContainerProps {
ariaLabel?: string;
exploreId: string;
width: number;
timeZone: TimeZone;
onCellFilterAdded?: (filter: AdHocFilterItem) => void;
showRawPrometheus?: boolean;
splitOpenFn: SplitOpen;
splitOpenFn?: SplitOpen;
}
function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContainerProps) {
function mapStateToProps(state: StoreState, { exploreId }: ExploreRawPrometheusContainerProps) {
const explore = state.explore;
const item: ExploreItemState = explore.panes[exploreId]!;
const { rawPrometheusResult, range, queryResponse } = item;
@@ -37,121 +37,47 @@ function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContaine
const connector = connect(mapStateToProps, {});
type Props = RawPrometheusContainerProps & ConnectedProps<typeof connector>;
type ExploreProps = ExploreRawPrometheusContainerProps & ConnectedProps<typeof connector>;
export const RawPrometheusContainer = memo(
/**
* Redux-connected wrapper for Explore.
* Gets data from Redux and passes to PrometheusQueryResultsContainer for processing and display.
*/
const ExploreRawPrometheusContainer = memo(
({
loading,
onCellFilterAdded,
tableResult,
width,
splitOpenFn,
range,
ariaLabel,
timeZone,
showRawPrometheus,
}: Props) => {
// If resultsStyle is undefined we won't render the toggle, and the default table will be rendered
const [resultsStyle, setResultsStyle] = useState<TableResultsStyle | undefined>(
showRawPrometheus ? TABLE_RESULTS_STYLE.raw : undefined
range,
splitOpenFn,
}: ExploreProps) => {
const dataLinkPostProcessor = useMemo(
() => exploreDataLinkPostProcessorFactory(splitOpenFn, range),
[splitOpenFn, range]
);
const onChangeResultsStyle = (newResultsStyle: TableResultsStyle) => {
setResultsStyle(newResultsStyle);
};
const getTableHeight = () => {
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
};
const renderLabel = () => {
const spacing = css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
});
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={spacing}>
<RadioButtonGroup
onClick={() => {
const props = {
state: resultsStyle === TABLE_RESULTS_STYLE.table ? TABLE_RESULTS_STYLE.raw : TABLE_RESULTS_STYLE.table,
};
reportInteraction('grafana_explore_prometheus_instant_query_ui_toggle_clicked', props);
}}
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={resultsStyle}
onChange={onChangeResultsStyle}
/>
</div>
);
};
const height = getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
let dataFrames = tableResult;
const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range);
if (dataFrames?.length) {
dataFrames = applyFieldOverrides({
data: dataFrames,
timeZone,
theme: config.theme2,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
fieldConfig: {
defaults: {},
overrides: [],
},
dataLinkPostProcessor,
});
}
const frames = dataFrames?.filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const title = resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table';
const label = resultsStyle !== undefined ? renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
const renderTable = !resultsStyle || resultsStyle === TABLE_RESULTS_STYLE.table;
return (
<PanelChrome title={title} actions={label} loadingState={loading}>
{frames?.length && (
<>
{renderTable && (
<Table
ariaLabel={ariaLabel}
data={frames[0]}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}
/>
)}
{resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</>
)}
{!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome>
<PrometheusQueryResultsContainer
tableResult={tableResult}
width={width}
timeZone={timeZone}
loading={loading}
ariaLabel={ariaLabel}
showRawPrometheus={showRawPrometheus}
onCellFilterAdded={onCellFilterAdded}
dataLinkPostProcessor={dataLinkPostProcessor}
/>
);
}
);
RawPrometheusContainer.displayName = 'RawPrometheusContainer';
ExploreRawPrometheusContainer.displayName = 'ExploreRawPrometheusContainer';
export default connector(RawPrometheusContainer);
// Keep the old export name for backwards compatibility
export const RawPrometheusContainer = ExploreRawPrometheusContainer;
export default connector(ExploreRawPrometheusContainer);

View File

@@ -0,0 +1,134 @@
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { DataFrame, GrafanaTheme2, LoadingState, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { MetaInfoText } from '../MetaInfoText';
import RawListContainer from '../PrometheusListView/RawListContainer';
const getStyles = (_theme: GrafanaTheme2) => ({
spacing: css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
}),
});
/**
* Props for the pure RawPrometheusContainer component.
* This component expects pre-processed DataFrames (caller should apply applyFieldOverrides).
*/
export interface RawPrometheusContainerPureProps {
/** Pre-processed DataFrames to display */
tableResult: DataFrame[];
/** Width of the container in pixels */
width: number;
/** Loading state for panel chrome indicator */
loading?: LoadingState;
/** Aria label for accessibility */
ariaLabel?: string;
/** Start in Raw view instead of Table view. When true, shows toggle. When false/undefined, shows table only. */
showRawPrometheus?: boolean;
/** Callback when user adds a cell filter */
onCellFilterAdded?: (filter: AdHocFilterItem) => void;
}
/**
* Pure component for displaying Prometheus query results with Table/Raw toggle.
* This component does NOT connect to Redux and expects pre-processed data.
*/
export const RawPrometheusContainerPure = memo(
({
loading,
onCellFilterAdded,
tableResult,
width,
ariaLabel,
showRawPrometheus,
}: RawPrometheusContainerPureProps) => {
const styles = useStyles2(getStyles);
// If resultsStyle is undefined we won't render the toggle, and the default table will be rendered
const [resultsStyle, setResultsStyle] = useState<TableResultsStyle | undefined>(
showRawPrometheus ? TABLE_RESULTS_STYLE.raw : undefined
);
const onChangeResultsStyle = (newResultsStyle: TableResultsStyle) => {
setResultsStyle(newResultsStyle);
};
const getTableHeight = () => {
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
};
const renderLabel = () => {
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={styles.spacing}>
<RadioButtonGroup
onClick={() => {
const props = {
state: resultsStyle === TABLE_RESULTS_STYLE.table ? TABLE_RESULTS_STYLE.raw : TABLE_RESULTS_STYLE.table,
};
reportInteraction('grafana_explore_prometheus_instant_query_ui_toggle_clicked', props);
}}
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={resultsStyle}
onChange={onChangeResultsStyle}
/>
</div>
);
};
const height = getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
const frames = tableResult?.filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const title = resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table';
const label = resultsStyle !== undefined ? renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
const renderTable = !resultsStyle || resultsStyle === TABLE_RESULTS_STYLE.table;
return (
<PanelChrome title={title} actions={label} loadingState={loading}>
{frames?.length && (
<>
{renderTable && (
<Table
ariaLabel={ariaLabel}
data={frames[0]}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}
/>
)}
{resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</>
)}
{!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome>
);
}
);
RawPrometheusContainerPure.displayName = 'RawPrometheusContainerPure';

View File

@@ -1,6 +1,7 @@
import { PluginExtensionExposedComponents } from '@grafana/data';
import CentralAlertHistorySceneExposedComponent from 'app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistorySceneExposedComponent';
import { AddToDashboardFormExposedComponent } from 'app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent';
import { PrometheusQueryResultsContainer } from 'app/features/explore/RawPrometheus/PrometheusQueryResultsContainer';
import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations';
@@ -43,5 +44,11 @@ exposedComponentsRegistry.register({
description: 'Add to dashboard form',
component: AddToDashboardFormExposedComponent,
},
{
id: PluginExtensionExposedComponents.PrometheusQueryResultsV1,
title: 'Prometheus query results',
description: 'Display Prometheus query results with Table/Raw toggle',
component: PrometheusQueryResultsContainer,
},
],
});

View File

@@ -3009,7 +3009,7 @@ __metadata:
"@grafana/api-clients": "npm:12.4.0-pre"
"@grafana/i18n": "npm:12.4.0-pre"
"@grafana/test-utils": "workspace:*"
"@reduxjs/toolkit": "npm:^2.9.0"
"@reduxjs/toolkit": "npm:2.10.1"
"@testing-library/jest-dom": "npm:^6.6.3"
"@testing-library/react": "npm:^16.3.0"
"@testing-library/user-event": "npm:^14.6.1"
@@ -3052,7 +3052,7 @@ __metadata:
typescript: "npm:5.9.2"
peerDependencies:
"@grafana/runtime": ">=11.6 <= 12.x"
"@reduxjs/toolkit": ^2.8.0
"@reduxjs/toolkit": ^2.10.0
rxjs: 7.8.2
languageName: unknown
linkType: soft
@@ -7294,7 +7294,7 @@ __metadata:
languageName: node
linkType: hard
"@reduxjs/toolkit@npm:2.10.1, @reduxjs/toolkit@npm:^2.9.0":
"@reduxjs/toolkit@npm:2.10.1":
version: 2.10.1
resolution: "@reduxjs/toolkit@npm:2.10.1"
dependencies: