diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ef476f4d272..ec656436ee7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -94,7 +94,6 @@ /apps/shorturl/ @grafana/sharing-squad /apps/secret/ @grafana/grafana-operator-experience-squad /apps/scope/ @grafana/grafana-operator-experience-squad -/apps/investigations/ @fcjack @matryer @svennergr /apps/advisor/ @grafana/plugins-platform-backend /apps/iam/ @grafana/access-squad /apps/sdk.mk @grafana/grafana-app-platform-squad diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 79987600b9a..6356a793b36 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,7 +19,6 @@ updates: - "/apps/dashboard" - "/apps/folder" - "/apps/iam" - - "/apps/investigations" - "/apps/playlist" - "/apps/plugins" - "/apps/preferences" diff --git a/.golangci.yml b/.golangci.yml index b52a986d435..069e88632ff 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -67,14 +67,6 @@ linters: deny: - pkg: github.com/grafana/grafana/pkg desc: apiserver is not allowed to import grafana core - apps-investigation: - list-mode: lax - files: - - ./apps/investigations/* - - ./apps/investigations/**/* - deny: - - pkg: github.com/grafana/grafana/pkg - desc: apps/investigations is not allowed to import grafana core apps-playlist: list-mode: lax files: diff --git a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch b/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch similarity index 73% rename from .yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch rename to .yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch index 730ecce8fb2..f3b7cb48e98 100644 --- a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch +++ b/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch @@ -1,8 +1,8 @@ diff --git a/dist/builder-manager/index.js b/dist/builder-manager/index.js -index 3d7f9b213dae1801bda62b31db31b9113e382ccd..212501c63d20146c29db63fb0f6300c6779eecb5 100644 +index ac8ac6a5f6a3b7852c4064e93dc9acd3201289e6..34a0a5a5c38dd7fe525c9ebd382a10a451d4d4f3 100644 --- a/dist/builder-manager/index.js +++ b/dist/builder-manager/index.js -@@ -1970,7 +1970,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { +@@ -1974,7 +1974,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { bundle: !0, minify: !0, sourcemap: !1, diff --git a/Dockerfile b/Dockerfile index 2b16926a836..d3c2fc9ef5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,7 @@ COPY pkg/storage/unified/resource pkg/storage/unified/resource COPY pkg/storage/unified/resourcepb pkg/storage/unified/resourcepb COPY pkg/storage/unified/apistore pkg/storage/unified/apistore COPY pkg/semconv pkg/semconv +COPY pkg/plugins pkg/plugins COPY pkg/aggregator pkg/aggregator COPY apps/playlist apps/playlist COPY apps/quotas apps/quotas @@ -103,7 +104,6 @@ COPY apps/collections apps/collections COPY apps/provisioning apps/provisioning COPY apps/secret apps/secret COPY apps/scope apps/scope -COPY apps/investigations apps/investigations COPY apps/logsdrilldown apps/logsdrilldown COPY apps/advisor apps/advisor COPY apps/dashboard apps/dashboard diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json new file mode 100644 index 00000000000..7bb2bc70f81 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json @@ -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": "" + } +} + + \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json new file mode 100644 index 00000000000..9c6354b2319 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json @@ -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" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json new file mode 100644 index 00000000000..23affbf4d4a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json @@ -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" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json new file mode 100644 index 00000000000..758ffd9c348 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json @@ -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" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go index d0679c76e63..df2db1ac5a1 100644 --- a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go @@ -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, + } + } + } + } + } +} diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 59c622b07bc..3eeb61893fa 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -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 diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json index f705124be5b..5b07f246ae3 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json @@ -290,7 +290,7 @@ ], "legend": { "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true, "values": [ "percent" @@ -304,7 +304,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -323,6 +323,15 @@ } ], "title": "Percent", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^Backend-(.*)$", + "renamePattern": "b-$1" + } + } + ], "type": "piechart" }, { @@ -366,7 +375,7 @@ ], "legend": { "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true, "values": [ "value" @@ -380,7 +389,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -399,6 +408,15 @@ } ], "title": "Value", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "(.*)", + "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" + } + } + ], "type": "piechart" }, { diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 0c91d66c945..e35a733d350 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -24,8 +24,6 @@ replace github.com/grafana/grafana/apps/alerting/historian => ../alerting/histor replace github.com/grafana/grafana/apps/correlations => ../correlations -replace github.com/grafana/grafana/apps/investigations => ../investigations - replace github.com/grafana/grafana/apps/logsdrilldown => ../logsdrilldown replace github.com/grafana/grafana/apps/playlist => ../playlist diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 7c089ae155c..063696e3d5a 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -82,8 +82,8 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0= @@ -749,6 +749,8 @@ github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs= +github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -887,8 +889,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= diff --git a/apps/iam/pkg/apis/iam/v0alpha1/extensions.go b/apps/iam/pkg/apis/iam/v0alpha1/extensions.go deleted file mode 100644 index c0c5a11f7e3..00000000000 --- a/apps/iam/pkg/apis/iam/v0alpha1/extensions.go +++ /dev/null @@ -1,43 +0,0 @@ -package v0alpha1 - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -func (u User) AuthID() string { - meta, err := utils.MetaAccessor(&u) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} - -func (s ServiceAccount) AuthID() string { - meta, err := utils.MetaAccessor(&s) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} - -func (t Team) AuthID() string { - meta, err := utils.MetaAccessor(&t) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} diff --git a/apps/investigations/Makefile b/apps/investigations/Makefile deleted file mode 100644 index bc8d6d30cb5..00000000000 --- a/apps/investigations/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -include ../sdk.mk - -.PHONY: generate # Run Grafana App SDK code generation -generate: install-app-sdk update-app-sdk - @$(APP_SDK_BIN) generate \ - --source=./kinds/ \ - --gogenpath=./pkg/apis \ - --grouping=group \ - --genoperatorstate=false \ - --defencoding=none \ No newline at end of file diff --git a/apps/investigations/example.json b/apps/investigations/example.json deleted file mode 100644 index 0a390fb074f..00000000000 --- a/apps/investigations/example.json +++ /dev/null @@ -1,152 +0,0 @@ -[ - { - "id": "896312ce-65b0-4b50-ade1-e7f04fa22c66", - "title": "Thursday morning investigation", - "hasCustomName": false, - "isFavorite": false, - "collectables": [ - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{detected_level}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\"} | detected_level != \"\"[$__auto])) by (detected_level)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_detected_level", - "title": "detected_level", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:23.637Z" - } - ], - "createdAt": "2025-02-13T11:31:23.636Z", - "updatedAt": "2025-02-13T11:31:23.637Z", - "viewMode": { - "mode": "compact", - "showComments": true, - "showTooltips": false - } - }, - { - "id": "e9cf1958-d0ed-46b7-b597-9052c7648656", - "title": "Thursday morning investigation", - "hasCustomName": false, - "isFavorite": false, - "collectables": [ - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{detected_level}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\"} | detected_level != \"\"[$__auto])) by (detected_level)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_detected_level", - "title": "detected_level", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:23.638Z" - }, - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{service_name}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\",service_name != \"\"} [$__auto])) by (service_name)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_service_name", - "title": "service_name", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:41.507Z" - }, - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{service}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\",service != \"\"} [$__auto])) by (service)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_service", - "title": "service", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:43.698Z" - } - ], - "createdAt": "2025-02-13T11:31:23.637Z", - "updatedAt": "2025-02-13T11:31:43.698Z", - "viewMode": { - "mode": "compact", - "showComments": true, - "showTooltips": false - } - } -] diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod deleted file mode 100644 index 40677b56dab..00000000000 --- a/apps/investigations/go.mod +++ /dev/null @@ -1,102 +0,0 @@ -module github.com/grafana/grafana/apps/investigations - -go 1.25.5 - -require ( - github.com/grafana/grafana-app-sdk v0.48.7 - k8s.io/apimachinery v0.34.3 - k8s.io/klog/v2 v2.130.1 - k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.13.0 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-test/deep v1.1.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.7 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/onsi/ginkgo/v2 v2.22.2 // indirect - github.com/onsi/gomega v1.36.2 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect - github.com/prometheus/procfs v0.19.2 // indirect - github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/woodsbury/decimal128 v1.4.0 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.3 // indirect - k8s.io/apiextensions-apiserver v0.34.3 // indirect - k8s.io/client-go v0.34.3 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect - sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum deleted file mode 100644 index e5233ba82c9..00000000000 --- a/apps/investigations/go.sum +++ /dev/null @@ -1,264 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= -github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= -github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= -github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= -github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= -github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= -github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= -github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= -github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= -k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/investigations/kinds/collectable.cue b/apps/investigations/kinds/collectable.cue deleted file mode 100644 index f9fc754a67c..00000000000 --- a/apps/investigations/kinds/collectable.cue +++ /dev/null @@ -1,43 +0,0 @@ -package investigations - -// Collectable represents an item collected during investigation -#Collectable: { - id: string - createdAt: string - - title: string - origin: string - type: string - queries: [...string] // +listType=atomic - timeRange: #TimeRange - datasource: #DatasourceRef - url: string - logoPath?: string - - note: string - noteUpdatedAt: string - - fieldConfig: string -} - -#CollectableSummary: { - id: string - title: string - logoPath: string - origin: string -} - -// TimeRange represents a time range with both absolute and relative values -#TimeRange: { - from: string - to: string - raw: { - from: string - to: string - } -} - -// DatasourceRef is a reference to a datasource -#DatasourceRef: { - uid: string -} diff --git a/apps/investigations/kinds/cue.mod/module.cue b/apps/investigations/kinds/cue.mod/module.cue deleted file mode 100644 index c592b1ce788..00000000000 --- a/apps/investigations/kinds/cue.mod/module.cue +++ /dev/null @@ -1,4 +0,0 @@ -module: "github.com/grafana/grafana/apps/investigations" -language: { - version: "v0.11.0" -} \ No newline at end of file diff --git a/apps/investigations/kinds/investigation.cue b/apps/investigations/kinds/investigation.cue deleted file mode 100644 index 1ade89416b9..00000000000 --- a/apps/investigations/kinds/investigation.cue +++ /dev/null @@ -1,43 +0,0 @@ -package investigations - -investigationV0alpha1: { - kind: "Investigation" - pluralName: "Investigations" - schema: { - spec: { - title: string - createdByProfile: #Person - hasCustomName: bool - isFavorite: bool - overviewNote: string - overviewNoteUpdatedAt: string - collectables: [...#Collectable] // +listType=atomic - viewMode: #ViewMode - } - } -} - -// Type definition for investigation summaries -#InvestigationSummary: { - title: string - createdByProfile: #Person - hasCustomName: bool - isFavorite: bool - overviewNote: string - overviewNoteUpdatedAt: string - viewMode: #ViewMode - collectableSummaries: [...#CollectableSummary] // +listType=atomic -} - -// Person represents a user profile with basic information -#Person: { - uid: string // Unique identifier for the user - name: string // Display name of the user - gravatarUrl: string // URL to user's Gravatar image -} - -#ViewMode: { - mode: "compact" | "full" - showComments: bool - showTooltips: bool -} diff --git a/apps/investigations/kinds/investigationindex.cue b/apps/investigations/kinds/investigationindex.cue deleted file mode 100644 index 65c5e3ae79f..00000000000 --- a/apps/investigations/kinds/investigationindex.cue +++ /dev/null @@ -1,18 +0,0 @@ -package investigations - -investigationIndexV0alpha1:{ - kind: "InvestigationIndex" - pluralName: "InvestigationIndexes" - schema: { - spec: { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - title: string - - // The Person who owns this investigation index - owner: #Person - - // Array of investigation summaries - investigationSummaries: [...#InvestigationSummary] // +listType=atomic - } - } -} diff --git a/apps/investigations/kinds/manifest.cue b/apps/investigations/kinds/manifest.cue deleted file mode 100644 index 947d84be482..00000000000 --- a/apps/investigations/kinds/manifest.cue +++ /dev/null @@ -1,18 +0,0 @@ -package investigations - -manifest: { - appName: "investigations" - groupOverride: "investigations.grafana.app" - versions: { - "v0alpha1": { - codegen: { - ts: {enabled: false} - go: {enabled: true} - } - kinds: [ - investigationV0alpha1, - investigationIndexV0alpha1, - ] - } - } -} \ No newline at end of file diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go b/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go deleted file mode 100644 index bd538eb9c30..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v0alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "investigations.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v0alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go deleted file mode 100644 index dea4d71802d..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go +++ /dev/null @@ -1,80 +0,0 @@ -package v0alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" -) - -type InvestigationClient struct { - client *resource.TypedClient[*Investigation, *InvestigationList] -} - -func NewInvestigationClient(client resource.Client) *InvestigationClient { - return &InvestigationClient{ - client: resource.NewTypedClient[*Investigation, *InvestigationList](client, InvestigationKind()), - } -} - -func NewInvestigationClientFromGenerator(generator resource.ClientGenerator) (*InvestigationClient, error) { - c, err := generator.ClientFor(InvestigationKind()) - if err != nil { - return nil, err - } - return NewInvestigationClient(c), nil -} - -func (c *InvestigationClient) Get(ctx context.Context, identifier resource.Identifier) (*Investigation, error) { - return c.client.Get(ctx, identifier) -} - -func (c *InvestigationClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *InvestigationClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *InvestigationClient) Create(ctx context.Context, obj *Investigation, opts resource.CreateOptions) (*Investigation, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = InvestigationKind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *InvestigationClient) Update(ctx context.Context, obj *Investigation, opts resource.UpdateOptions) (*Investigation, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *InvestigationClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Investigation, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *InvestigationClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go deleted file mode 100644 index 6bde81b50d3..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// InvestigationJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type InvestigationJSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*InvestigationJSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*InvestigationJSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &InvestigationJSONCodec{} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go deleted file mode 100644 index ffd588fec5e..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type InvestigationMetadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewInvestigationMetadata creates a new InvestigationMetadata object. -func NewInvestigationMetadata() *InvestigationMetadata { - return &InvestigationMetadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go deleted file mode 100644 index f64d7b6e047..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go +++ /dev/null @@ -1,293 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type Investigation struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the Investigation - Spec InvestigationSpec `json:"spec" yaml:"spec"` -} - -func (o *Investigation) GetSpec() any { - return o.Spec -} - -func (o *Investigation) SetSpec(spec any) error { - cast, ok := spec.(InvestigationSpec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *Investigation) GetSubresources() map[string]any { - return map[string]any{} -} - -func (o *Investigation) GetSubresource(name string) (any, bool) { - switch name { - default: - return nil, false - } -} - -func (o *Investigation) SetSubresource(name string, value any) error { - switch name { - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *Investigation) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *Investigation) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *Investigation) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *Investigation) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *Investigation) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *Investigation) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *Investigation) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *Investigation) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *Investigation) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *Investigation) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *Investigation) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *Investigation) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *Investigation) DeepCopy() *Investigation { - cpy := &Investigation{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *Investigation) DeepCopyInto(dst *Investigation) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) -} - -// Interface compliance compile-time check -var _ resource.Object = &Investigation{} - -// +k8s:openapi-gen=true -type InvestigationList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []Investigation `json:"items" yaml:"items"` -} - -func (o *InvestigationList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationList) Copy() resource.ListObject { - cpy := &InvestigationList{ - TypeMeta: o.TypeMeta, - Items: make([]Investigation, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*Investigation); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *InvestigationList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *InvestigationList) SetItems(items []resource.Object) { - o.Items = make([]Investigation, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*Investigation) - } -} - -func (o *InvestigationList) DeepCopy() *InvestigationList { - cpy := &InvestigationList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationList) DeepCopyInto(dst *InvestigationList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &InvestigationList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *InvestigationSpec) DeepCopy() *InvestigationSpec { - cpy := &InvestigationSpec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *InvestigationSpec) DeepCopyInto(dst *InvestigationSpec) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go deleted file mode 100644 index 2a1f03c884a..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaInvestigation = resource.NewSimpleSchema("investigations.grafana.app", "v0alpha1", &Investigation{}, &InvestigationList{}, resource.WithKind("Investigation"), - resource.WithPlural("investigations"), resource.WithScope(resource.NamespacedScope)) - kindInvestigation = resource.Kind{ - Schema: schemaInvestigation, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &InvestigationJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func InvestigationKind() resource.Kind { - return kindInvestigation -} - -// Schema returns a resource.SimpleSchema representation of Investigation -func InvestigationSchema() *resource.SimpleSchema { - return schemaInvestigation -} - -// Interface compliance checks -var _ resource.Schema = kindInvestigation diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go deleted file mode 100644 index 88d74be54eb..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go +++ /dev/null @@ -1,126 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// Person represents a user profile with basic information -// +k8s:openapi-gen=true -type InvestigationPerson struct { - // Unique identifier for the user - Uid string `json:"uid"` - // Display name of the user - Name string `json:"name"` - // URL to user's Gravatar image - GravatarUrl string `json:"gravatarUrl"` -} - -// NewInvestigationPerson creates a new InvestigationPerson object. -func NewInvestigationPerson() *InvestigationPerson { - return &InvestigationPerson{} -} - -// Collectable represents an item collected during investigation -// +k8s:openapi-gen=true -type InvestigationCollectable struct { - Id string `json:"id"` - CreatedAt string `json:"createdAt"` - Title string `json:"title"` - Origin string `json:"origin"` - Type string `json:"type"` - // +listType=atomic - Queries []string `json:"queries"` - TimeRange InvestigationTimeRange `json:"timeRange"` - Datasource InvestigationDatasourceRef `json:"datasource"` - Url string `json:"url"` - LogoPath *string `json:"logoPath,omitempty"` - Note string `json:"note"` - NoteUpdatedAt string `json:"noteUpdatedAt"` - FieldConfig string `json:"fieldConfig"` -} - -// NewInvestigationCollectable creates a new InvestigationCollectable object. -func NewInvestigationCollectable() *InvestigationCollectable { - return &InvestigationCollectable{ - Queries: []string{}, - TimeRange: *NewInvestigationTimeRange(), - Datasource: *NewInvestigationDatasourceRef(), - } -} - -// TimeRange represents a time range with both absolute and relative values -// +k8s:openapi-gen=true -type InvestigationTimeRange struct { - From string `json:"from"` - To string `json:"to"` - Raw InvestigationV0alpha1TimeRangeRaw `json:"raw"` -} - -// NewInvestigationTimeRange creates a new InvestigationTimeRange object. -func NewInvestigationTimeRange() *InvestigationTimeRange { - return &InvestigationTimeRange{ - Raw: *NewInvestigationV0alpha1TimeRangeRaw(), - } -} - -// DatasourceRef is a reference to a datasource -// +k8s:openapi-gen=true -type InvestigationDatasourceRef struct { - Uid string `json:"uid"` -} - -// NewInvestigationDatasourceRef creates a new InvestigationDatasourceRef object. -func NewInvestigationDatasourceRef() *InvestigationDatasourceRef { - return &InvestigationDatasourceRef{} -} - -// +k8s:openapi-gen=true -type InvestigationViewMode struct { - Mode InvestigationViewModeMode `json:"mode"` - ShowComments bool `json:"showComments"` - ShowTooltips bool `json:"showTooltips"` -} - -// NewInvestigationViewMode creates a new InvestigationViewMode object. -func NewInvestigationViewMode() *InvestigationViewMode { - return &InvestigationViewMode{} -} - -// +k8s:openapi-gen=true -type InvestigationSpec struct { - Title string `json:"title"` - CreatedByProfile InvestigationPerson `json:"createdByProfile"` - HasCustomName bool `json:"hasCustomName"` - IsFavorite bool `json:"isFavorite"` - OverviewNote string `json:"overviewNote"` - OverviewNoteUpdatedAt string `json:"overviewNoteUpdatedAt"` - // +listType=atomic - Collectables []InvestigationCollectable `json:"collectables"` - ViewMode InvestigationViewMode `json:"viewMode"` -} - -// NewInvestigationSpec creates a new InvestigationSpec object. -func NewInvestigationSpec() *InvestigationSpec { - return &InvestigationSpec{ - CreatedByProfile: *NewInvestigationPerson(), - Collectables: []InvestigationCollectable{}, - ViewMode: *NewInvestigationViewMode(), - } -} - -// +k8s:openapi-gen=true -type InvestigationV0alpha1TimeRangeRaw struct { - From string `json:"from"` - To string `json:"to"` -} - -// NewInvestigationV0alpha1TimeRangeRaw creates a new InvestigationV0alpha1TimeRangeRaw object. -func NewInvestigationV0alpha1TimeRangeRaw() *InvestigationV0alpha1TimeRangeRaw { - return &InvestigationV0alpha1TimeRangeRaw{} -} - -// +k8s:openapi-gen=true -type InvestigationViewModeMode string - -const ( - InvestigationViewModeModeCompact InvestigationViewModeMode = "compact" - InvestigationViewModeModeFull InvestigationViewModeMode = "full" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go deleted file mode 100644 index dafe3e1012e..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go +++ /dev/null @@ -1,80 +0,0 @@ -package v0alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" -) - -type InvestigationIndexClient struct { - client *resource.TypedClient[*InvestigationIndex, *InvestigationIndexList] -} - -func NewInvestigationIndexClient(client resource.Client) *InvestigationIndexClient { - return &InvestigationIndexClient{ - client: resource.NewTypedClient[*InvestigationIndex, *InvestigationIndexList](client, InvestigationIndexKind()), - } -} - -func NewInvestigationIndexClientFromGenerator(generator resource.ClientGenerator) (*InvestigationIndexClient, error) { - c, err := generator.ClientFor(InvestigationIndexKind()) - if err != nil { - return nil, err - } - return NewInvestigationIndexClient(c), nil -} - -func (c *InvestigationIndexClient) Get(ctx context.Context, identifier resource.Identifier) (*InvestigationIndex, error) { - return c.client.Get(ctx, identifier) -} - -func (c *InvestigationIndexClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationIndexList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *InvestigationIndexClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationIndexList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *InvestigationIndexClient) Create(ctx context.Context, obj *InvestigationIndex, opts resource.CreateOptions) (*InvestigationIndex, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = InvestigationIndexKind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *InvestigationIndexClient) Update(ctx context.Context, obj *InvestigationIndex, opts resource.UpdateOptions) (*InvestigationIndex, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *InvestigationIndexClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*InvestigationIndex, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *InvestigationIndexClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go deleted file mode 100644 index 0496ce4af6f..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// InvestigationIndexJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type InvestigationIndexJSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*InvestigationIndexJSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*InvestigationIndexJSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &InvestigationIndexJSONCodec{} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go deleted file mode 100644 index e7bef02a137..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type InvestigationIndexMetadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewInvestigationIndexMetadata creates a new InvestigationIndexMetadata object. -func NewInvestigationIndexMetadata() *InvestigationIndexMetadata { - return &InvestigationIndexMetadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go deleted file mode 100644 index b8272b4fae7..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go +++ /dev/null @@ -1,293 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type InvestigationIndex struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the InvestigationIndex - Spec InvestigationIndexSpec `json:"spec" yaml:"spec"` -} - -func (o *InvestigationIndex) GetSpec() any { - return o.Spec -} - -func (o *InvestigationIndex) SetSpec(spec any) error { - cast, ok := spec.(InvestigationIndexSpec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *InvestigationIndex) GetSubresources() map[string]any { - return map[string]any{} -} - -func (o *InvestigationIndex) GetSubresource(name string) (any, bool) { - switch name { - default: - return nil, false - } -} - -func (o *InvestigationIndex) SetSubresource(name string, value any) error { - switch name { - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *InvestigationIndex) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *InvestigationIndex) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *InvestigationIndex) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *InvestigationIndex) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *InvestigationIndex) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *InvestigationIndex) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *InvestigationIndex) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *InvestigationIndex) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *InvestigationIndex) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *InvestigationIndex) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *InvestigationIndex) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *InvestigationIndex) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationIndex) DeepCopy() *InvestigationIndex { - cpy := &InvestigationIndex{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationIndex) DeepCopyInto(dst *InvestigationIndex) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) -} - -// Interface compliance compile-time check -var _ resource.Object = &InvestigationIndex{} - -// +k8s:openapi-gen=true -type InvestigationIndexList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []InvestigationIndex `json:"items" yaml:"items"` -} - -func (o *InvestigationIndexList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationIndexList) Copy() resource.ListObject { - cpy := &InvestigationIndexList{ - TypeMeta: o.TypeMeta, - Items: make([]InvestigationIndex, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*InvestigationIndex); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *InvestigationIndexList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *InvestigationIndexList) SetItems(items []resource.Object) { - o.Items = make([]InvestigationIndex, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*InvestigationIndex) - } -} - -func (o *InvestigationIndexList) DeepCopy() *InvestigationIndexList { - cpy := &InvestigationIndexList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationIndexList) DeepCopyInto(dst *InvestigationIndexList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &InvestigationIndexList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *InvestigationIndexSpec) DeepCopy() *InvestigationIndexSpec { - cpy := &InvestigationIndexSpec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *InvestigationIndexSpec) DeepCopyInto(dst *InvestigationIndexSpec) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go deleted file mode 100644 index 3a0041dc6b8..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaInvestigationIndex = resource.NewSimpleSchema("investigations.grafana.app", "v0alpha1", &InvestigationIndex{}, &InvestigationIndexList{}, resource.WithKind("InvestigationIndex"), - resource.WithPlural("investigationindexes"), resource.WithScope(resource.NamespacedScope)) - kindInvestigationIndex = resource.Kind{ - Schema: schemaInvestigationIndex, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &InvestigationIndexJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func InvestigationIndexKind() resource.Kind { - return kindInvestigationIndex -} - -// Schema returns a resource.SimpleSchema representation of InvestigationIndex -func InvestigationIndexSchema() *resource.SimpleSchema { - return schemaInvestigationIndex -} - -// Interface compliance checks -var _ resource.Schema = kindInvestigationIndex diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go deleted file mode 100644 index 44be927c850..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// Person represents a user profile with basic information -// +k8s:openapi-gen=true -type InvestigationIndexPerson struct { - // Unique identifier for the user - Uid string `json:"uid"` - // Display name of the user - Name string `json:"name"` - // URL to user's Gravatar image - GravatarUrl string `json:"gravatarUrl"` -} - -// NewInvestigationIndexPerson creates a new InvestigationIndexPerson object. -func NewInvestigationIndexPerson() *InvestigationIndexPerson { - return &InvestigationIndexPerson{} -} - -// Type definition for investigation summaries -// +k8s:openapi-gen=true -type InvestigationIndexInvestigationSummary struct { - Title string `json:"title"` - CreatedByProfile InvestigationIndexPerson `json:"createdByProfile"` - HasCustomName bool `json:"hasCustomName"` - IsFavorite bool `json:"isFavorite"` - OverviewNote string `json:"overviewNote"` - OverviewNoteUpdatedAt string `json:"overviewNoteUpdatedAt"` - ViewMode InvestigationIndexViewMode `json:"viewMode"` - // +listType=atomic - CollectableSummaries []InvestigationIndexCollectableSummary `json:"collectableSummaries"` -} - -// NewInvestigationIndexInvestigationSummary creates a new InvestigationIndexInvestigationSummary object. -func NewInvestigationIndexInvestigationSummary() *InvestigationIndexInvestigationSummary { - return &InvestigationIndexInvestigationSummary{ - CreatedByProfile: *NewInvestigationIndexPerson(), - ViewMode: *NewInvestigationIndexViewMode(), - CollectableSummaries: []InvestigationIndexCollectableSummary{}, - } -} - -// +k8s:openapi-gen=true -type InvestigationIndexViewMode struct { - Mode InvestigationIndexViewModeMode `json:"mode"` - ShowComments bool `json:"showComments"` - ShowTooltips bool `json:"showTooltips"` -} - -// NewInvestigationIndexViewMode creates a new InvestigationIndexViewMode object. -func NewInvestigationIndexViewMode() *InvestigationIndexViewMode { - return &InvestigationIndexViewMode{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexCollectableSummary struct { - Id string `json:"id"` - Title string `json:"title"` - LogoPath string `json:"logoPath"` - Origin string `json:"origin"` -} - -// NewInvestigationIndexCollectableSummary creates a new InvestigationIndexCollectableSummary object. -func NewInvestigationIndexCollectableSummary() *InvestigationIndexCollectableSummary { - return &InvestigationIndexCollectableSummary{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexSpec struct { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - Title string `json:"title"` - // The Person who owns this investigation index - Owner InvestigationIndexPerson `json:"owner"` - // Array of investigation summaries - // +listType=atomic - InvestigationSummaries []InvestigationIndexInvestigationSummary `json:"investigationSummaries"` -} - -// NewInvestigationIndexSpec creates a new InvestigationIndexSpec object. -func NewInvestigationIndexSpec() *InvestigationIndexSpec { - return &InvestigationIndexSpec{ - Owner: *NewInvestigationIndexPerson(), - InvestigationSummaries: []InvestigationIndexInvestigationSummary{}, - } -} - -// +k8s:openapi-gen=true -type InvestigationIndexViewModeMode string - -const ( - InvestigationIndexViewModeModeCompact InvestigationIndexViewModeMode = "compact" - InvestigationIndexViewModeModeFull InvestigationIndexViewModeMode = "full" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go deleted file mode 100644 index 455d39bdb48..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// +k8s:openapi-gen=true -type InvestigationIndexstatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State InvestigationIndexStatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewInvestigationIndexstatusOperatorState creates a new InvestigationIndexstatusOperatorState object. -func NewInvestigationIndexstatusOperatorState() *InvestigationIndexstatusOperatorState { - return &InvestigationIndexstatusOperatorState{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexStatus struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]InvestigationIndexstatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewInvestigationIndexStatus creates a new InvestigationIndexStatus object. -func NewInvestigationIndexStatus() *InvestigationIndexStatus { - return &InvestigationIndexStatus{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexStatusOperatorStateState string - -const ( - InvestigationIndexStatusOperatorStateStateSuccess InvestigationIndexStatusOperatorStateState = "success" - InvestigationIndexStatusOperatorStateStateInProgress InvestigationIndexStatusOperatorStateState = "in_progress" - InvestigationIndexStatusOperatorStateStateFailed InvestigationIndexStatusOperatorStateState = "failed" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go deleted file mode 100644 index 4a095da63fc..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go +++ /dev/null @@ -1,1014 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// Code generated by grafana-app-sdk. DO NOT EDIT. - -package v0alpha1 - -import ( - common "k8s.io/kube-openapi/pkg/common" - spec "k8s.io/kube-openapi/pkg/validation/spec" -) - -func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation": schema_pkg_apis_investigations_v0alpha1_Investigation(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable": schema_pkg_apis_investigations_v0alpha1_InvestigationCollectable(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef": schema_pkg_apis_investigations_v0alpha1_InvestigationDatasourceRef(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex": schema_pkg_apis_investigations_v0alpha1_InvestigationIndex(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexCollectableSummary(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexInvestigationSummary(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexList": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexList(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexPerson(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexSpec(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexStatus(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexViewMode(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexstatusOperatorState(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationList": schema_pkg_apis_investigations_v0alpha1_InvestigationList(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson": schema_pkg_apis_investigations_v0alpha1_InvestigationPerson(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec": schema_pkg_apis_investigations_v0alpha1_InvestigationSpec(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus": schema_pkg_apis_investigations_v0alpha1_InvestigationStatus(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange": schema_pkg_apis_investigations_v0alpha1_InvestigationTimeRange(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw": schema_pkg_apis_investigations_v0alpha1_InvestigationV0alpha1TimeRangeRaw(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode": schema_pkg_apis_investigations_v0alpha1_InvestigationViewMode(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState": schema_pkg_apis_investigations_v0alpha1_InvestigationstatusOperatorState(ref), - } -} - -func schema_pkg_apis_investigations_v0alpha1_Investigation(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.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the Investigation", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus"), - }, - }, - }, - Required: []string{"metadata", "spec", "status"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationCollectable(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Collectable represents an item collected during investigation", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "id": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "origin": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "type": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "queries": { - 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: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "timeRange": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange"), - }, - }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef"), - }, - }, - "url": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "logoPath": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "note": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "noteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldConfig": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"id", "createdAt", "title", "origin", "type", "queries", "timeRange", "datasource", "url", "note", "noteUpdatedAt", "fieldConfig"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationDatasourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "DatasourceRef is a reference to a datasource", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndex(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.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the InvestigationIndex", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus"), - }, - }, - }, - Required: []string{"metadata", "spec", "status"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexCollectableSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "id": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "logoPath": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "origin": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"id", "title", "logoPath", "origin"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexInvestigationSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Type definition for investigation summaries", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdByProfile": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"), - }, - }, - "hasCustomName": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "isFavorite": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "overviewNote": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "overviewNoteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "viewMode": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode"), - }, - }, - "collectableSummaries": { - 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/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary"), - }, - }, - }, - }, - }, - }, - Required: []string{"title", "createdByProfile", "hasCustomName", "isFavorite", "overviewNote", "overviewNoteUpdatedAt", "viewMode", "collectableSummaries"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexList(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": { - 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/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexPerson(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Person represents a user profile with basic information", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Unique identifier for the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Display name of the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gravatarUrl": { - SchemaProps: spec.SchemaProps{ - Description: "URL to user's Gravatar image", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid", "name", "gravatarUrl"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Description: "Title of the index, e.g. 'Favorites' or 'My Investigations'", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "owner": { - SchemaProps: spec.SchemaProps{ - Description: "The Person who owns this investigation index", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"), - }, - }, - "investigationSummaries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Description: "Array of investigation summaries", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary"), - }, - }, - }, - }, - }, - }, - Required: []string{"title", "owner", "investigationSummaries"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "operatorStates": { - SchemaProps: spec.SchemaProps{ - Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexViewMode(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "mode": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "showComments": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "showTooltips": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"mode", "showComments", "showTooltips"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationList(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": { - 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/investigations/pkg/apis/investigations/v0alpha1.Investigation"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationPerson(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Person represents a user profile with basic information", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Unique identifier for the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Display name of the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gravatarUrl": { - SchemaProps: spec.SchemaProps{ - Description: "URL to user's Gravatar image", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid", "name", "gravatarUrl"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "spec is the schema of our resource", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdByProfile": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson"), - }, - }, - "hasCustomName": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "isFavorite": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "overviewNote": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "overviewNoteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "collectables": { - 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/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable"), - }, - }, - }, - }, - }, - "viewMode": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode"), - }, - }, - }, - Required: []string{"title", "createdByProfile", "hasCustomName", "isFavorite", "overviewNote", "overviewNoteUpdatedAt", "collectables", "viewMode"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "operatorStates": { - SchemaProps: spec.SchemaProps{ - Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationTimeRange(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TimeRange represents a time range with both absolute and relative values", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "from": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "to": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "raw": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw"), - }, - }, - }, - Required: []string{"from", "to", "raw"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationV0alpha1TimeRangeRaw(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "from": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "to": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"from", "to"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationViewMode(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "mode": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "showComments": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "showTooltips": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"mode", "showComments", "showTooltips"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, - }, - }, - } -} diff --git a/apps/investigations/pkg/apis/investigations_manifest.go b/apps/investigations/pkg/apis/investigations_manifest.go deleted file mode 100644 index 249e9b49758..00000000000 --- a/apps/investigations/pkg/apis/investigations_manifest.go +++ /dev/null @@ -1,136 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package apis - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - v0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" -) - -var ( - rawSchemaInvestigationv0alpha1 = []byte(`{"Collectable":{"additionalProperties":false,"description":"Collectable represents an item collected during investigation","properties":{"createdAt":{"type":"string"},"datasource":{"$ref":"#/components/schemas/DatasourceRef"},"fieldConfig":{"type":"string"},"id":{"type":"string"},"logoPath":{"type":"string"},"note":{"type":"string"},"noteUpdatedAt":{"type":"string"},"origin":{"type":"string"},"queries":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"timeRange":{"$ref":"#/components/schemas/TimeRange"},"title":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"required":["id","createdAt","title","origin","type","queries","timeRange","datasource","url","note","noteUpdatedAt","fieldConfig"],"type":"object"},"DatasourceRef":{"additionalProperties":false,"description":"DatasourceRef is a reference to a datasource","properties":{"uid":{"type":"string"}},"required":["uid"],"type":"object"},"Investigation":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"Person":{"additionalProperties":false,"description":"Person represents a user profile with basic information","properties":{"gravatarUrl":{"description":"URL to user's Gravatar image","type":"string"},"name":{"description":"Display name of the user","type":"string"},"uid":{"description":"Unique identifier for the user","type":"string"}},"required":["uid","name","gravatarUrl"],"type":"object"},"TimeRange":{"additionalProperties":false,"description":"TimeRange represents a time range with both absolute and relative values","properties":{"from":{"type":"string"},"raw":{"additionalProperties":false,"properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"to":{"type":"string"}},"required":["from","to","raw"],"type":"object"},"ViewMode":{"additionalProperties":false,"properties":{"mode":{"enum":["compact","full"],"type":"string"},"showComments":{"type":"boolean"},"showTooltips":{"type":"boolean"}},"required":["mode","showComments","showTooltips"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"collectables":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Collectable"},"type":"array"},"createdByProfile":{"$ref":"#/components/schemas/Person"},"hasCustomName":{"type":"boolean"},"isFavorite":{"type":"boolean"},"overviewNote":{"type":"string"},"overviewNoteUpdatedAt":{"type":"string"},"title":{"type":"string"},"viewMode":{"$ref":"#/components/schemas/ViewMode"}},"required":["title","createdByProfile","hasCustomName","isFavorite","overviewNote","overviewNoteUpdatedAt","collectables","viewMode"],"type":"object"}}`) - versionSchemaInvestigationv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaInvestigationv0alpha1, &versionSchemaInvestigationv0alpha1) - rawSchemaInvestigationIndexv0alpha1 = []byte(`{"CollectableSummary":{"additionalProperties":false,"properties":{"id":{"type":"string"},"logoPath":{"type":"string"},"origin":{"type":"string"},"title":{"type":"string"}},"required":["id","title","logoPath","origin"],"type":"object"},"InvestigationIndex":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"InvestigationSummary":{"additionalProperties":false,"description":"Type definition for investigation summaries","properties":{"collectableSummaries":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/CollectableSummary"},"type":"array"},"createdByProfile":{"$ref":"#/components/schemas/Person"},"hasCustomName":{"type":"boolean"},"isFavorite":{"type":"boolean"},"overviewNote":{"type":"string"},"overviewNoteUpdatedAt":{"type":"string"},"title":{"type":"string"},"viewMode":{"$ref":"#/components/schemas/ViewMode"}},"required":["title","createdByProfile","hasCustomName","isFavorite","overviewNote","overviewNoteUpdatedAt","viewMode","collectableSummaries"],"type":"object"},"Person":{"additionalProperties":false,"description":"Person represents a user profile with basic information","properties":{"gravatarUrl":{"description":"URL to user's Gravatar image","type":"string"},"name":{"description":"Display name of the user","type":"string"},"uid":{"description":"Unique identifier for the user","type":"string"}},"required":["uid","name","gravatarUrl"],"type":"object"},"ViewMode":{"additionalProperties":false,"properties":{"mode":{"enum":["compact","full"],"type":"string"},"showComments":{"type":"boolean"},"showTooltips":{"type":"boolean"}},"required":["mode","showComments","showTooltips"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"investigationSummaries":{"description":"Array of investigation summaries\n+listType=atomic","items":{"$ref":"#/components/schemas/InvestigationSummary"},"type":"array"},"owner":{"$ref":"#/components/schemas/Person","description":"The Person who owns this investigation index"},"title":{"description":"Title of the index, e.g. 'Favorites' or 'My Investigations'","type":"string"}},"required":["title","owner","investigationSummaries"],"type":"object"}}`) - versionSchemaInvestigationIndexv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaInvestigationIndexv0alpha1, &versionSchemaInvestigationIndexv0alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "investigations", - Group: "investigations.grafana.app", - PreferredVersion: "v0alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v0alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "Investigation", - Plural: "Investigations", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaInvestigationv0alpha1, - }, - - { - Kind: "InvestigationIndex", - Plural: "InvestigationIndexes", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaInvestigationIndexv0alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("investigations") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "Investigation/v0alpha1": v0alpha1.InvestigationKind(), - "InvestigationIndex/v0alpha1": v0alpha1.InvestigationIndexKind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/investigations/pkg/app/investigations_app.go b/apps/investigations/pkg/app/investigations_app.go deleted file mode 100644 index 516d278788d..00000000000 --- a/apps/investigations/pkg/app/investigations_app.go +++ /dev/null @@ -1,62 +0,0 @@ -package app - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/operator" - "github.com/grafana/grafana-app-sdk/resource" - "github.com/grafana/grafana-app-sdk/simple" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/klog/v2" - - investigationsv0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" -) - -func New(cfg app.Config) (app.App, error) { - var err error - simpleConfig := simple.AppConfig{ - Name: "investigation", - KubeConfig: cfg.KubeConfig, - InformerConfig: simple.AppInformerConfig{ - InformerOptions: operator.InformerOptions{ - ErrorHandler: func(_ context.Context, err error) { - klog.ErrorS(err, "Informer processing error") - }, - }, - }, - ManagedKinds: []simple.AppManagedKind{ - { - Kind: investigationsv0alpha1.InvestigationKind(), - }, - { - Kind: investigationsv0alpha1.InvestigationIndexKind(), - }, - }, - } - - a, err := simple.NewApp(simpleConfig) - if err != nil { - return nil, err - } - - err = a.ValidateManifest(cfg.ManifestData) - if err != nil { - return nil, err - } - - return a, nil -} - -func GetKinds() map[schema.GroupVersion][]resource.Kind { - gv := schema.GroupVersion{ - Group: investigationsv0alpha1.InvestigationKind().Group(), - Version: investigationsv0alpha1.InvestigationKind().Version(), - } - return map[schema.GroupVersion][]resource.Kind{ - gv: { - investigationsv0alpha1.InvestigationKind(), - investigationsv0alpha1.InvestigationIndexKind(), - }, - } -} diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts b/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts deleted file mode 100644 index c46622cd502..00000000000 --- a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// Person represents a user profile with basic information -export interface Person { - // Unique identifier for the user - uid: string; - // Display name of the user - name: string; - // URL to user's Gravatar image - gravatarUrl: string; -} - -export const defaultPerson = (): Person => ({ - uid: "", - name: "", - gravatarUrl: "", -}); - -// Collectable represents an item collected during investigation -export interface Collectable { - id: string; - createdAt: string; - title: string; - origin: string; - type: string; - // +listType=atomic - queries: string[]; - timeRange: TimeRange; - datasource: DatasourceRef; - url: string; - logoPath?: string; - note: string; - noteUpdatedAt: string; - fieldConfig: string; -} - -export const defaultCollectable = (): Collectable => ({ - id: "", - createdAt: "", - title: "", - origin: "", - type: "", - queries: [], - timeRange: defaultTimeRange(), - datasource: defaultDatasourceRef(), - url: "", - note: "", - noteUpdatedAt: "", - fieldConfig: "", -}); - -// TimeRange represents a time range with both absolute and relative values -export interface TimeRange { - from: string; - to: string; - raw: { - from: string; - to: string; - }; -} - -export const defaultTimeRange = (): TimeRange => ({ - from: "", - to: "", - raw: { - from: "", - to: "", -}, -}); - -// DatasourceRef is a reference to a datasource -export interface DatasourceRef { - uid: string; -} - -export const defaultDatasourceRef = (): DatasourceRef => ({ - uid: "", -}); - -export interface ViewMode { - mode: "compact" | "full"; - showComments: boolean; - showTooltips: boolean; -} - -export const defaultViewMode = (): ViewMode => ({ - mode: "compact", - showComments: false, - showTooltips: false, -}); - -// spec is the schema of our resource -export interface Spec { - title: string; - createdByProfile: Person; - hasCustomName: boolean; - isFavorite: boolean; - overviewNote: string; - overviewNoteUpdatedAt: string; - // +listType=atomic - collectables: Collectable[]; - viewMode: ViewMode; -} - -export const defaultSpec = (): Spec => ({ - title: "", - createdByProfile: defaultPerson(), - hasCustomName: false, - isFavorite: false, - overviewNote: "", - overviewNoteUpdatedAt: "", - collectables: [], - viewMode: defaultViewMode(), -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts deleted file mode 100644 index 7d88430d451..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file was generated by grafana-app-sdk. DO NOT EDIT. - */ -import { Spec } from './types.spec.gen'; -import { Status } from './types.status.gen'; - -export interface Metadata { - name: string; - namespace: string; - generateName?: string; - selfLink?: string; - uid?: string; - resourceVersion?: string; - generation?: number; - creationTimestamp?: string; - deletionTimestamp?: string; - deletionGracePeriodSeconds?: number; - labels?: Record; - annotations?: Record; - ownerReferences?: OwnerReference[]; - finalizers?: string[]; - managedFields?: ManagedFieldsEntry[]; -} - -export interface OwnerReference { - apiVersion: string; - kind: string; - name: string; - uid: string; - controller?: boolean; - blockOwnerDeletion?: boolean; -} - -export interface ManagedFieldsEntry { - manager?: string; - operation?: string; - apiVersion?: string; - time?: string; - fieldsType?: string; - subresource?: string; -} - -export interface InvestigationIndex { - kind: string; - apiVersion: string; - metadata: Metadata; - spec: Spec; - status: Status; -} diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts deleted file mode 100644 index 4377f3c1d08..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -export interface Metadata { - updateTimestamp: string; - createdBy: string; - uid: string; - creationTimestamp: string; - deletionTimestamp?: string; - finalizers: string[]; - resourceVersion: string; - generation: number; - updatedBy: string; - labels: Record; -} - -export const defaultMetadata = (): Metadata => ({ - updateTimestamp: "", - createdBy: "", - uid: "", - creationTimestamp: "", - finalizers: [], - resourceVersion: "", - generation: 0, - updatedBy: "", - labels: {}, -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts deleted file mode 100644 index f0d2f25d96b..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// Person represents a user profile with basic information -export interface Person { - // Unique identifier for the user - uid: string; - // Display name of the user - name: string; - // URL to user's Gravatar image - gravatarUrl: string; -} - -export const defaultPerson = (): Person => ({ - uid: "", - name: "", - gravatarUrl: "", -}); - -// Type definition for investigation summaries -export interface InvestigationSummary { - title: string; - createdByProfile: Person; - hasCustomName: boolean; - isFavorite: boolean; - overviewNote: string; - overviewNoteUpdatedAt: string; - viewMode: ViewMode; - // +listType=atomic - collectableSummaries: CollectableSummary[]; -} - -export const defaultInvestigationSummary = (): InvestigationSummary => ({ - title: "", - createdByProfile: defaultPerson(), - hasCustomName: false, - isFavorite: false, - overviewNote: "", - overviewNoteUpdatedAt: "", - viewMode: defaultViewMode(), - collectableSummaries: [], -}); - -export interface ViewMode { - mode: "compact" | "full"; - showComments: boolean; - showTooltips: boolean; -} - -export const defaultViewMode = (): ViewMode => ({ - mode: "compact", - showComments: false, - showTooltips: false, -}); - -export interface CollectableSummary { - id: string; - title: string; - logoPath: string; - origin: string; -} - -export const defaultCollectableSummary = (): CollectableSummary => ({ - id: "", - title: "", - logoPath: "", - origin: "", -}); - -export interface Spec { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - title: string; - // The Person who owns this investigation index - owner: Person; - // Array of investigation summaries - // +listType=atomic - investigationSummaries: InvestigationSummary[]; -} - -export const defaultSpec = (): Spec => ({ - title: "", - owner: defaultPerson(), - investigationSummaries: [], -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts deleted file mode 100644 index 01be8df7961..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -export interface OperatorState { - // lastEvaluation is the ResourceVersion last evaluated - lastEvaluation: string; - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - state: "success" | "in_progress" | "failed"; - // descriptiveState is an optional more descriptive state field which has no requirements on format - descriptiveState?: string; - // details contains any extra information that is operator-specific - details?: Record; -} - -export const defaultOperatorState = (): OperatorState => ({ - lastEvaluation: "", - state: "success", -}); - -export interface Status { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - operatorStates?: Record; - // additionalFields is reserved for future use - additionalFields?: Record; -} - -export const defaultStatus = (): Status => ({ -}); - diff --git a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json index da34c0c70f2..3ef92d3d5b8 100644 --- a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json +++ b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json @@ -191,7 +191,13 @@ } }, "conversion": false - }, + } + ] + }, + { + "name": "v1beta1", + "served": true, + "kinds": [ { "kind": "LogsDrilldownDefaultColumns", "plural": "LogsDrilldownDefaultColumns", @@ -314,6 +320,6 @@ ] } ], - "preferredVersion": "v1alpha1" + "preferredVersion": "v1beta1" } } diff --git a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json index 28aa314311d..c8987741983 100644 --- a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json +++ b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json @@ -8,7 +8,7 @@ "group": "logsdrilldown.grafana.app", "versions": [ { - "name": "v1alpha1", + "name": "v1beta1", "served": true, "storage": true, "schema": { diff --git a/apps/logsdrilldown/kinds/logsdrilldown.cue b/apps/logsdrilldown/kinds/logsdrilldown.cue index d2752103820..4b2d815743d 100644 --- a/apps/logsdrilldown/kinds/logsdrilldown.cue +++ b/apps/logsdrilldown/kinds/logsdrilldown.cue @@ -1,7 +1,7 @@ package kinds import ( - "github.com/grafana/grafana/apps/logsdrilldown/kinds/v0alpha1" + "github.com/grafana/grafana/apps/logsdrilldown/kinds/v1beta1", ) LogsDrilldownSpecv1alpha1: { @@ -26,11 +26,11 @@ logsdrilldownDefaultsv1alpha1: { } } -// Default columns API -logsdrilldownDefaultColumnsv0alpha1: { +// Default columns API (beta) +logsdrilldownDefaultColumnsv1beta1: { kind: "LogsDrilldownDefaultColumns" pluralName: "LogsDrilldownDefaultColumns" schema: { - spec: v0alpha1.LogsDefaultColumns + spec: v1beta1.LogsDefaultColumns } } diff --git a/apps/logsdrilldown/kinds/manifest.cue b/apps/logsdrilldown/kinds/manifest.cue index ab717de6a92..cbd47ce2b65 100644 --- a/apps/logsdrilldown/kinds/manifest.cue +++ b/apps/logsdrilldown/kinds/manifest.cue @@ -15,6 +15,7 @@ manifest: { // If your app needs access to kinds managed by another app, use permissions.accessKinds to allow your app access. versions: { "v1alpha1": v1alpha1 + "v1beta1" : v1beta1 } // extraPermissions contains any additional permissions your app may require to function. // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). @@ -35,7 +36,40 @@ manifest: { // It includes kinds which the v1alpha1 API serves, and (future) custom routes served globally from the v1alpha1 version. v1alpha1: { // kinds is the list of kinds served by this version - kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1, logsdrilldownDefaultColumnsv0alpha1] + kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1] + // [OPTIONAL] + // served indicates whether this particular version is served by the API server. + // served should be set to false before a version is removed from the manifest entirely. + // served defaults to true if not present. + served: true + // [OPTIONAL] + // Codegen is a trait that tells the grafana-app-sdk, or other code generation tooling, how to process this kind. + // If not present, default values within the codegen trait are used. + // If you wish to specify codegen per-version, put this section in the version's object + // (for example, v1alpha1) instead. + codegen: { + // [OPTIONAL] + // ts contains TypeScript code generation properties for the kind + ts: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate front-end TypeScript code for the kind. + // Defaults to true if not present. + enabled: true + } + // [OPTIONAL] + // go contains go code generation properties for the kind + go: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate back-end go code for the kind. + // Defaults to true if not present. + enabled: true + } + } +} + +v1beta1: { + // kinds is the list of kinds served by this version + kinds: [logsdrilldownDefaultColumnsv1beta1] // [OPTIONAL] // served indicates whether this particular version is served by the API server. // served should be set to false before a version is removed from the manifest entirely. diff --git a/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue new file mode 100644 index 00000000000..a66e813766b --- /dev/null +++ b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue @@ -0,0 +1,19 @@ +package v1beta1 + +#LogsDefaultColumnsLabel: { + key: string + value: string +} + +#LogsDefaultColumnsLabels: [...#LogsDefaultColumnsLabel] + +#LogsDefaultColumnsRecord: { + columns: [...string] + labels: #LogsDefaultColumnsLabels +} + +#LogsDefaultColumnsRecords: [...#LogsDefaultColumnsRecord] + +LogsDefaultColumns: { + records: #LogsDefaultColumnsRecords +} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go similarity index 91% rename from apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go index 082bec7c874..ecfc11a456f 100644 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import "k8s.io/apimachinery/pkg/runtime/schema" @@ -6,7 +6,7 @@ const ( // APIGroup is the API group used by all kinds in this package APIGroup = "logsdrilldown.grafana.app" // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" + APIVersion = "v1beta1" ) var ( diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go index b5d573bc1dc..856c3af291b 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import ( "context" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go index 311d2f02683..12622814e72 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go index a4bb052fe25..ee2d44fde2e 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 import ( time "time" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go index 4340a27714e..6822fbfb4d7 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "fmt" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go similarity index 85% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go index cc5363e16bb..49d234f47a5 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "github.com/grafana/grafana-app-sdk/resource" @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), + schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1beta1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) kindLogsDrilldownDefaultColumns = resource.Kind{ Schema: schemaLogsDrilldownDefaultColumns, diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go index ce12ebb0761..6163f66c52c 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords []LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go index c2183832095..e109592eb09 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsstatusOperatorState struct { diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go index 2350b924dda..a242d11b2bf 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go @@ -17,24 +17,25 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" v1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + v1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) + rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) + rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) + rawSchemaLogsDrilldownDefaultColumnsv1beta1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultColumnsv1beta1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1beta1, &versionSchemaLogsDrilldownDefaultColumnsv1beta1) ) var appManifestData = app.ManifestData{ AppName: "logsdrilldown", Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", + PreferredVersion: "v1beta1", Versions: []app.ManifestVersion{ { Name: "v1alpha1", @@ -55,13 +56,24 @@ var appManifestData = app.ManifestData{ Conversion: false, Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{}, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + { + Name: "v1beta1", + Served: true, + Kinds: []app.ManifestVersionKind{ { Kind: "LogsDrilldownDefaultColumns", Plural: "LogsDrilldownDefaultColumns", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, + Schema: &versionSchemaLogsDrilldownDefaultColumnsv1beta1, }, }, Routes: app.ManifestVersionRoutes{ @@ -82,9 +94,9 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), - "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), - "LogsDrilldownDefaultColumns/v1alpha1": v1alpha1.LogsDrilldownDefaultColumnsKind(), + "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), + "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), + "LogsDrilldownDefaultColumns/v1beta1": v1beta1.LogsDrilldownDefaultColumnsKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/apps/logsdrilldown/pkg/app/app.go b/apps/logsdrilldown/pkg/app/app.go index 23260270207..1e3e37851e2 100644 --- a/apps/logsdrilldown/pkg/app/app.go +++ b/apps/logsdrilldown/pkg/app/app.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + logsdrilldownv1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) func New(cfg app.Config) (app.App, error) { @@ -32,7 +33,7 @@ func New(cfg app.Config) (app.App, error) { Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultsKind(), }, { - Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultColumnsKind(), + Kind: logsdrilldownv1beta1.LogsDrilldownDefaultColumnsKind(), }, }, } diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go deleted file mode 100644 index c133b65f45b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownClient struct { - client *resource.TypedClient[*LogsDrilldown, *LogsDrilldownList] -} - -func NewLogsDrilldownClient(client resource.Client) *LogsDrilldownClient { - return &LogsDrilldownClient{ - client: resource.NewTypedClient[*LogsDrilldown, *LogsDrilldownList](client, Kind()), - } -} - -func NewLogsDrilldownClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownClient(c), nil -} - -func (c *LogsDrilldownClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldown, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownClient) Create(ctx context.Context, obj *LogsDrilldown, opts resource.CreateOptions) (*LogsDrilldown, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Update(ctx context.Context, obj *LogsDrilldown, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldown, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, &LogsDrilldown{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go deleted file mode 100644 index 5d40a873e6b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldown struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldown - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldown) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldown) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldown) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldown) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldown) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldown) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldown) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldown) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldown) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldown) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldown) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldown) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldown) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldown) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldown) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldown) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldown) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldown) DeepCopy() *LogsDrilldown { - cpy := &LogsDrilldown{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldown) DeepCopyInto(dst *LogsDrilldown) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldown{} - -// +k8s:openapi-gen=true -type LogsDrilldownList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldown `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownList) Copy() resource.ListObject { - cpy := &LogsDrilldownList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldown, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldown); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldown, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldown) - } -} - -func (o *LogsDrilldownList) DeepCopy() *LogsDrilldownList { - cpy := &LogsDrilldownList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownList) DeepCopyInto(dst *LogsDrilldownList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go deleted file mode 100644 index 942794416e8..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldown = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldown{}, &LogsDrilldownList{}, resource.WithKind("LogsDrilldown"), - resource.WithPlural("logsdrilldowns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldown = resource.Kind{ - Schema: schemaLogsDrilldown, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldown -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldown -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldown -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldown diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go deleted file mode 100644 index b66471eb4ba..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultColumnsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList] -} - -func NewLogsDrilldownDefaultColumnsClient(client resource.Client) *LogsDrilldownDefaultColumnsClient { - return &LogsDrilldownDefaultColumnsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultColumnsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultColumnsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultColumnsClient(c), nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaultColumns, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultColumnsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Create(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.CreateOptions) (*LogsDrilldownDefaultColumns, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Update(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, &LogsDrilldownDefaultColumns{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultColumnsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go deleted file mode 100644 index 3173c28330e..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumns struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaultColumns - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaultColumns) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaultColumns) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaultColumns) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaultColumns) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaultColumns) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaultColumns) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaultColumns) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaultColumns) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaultColumns) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaultColumns) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaultColumns) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaultColumns) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaultColumns) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaultColumns) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumns) DeepCopy() *LogsDrilldownDefaultColumns { - cpy := &LogsDrilldownDefaultColumns{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyInto(dst *LogsDrilldownDefaultColumns) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaultColumns{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumnsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaultColumns `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumnsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultColumnsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaultColumns, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaultColumns); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultColumnsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaultColumns, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaultColumns) - } -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopy() *LogsDrilldownDefaultColumnsList { - cpy := &LogsDrilldownDefaultColumnsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyInto(dst *LogsDrilldownDefaultColumnsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultColumnsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go deleted file mode 100644 index b50be391fc7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaultColumns{}, &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), - resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaultColumns = resource.Kind{ - Schema: schemaLogsDrilldownDefaultColumns, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaultColumns -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaultColumns -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaultColumns -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaultColumns diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go deleted file mode 100644 index d9cd977aeb9..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecords []LogsDefaultColumnsRecord - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecord struct { - Columns []string `json:"columns"` - Labels LogsDefaultColumnsLabels `json:"labels"` -} - -// NewLogsDefaultColumnsRecord creates a new LogsDefaultColumnsRecord object. -func NewLogsDefaultColumnsRecord() *LogsDefaultColumnsRecord { - return &LogsDefaultColumnsRecord{ - Columns: []string{}, - } -} - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabels []LogsDefaultColumnsLabel - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabel struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// NewLogsDefaultColumnsLabel creates a new LogsDefaultColumnsLabel object. -func NewLogsDefaultColumnsLabel() *LogsDefaultColumnsLabel { - return &LogsDefaultColumnsLabel{} -} - -// +k8s:openapi-gen=true -type Spec struct { - Records LogsDefaultColumnsRecords `json:"records"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{} -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go deleted file mode 100644 index cc06a10b1e7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList] -} - -func NewLogsDrilldownDefaultsClient(client resource.Client) *LogsDrilldownDefaultsClient { - return &LogsDrilldownDefaultsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultsClient(c), nil -} - -func (c *LogsDrilldownDefaultsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaults, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultsClient) Create(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.CreateOptions) (*LogsDrilldownDefaults, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Update(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaults, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, &LogsDrilldownDefaults{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go deleted file mode 100644 index d9354522dd7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaults struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaults - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaults) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaults) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaults) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaults) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaults) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaults) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaults) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaults) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaults) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaults) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaults) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaults) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaults) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaults) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaults) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaults) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaults) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaults) DeepCopy() *LogsDrilldownDefaults { - cpy := &LogsDrilldownDefaults{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaults) DeepCopyInto(dst *LogsDrilldownDefaults) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaults{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaults `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaults, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaults); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaults, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaults) - } -} - -func (o *LogsDrilldownDefaultsList) DeepCopy() *LogsDrilldownDefaultsList { - cpy := &LogsDrilldownDefaultsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultsList) DeepCopyInto(dst *LogsDrilldownDefaultsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go deleted file mode 100644 index bda3e49377d..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaults = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaults{}, &LogsDrilldownDefaultsList{}, resource.WithKind("LogsDrilldownDefaults"), - resource.WithPlural("logsdrilldowndefaults"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaults = resource.Kind{ - Schema: schemaLogsDrilldownDefaults, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaults -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaults -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaults -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaults diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go deleted file mode 100644 index 9deb5d5d3a1..00000000000 --- a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go +++ /dev/null @@ -1,150 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package manifestdata - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1" - logsdrilldowndefaultcolumnsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1" - logsdrilldowndefaultsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1" -) - -var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "logsdrilldown", - Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v1alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "LogsDrilldown", - Plural: "LogsDrilldowns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaults", - Plural: "LogsDrilldownDefaults", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaultColumns", - Plural: "LogsDrilldownDefaultColumns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("logsdrilldown") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": logsdrilldownv1alpha1.Kind(), - "LogsDrilldownDefaults/v1alpha1": logsdrilldowndefaultsv1alpha1.Kind(), - "LogsDrilldownDefaultColumns/v1alpha1": logsdrilldowndefaultcolumnsv1alpha1.Kind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts similarity index 95% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts index 3c7cf0874f8..f7ba7b0f223 100644 --- a/apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts @@ -40,7 +40,7 @@ export interface ManagedFieldsEntry { subresource?: string; } -export interface Investigation { +export interface LogsDrilldownDefaultColumns { kind: string; apiVersion: string; metadata: Metadata; diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.metadata.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/types.metadata.gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts new file mode 100644 index 00000000000..fde99894776 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts @@ -0,0 +1,38 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export type LogsDefaultColumnsRecords = LogsDefaultColumnsRecord[]; + +export const defaultLogsDefaultColumnsRecords = (): LogsDefaultColumnsRecords => ([]); + +export interface LogsDefaultColumnsRecord { + columns: string[]; + labels: LogsDefaultColumnsLabels; +} + +export const defaultLogsDefaultColumnsRecord = (): LogsDefaultColumnsRecord => ({ + columns: [], + labels: defaultLogsDefaultColumnsLabels(), +}); + +export type LogsDefaultColumnsLabels = LogsDefaultColumnsLabel[]; + +export const defaultLogsDefaultColumnsLabels = (): LogsDefaultColumnsLabels => ([]); + +export interface LogsDefaultColumnsLabel { + key: string; + value: string; +} + +export const defaultLogsDefaultColumnsLabel = (): LogsDefaultColumnsLabel => ({ + key: "", + value: "", +}); + +export interface Spec { + records: LogsDefaultColumnsRecords; +} + +export const defaultSpec = (): Spec => ({ + records: defaultLogsDefaultColumnsRecords(), +}); + diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.status.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/types.status.gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 69006b41792..e4866731967 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -8,12 +8,17 @@ replace github.com/grafana/grafana/pkg/apimachinery => ../../pkg/apimachinery replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver +replace github.com/grafana/grafana/pkg/plugins => ../../pkg/plugins + +replace github.com/grafana/grafana/pkg/semconv => ../../pkg/semconv + require ( github.com/emicklei/go-restful/v3 v3.13.0 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 github.com/grafana/grafana/pkg/apimachinery v0.0.0 + github.com/grafana/grafana/pkg/plugins v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.3 k8s.io/apiserver v0.34.3 @@ -26,7 +31,7 @@ require ( cel.dev/expr v0.25.1 // indirect github.com/Machiel/slugify v1.0.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -101,7 +106,7 @@ require ( github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect - github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/sqlds/v5 v5.0.3 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index d1429e1a498..29738a48cca 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -11,8 +11,8 @@ github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -235,8 +235,6 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go index 228523f598e..f9f8dcc8382 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go @@ -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"` +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go index f06798c0ddd..c1785a048d1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go @@ -197,6 +197,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { &HistoricJobList{}, &Connection{}, &ConnectionList{}, + &ExternalRepositoryList{}, ) return nil } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 48d03d23f99..97b1e752f68 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -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 diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index a96fa2d8a6b..11788142e94 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -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{ diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index b9504855b80..3a54dcf2a5e 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -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 diff --git a/apps/provisioning/pkg/controller/connection_status.go b/apps/provisioning/pkg/controller/connection_status.go new file mode 100644 index 00000000000..0d8a0002f41 --- /dev/null +++ b/apps/provisioning/pkg/controller/connection_status.go @@ -0,0 +1,40 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// ConnectionStatusPatcher provides methods to patch Connection status subresources. +type ConnectionStatusPatcher struct { + client client.ProvisioningV0alpha1Interface +} + +// NewConnectionStatusPatcher creates a new ConnectionStatusPatcher. +func NewConnectionStatusPatcher(client client.ProvisioningV0alpha1Interface) *ConnectionStatusPatcher { + return &ConnectionStatusPatcher{ + client: client, + } +} + +// Patch applies JSON patch operations to a Connection's status subresource. +func (p *ConnectionStatusPatcher) Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error { + patch, err := json.Marshal(patchOperations) + if err != nil { + return fmt.Errorf("unable to marshal patch data: %w", err) + } + + _, err = p.client.Connections(conn.Namespace). + Patch(ctx, conn.Name, types.JSONPatchType, patch, metav1.PatchOptions{}, "status") + if err != nil { + return fmt.Errorf("unable to update connection status: %w", err) + } + + return nil +} diff --git a/conf/defaults.ini b/conf/defaults.ini index 363ca39d0c4..8e0a113a0ec 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2234,6 +2234,8 @@ encryption_provider = secret_key.v1 # These flags are required in on-prem installations for GitSync to work # +# Whether to register the MT CRUD API +register_api_server = true # Whether to create the MT secrets management database run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. @@ -2279,3 +2281,10 @@ allow_image_rendering = true # will check if there has been any changes to the repository not propagated by a webhook. # The minimum value is 10 seconds. min_sync_interval = 10s + +#################################### Unified Storage #################################### +[unified_storage] +# index_path is the path where unified storage can store its index files for search. +# If empty, defaults to "/unified-search/bleve" (see [paths] section). +# Please note that sharing the same index_path between multiple running Grafana instances is not supported. +index_path = diff --git a/conf/sample.ini b/conf/sample.ini index 530b14c87ac..5a579d0e74e 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -2123,6 +2123,8 @@ default_datasource_uid = # These flags are required in on-prem installations for GitSync to work # +# Whether to register the MT CRUD API +;register_api_server = true # Whether to create the MT secrets management database ;run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. diff --git a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json index 4333993ea8e..ac11fd803b9 100644 --- a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json +++ b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json @@ -248,7 +248,7 @@ "legend": { "values": ["percent"], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "pieType": "pie", "reduceOptions": { @@ -256,7 +256,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -272,6 +272,15 @@ "timeFrom": null, "timeShift": null, "title": "Percent", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^Backend-(.*)$", + "renamePattern": "b-$1" + } + } + ], "type": "piechart" }, { @@ -311,7 +320,7 @@ "legend": { "values": ["value"], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "pieType": "pie", "reduceOptions": { @@ -319,7 +328,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -335,6 +344,15 @@ "timeFrom": null, "timeShift": null, "title": "Value", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "(.*)", + "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" + } + } + ], "type": "piechart" }, { diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index ef800415547..66ba7b6988c 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -48,6 +48,23 @@ scopes: operator: equals value: kids + # This scope appears in multiple places in the tree. + # The defaultPath determines which path is shown when this scope is selected + # (e.g., from a URL or programmatically), even if another path also links to it. + shared-service: + title: Shared Service + # Path from the root node down to the direct scopeNode. + # Node names are hierarchical (parent-child), so use the full names. + # This points to: gdev-scopes > production > shared-service-prod + defaultPath: + - gdev-scopes + - gdev-scopes-production + - gdev-scopes-production-shared-service-prod + filters: + - key: service + operator: equals + value: shared + tree: gdev-scopes: title: gdev-scopes @@ -68,6 +85,13 @@ tree: nodeType: leaf linkId: app2 linkType: scope + # This node links to 'shared-service' scope. + # The scope's defaultPath points here (production > gdev-scopes). + shared-service-prod: + title: Shared Service + nodeType: leaf + linkId: shared-service + linkType: scope test-cases: title: Test cases nodeType: container @@ -83,6 +107,15 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope + # This node also links to the same 'shared-service' scope. + # However, the scope's defaultPath points to the production path, + # so selecting this scope will expand the tree to production > shared-service-prod. + shared-service-test: + title: Shared Service (also in Production) + subTitle: defaultPath points to Production + nodeType: leaf + linkId: shared-service + linkType: scope test-case-redirect: title: Test case with redirect nodeType: leaf diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index e3f4de80d21..4487c5b69ef 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -51,8 +51,9 @@ type Config struct { // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec type ScopeConfig struct { - Title string `yaml:"title"` - Filters []ScopeFilterConfig `yaml:"filters"` + Title string `yaml:"title"` + DefaultPath []string `yaml:"defaultPath,omitempty"` + Filters []ScopeFilterConfig `yaml:"filters"` } // ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter @@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec { for i, f := range cfg.Filters { filters[i] = convertFilter(f) } + + // Prefix defaultPath elements with the gdev prefix + var defaultPath []string + if len(cfg.DefaultPath) > 0 { + defaultPath = make([]string, len(cfg.DefaultPath)) + for i, p := range cfg.DefaultPath { + defaultPath[i] = prefix + "-" + p + } + } + return v0alpha1.ScopeSpec{ - Title: cfg.Title, - Filters: filters, + Title: cfg.Title, + DefaultPath: defaultPath, + Filters: filters, } } diff --git a/docs/sources/alerting/monitor-status/view-alert-rules.md b/docs/sources/alerting/monitor-status/view-alert-rules.md index b60ede12328..e5410d48765 100644 --- a/docs/sources/alerting/monitor-status/view-alert-rules.md +++ b/docs/sources/alerting/monitor-status/view-alert-rules.md @@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group. The list view includes a number of filters to simplify managing large volumes of alerts. +## Filter and save searches + Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search. -{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}} +Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied. + +{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}} ## Change alert rules list view diff --git a/docs/sources/as-code/observability-as-code/schema-v2/_index.md b/docs/sources/as-code/observability-as-code/schema-v2/_index.md index 9be869a8391..65c73a49cbe 100644 --- a/docs/sources/as-code/observability-as-code/schema-v2/_index.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/_index.md @@ -186,7 +186,7 @@ For the JSON and field usage notes, refer to the [links schema documentation](ht ### `tags` -The tags associated with the dashboard: +Tags associated with the dashboard. Each tag can be up to 50 characters long. ` [...string]` diff --git a/docs/sources/datasources/graphite/_index.md b/docs/sources/datasources/graphite/_index.md index fba9d616f38..78972af5781 100644 --- a/docs/sources/datasources/graphite/_index.md +++ b/docs/sources/datasources/graphite/_index.md @@ -111,3 +111,4 @@ After installing and configuring the Graphite data source you can: - Add [transformations](ref:transformations) - Add [annotations](ref:annotate-visualizations) - Set up [alerting](ref:alerting) +- [Troubleshoot](troubleshooting/) common issues with the Graphite data source diff --git a/docs/sources/datasources/graphite/troubleshooting/index.md b/docs/sources/datasources/graphite/troubleshooting/index.md new file mode 100644 index 00000000000..41702c6b975 --- /dev/null +++ b/docs/sources/datasources/graphite/troubleshooting/index.md @@ -0,0 +1,174 @@ +--- +description: Troubleshoot common issues with the Graphite data source. +keywords: + - grafana + - graphite + - troubleshooting + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Graphite data source issues +weight: 400 +refs: + configure-graphite: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/graphite/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/graphite/configure/ + query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/graphite/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/graphite/query-editor/ +--- + +# Troubleshoot Graphite data source issues + +This document provides solutions for common issues you might encounter when using the Graphite data source. + +## Connection issues + +Use the following troubleshooting steps to resolve connection problems between Grafana and your Graphite server. + +**Data source test fails with "Unable to connect":** + +If the data source test fails, verify the following: + +- The URL in your data source configuration is correct and accessible from the Grafana server. +- The Graphite server is running and accepting connections. +- Any firewall rules or network policies allow traffic between Grafana and the Graphite server. +- If using TLS, ensure your certificates are valid and properly configured. + +To test connectivity, run the following command from the Grafana server: + +```sh +curl -v /render +``` + +Replace _``_ with your Graphite server URL. A successful connection returns a response from the Graphite server. + +**Authentication errors:** + +If you receive 401 or 403 errors: + +- Verify your Basic Auth username and password are correct. +- Ensure the **With Credentials** toggle is enabled if your Graphite server requires cookies for authentication. +- Check that your TLS client certificates are valid and match what the server expects. + +For detailed authentication configuration, refer to [Configure the Graphite data source](ref:configure-graphite). + +## Query issues + +Use the following troubleshooting steps to resolve problems with Graphite queries. + +**No data returned:** + +If your query returns no data: + +- Verify the metric path exists in your Graphite server by testing directly in the Graphite web interface. +- Check that the time range in Grafana matches when data was collected. +- Ensure wildcards in your query match existing metrics. +- Confirm your query syntax is correct for your Graphite version. + +**HTTP 500 errors with HTML content:** + +Graphite-web versions before 1.6 return HTTP 500 errors with full HTML stack traces when a query fails. If you see error messages containing HTML tags: + +- Check the Graphite server logs for the full error details. +- Verify your query syntax is valid. +- Ensure the requested time range doesn't exceed your Graphite server's capabilities. +- Check that all functions used in your query are supported by your Graphite version. + +**Parser errors in the query editor:** + +If the query editor displays parser errors: + +- Check for unbalanced parentheses in function calls. +- Verify that function arguments are in the correct format. +- Ensure metric paths don't contain unsupported characters. + +For query syntax help, refer to [Graphite query editor](ref:query-editor). + +## Version and feature issues + +Use the following troubleshooting steps to resolve problems related to Graphite versions and features. + +**Functions missing from the query editor:** + +If expected functions don't appear in the query editor: + +- Verify the correct Graphite version is selected in the data source configuration. +- The available functions depend on the configured version. For example, tag-based functions require Graphite 1.1 or later. +- If using a custom Graphite installation with additional functions, ensure the version setting matches your server. + +**Tag-based queries not working:** + +If `seriesByTag()` or other tag functions fail: + +- Confirm your Graphite server is version 1.1 or later. +- Verify the Graphite version setting in your data source configuration matches your actual server version. +- Check that tags are properly configured in your Graphite server. + +## Performance issues + +Use the following troubleshooting steps to address slow queries or timeouts. + +**Queries timing out:** + +If queries consistently time out: + +- Increase the **Timeout** setting in the data source configuration. +- Reduce the time range of your query. +- Use more specific metric paths instead of broad wildcards. +- Consider using `summarize()` or `consolidateBy()` functions to reduce the amount of data returned. +- Check your Graphite server's performance and resource utilization. + +**Slow autocomplete in the query editor:** + +If metric path autocomplete is slow: + +- This often indicates a large number of metrics in your Graphite server. +- Use more specific path prefixes to narrow the search scope. +- Check your Graphite server's index performance. + +## MetricTank-specific issues + +If you're using MetricTank as your Graphite backend, use the following troubleshooting steps. + +**Rollup indicator not appearing:** + +If the rollup indicator doesn't display when expected: + +- Verify **Metrictank** is selected as the Graphite backend type in the data source configuration. +- Ensure the **Rollup indicator** toggle is enabled. +- The indicator only appears when data aggregation actually occurs. + +**Unexpected data aggregation:** + +If you see unexpected aggregation in your data: + +- Check the rollup configuration in your MetricTank instance. +- Adjust the time range or use `consolidateBy()` to control aggregation behavior. +- Review the query processing metadata in the panel inspector for details on how data was processed. + +## Get additional help + +If you continue to experience issues: + +- Check the [Grafana community forums](https://community.grafana.com/) for similar issues and solutions. +- Review the [Graphite documentation](https://graphite.readthedocs.io/) for additional configuration options. +- Contact [Grafana Support](https://grafana.com/support/) if you're an Enterprise, Cloud Pro, or Cloud Advanced customer. + +When reporting issues, include the following information: + +- Grafana version +- Graphite version (for example, 1.1.x) and backend type (Default or MetricTank) +- Authentication method (Basic Auth, TLS, or none) +- Error messages (redact sensitive information) +- Steps to reproduce the issue +- Relevant configuration such as data source settings, timeout values, and Graphite version setting (redact passwords and other credentials) +- Sample query (if applicable, with sensitive data redacted) diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard.md b/docs/sources/developer-resources/api-reference/http-api/dashboard.md index abef6a8f5e6..1b5d32bb9f4 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard.md @@ -231,6 +231,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the folder under which the dashboard should be created. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -521,6 +525,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/message** - Optional field, to set a commit message for the version history. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/folder.md b/docs/sources/developer-resources/api-reference/http-api/folder.md index f042a3a5a3e..6a6b18abeba 100644 --- a/docs/sources/developer-resources/api-reference/http-api/folder.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder.md @@ -259,6 +259,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be created. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -337,6 +341,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be - update this to move the folder under a different parent folder. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/library_element.md b/docs/sources/developer-resources/api-reference/http-api/library_element.md index ea241c220e1..ce367c485ef 100644 --- a/docs/sources/developer-resources/api-reference/http-api/library_element.md +++ b/docs/sources/developer-resources/api-reference/http-api/library_element.md @@ -41,7 +41,8 @@ Query parameters: - `sortDirection`: Sort order of elements. Use `alpha-asc` for ascending and `alpha-desc` for descending sort order. - `typeFilter`: A comma separated list of types to filter the elements by. - `excludeUid`: Element UID to exclude from search results. -- `folderFilter`: A comma separated list of folder IDs to filter the elements by. +- `folderFilter`: **Deprecated.** A comma separated list of folder IDs to filter the elements by. Use `folderFilterUIDs` instead. +- `folderFilterUIDs`: A comma separated list of folder UIDs to filter the elements by. - `perPage`: The number of results per page; default is 100. - `page`: The page for a set of records, given that only `perPage` records are returned at a time. Numbering starts at `1`. diff --git a/docs/sources/developer-resources/api-reference/http-api/preferences.md b/docs/sources/developer-resources/api-reference/http-api/preferences.md index 1cd350ee059..079fa1ebce9 100644 --- a/docs/sources/developer-resources/api-reference/http-api/preferences.md +++ b/docs/sources/developer-resources/api-reference/http-api/preferences.md @@ -25,7 +25,7 @@ Keys: - **theme** - One of: `light`, `dark`, or an empty string for the default theme - **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead. - **homeDashboardUID**: The `:uid` of a dashboard -- **timezone** - One of: `utc`, `browser`, or an empty string for the default +- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default. Omitting a key will cause the current value to be replaced with the system default value. diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md index 77df611f904..27c65e7bd02 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md @@ -38,13 +38,6 @@ Users can now view anonymous usage statistics, including the count of devices an The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access. -To display anonymous users and devices for versions 10.2, 10.3, 10.4, you need to enable the feature toggle `displayAnonymousStats` - -```bash -[feature_toggles] -enable = displayAnonymousStats -``` - ## Configuration Example: @@ -67,3 +60,15 @@ device_limit = ``` If you change your organization name in the Grafana UI this setting needs to be updated to match the new name. + +## Licensing for anonymous access + +Grafana Enterprise (self-managed) licenses anonymous access as active users. + +Anonymous access lets people use Grafana without login credentials. It was an early way to share dashboards, but Public dashboards gives you a more secure way to share dashboards. + +### How anonymous usage is counted + +Grafana estimates anonymous active users from anonymous devices: + +- **Counting rule**: Grafana counts 1 anonymous user for every 3 anonymous devices detected. diff --git a/docs/sources/shared/datasources/tempo-editor-traceql.md b/docs/sources/shared/datasources/tempo-editor-traceql.md index 54496c339e3..003a449fd96 100644 --- a/docs/sources/shared/datasources/tempo-editor-traceql.md +++ b/docs/sources/shared/datasources/tempo-editor-traceql.md @@ -135,9 +135,12 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query This field sets the maximum number of spans to return for each span set. By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100. In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator. -Grafana Cloud users can contact Grafana Support to request a change. Entering a value higher than the default results in an error. +{{< admonition type="note" >}} +Changing the value of `max_spans_per_span_set` isn't supported in Grafana Cloud. +{{< /admonition >}} + ### Focus on traces or spans Under **Options**, you can choose to display the table as **Traces** or **Spans** focused. diff --git a/docs/sources/tutorials/alerting-get-started-pt6/index.md b/docs/sources/tutorials/alerting-get-started-pt6/index.md index 470330237e2..edf5e61e178 100644 --- a/docs/sources/tutorials/alerting-get-started-pt6/index.md +++ b/docs/sources/tutorials/alerting-get-started-pt6/index.md @@ -23,6 +23,8 @@ killercoda: This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial. +{{< youtube id="mqj_hN24zLU" >}} + In this tutorial you will learn how to: diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts index 19ad38f16d5..867337ba088 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts @@ -51,6 +51,8 @@ test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] } await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index f874cefa27c..dd83b3a0cfd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -58,6 +58,8 @@ test.describe( await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 6dddba81820..ac10cb2b735 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -82,9 +82,9 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // click cell inspect, check that cell inspection pops open in the side as we'd expect. - await loremIpsumCell.getByLabel('Inspect value').click(); const loremIpsumText = await loremIpsumCell.textContent(); expect(loremIpsumText).toBeDefined(); + await loremIpsumCell.getByLabel('Inspect value').click(); await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); }); diff --git a/go.mod b/go.mod index 06659637f20..102310173d3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling cloud.google.com/go/kms v1.22.0 // @grafana/grafana-backend-group cloud.google.com/go/storage v1.55.0 // @grafana/grafana-backend-group - connectrpc.com/connect v1.18.1 // @grafana/observability-traces-and-profiling + connectrpc.com/connect v1.19.1 // @grafana/observability-traces-and-profiling cuelang.org/go v0.11.1 // @grafana/grafana-as-code dario.cat/mergo v1.0.2 // @grafana/grafana-app-platform-squad filippo.io/age v1.2.1 // @grafana/identity-access-team @@ -25,7 +25,6 @@ require ( github.com/Masterminds/semver v1.5.0 // @grafana/grafana-backend-group github.com/Masterminds/semver/v3 v3.4.0 // @grafana/grafana-developer-enablement-squad github.com/Masterminds/sprig/v3 v3.3.0 // @grafana/grafana-backend-group - github.com/ProtonMail/go-crypto v1.1.6 // @grafana/plugins-platform-backend github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/grafana-backend-group github.com/alicebob/miniredis/v2 v2.34.0 // @grafana/alerting-backend github.com/andybalholm/brotli v1.2.0 // @grafana/partner-datasources @@ -33,12 +32,14 @@ require ( github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend @@ -111,15 +112,14 @@ require ( github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling - github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling + github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // @grafana/identity-access-team github.com/hashicorp/go-hclog v1.6.3 // @grafana/plugins-platform-backend github.com/hashicorp/go-multierror v1.1.1 // @grafana/alerting-squad - github.com/hashicorp/go-plugin v1.7.0 // @grafana/plugins-platform-backend - github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // @grafana/plugins-platform-backend + github.com/hashicorp/go-plugin v1.7.0 // indirect; @grafana/plugins-platform-backend github.com/hashicorp/go-version v1.7.0 // @grafana/grafana-backend-group github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-backend github.com/hashicorp/hcl/v2 v2.24.0 // @grafana/alerting-backend @@ -248,7 +248,6 @@ require ( github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/folder v0.0.0 // @grafana/grafana-search-and-storage github.com/grafana/grafana/apps/iam v0.0.0 // @grafana/identity-access-team - github.com/grafana/grafana/apps/investigations v0.0.0 // @fcjack @matryer github.com/grafana/grafana/apps/logsdrilldown v0.0.0 // @grafana/observability-logs github.com/grafana/grafana/apps/playlist v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/plugins v0.0.0 // @grafana/plugins-platform-backend @@ -282,7 +281,6 @@ replace ( github.com/grafana/grafana/apps/dashboard => ./apps/dashboard github.com/grafana/grafana/apps/folder => ./apps/folder github.com/grafana/grafana/apps/iam => ./apps/iam - github.com/grafana/grafana/apps/investigations => ./apps/investigations github.com/grafana/grafana/apps/logsdrilldown => ./apps/logsdrilldown github.com/grafana/grafana/apps/playlist => ./apps/playlist github.com/grafana/grafana/apps/plugins => ./apps/plugins @@ -343,7 +341,6 @@ require ( github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect @@ -358,7 +355,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect @@ -395,7 +391,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // indirect - github.com/cloudflare/circl v1.6.1 // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -492,7 +487,6 @@ require ( github.com/jhump/protoreflect v1.17.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect @@ -660,10 +654,8 @@ require ( require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling -require github.com/Machiel/slugify v1.0.1 // @grafana/plugins-platform-backend - require ( - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect @@ -681,6 +673,7 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/google/gnostic v0.7.1 // indirect github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect diff --git a/go.sum b/go.sum index 9fe2c39eb9c..0a828108d08 100644 --- a/go.sum +++ b/go.sum @@ -627,8 +627,8 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= @@ -679,8 +679,7 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+ github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= @@ -738,8 +737,6 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= -github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= -github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -762,8 +759,6 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.5 h1:zl/OfRA6nftbBK9qTohYBJ5xvw6C/oNKizR7cZGl3cI= github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -1031,8 +1026,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -1503,6 +1496,8 @@ github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PU github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs= +github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -1685,8 +1680,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0= @@ -1758,8 +1753,6 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= -github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8= -github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= @@ -1884,10 +1877,6 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= -github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= -github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= -github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= diff --git a/go.work b/go.work index 884f393673e..208eca0454a 100644 --- a/go.work +++ b/go.work @@ -17,7 +17,6 @@ use ( ./apps/example ./apps/folder ./apps/iam - ./apps/investigations ./apps/logsdrilldown ./apps/playlist ./apps/plugins @@ -33,6 +32,7 @@ use ( ./pkg/build ./pkg/build/wire // skip:golangci-lint ./pkg/codegen + ./pkg/plugins ./pkg/plugins/codegen ./pkg/promlib ./pkg/semconv diff --git a/go.work.sum b/go.work.sum index 5030d8738a1..0300e5becbc 100644 --- a/go.work.sum +++ b/go.work.sum @@ -280,6 +280,7 @@ github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fw github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4= github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= @@ -574,6 +575,7 @@ github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnx github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= @@ -755,6 +757,8 @@ github.com/felixge/fgprof v0.9.4/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZP github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/flowstack/go-jsonschema v0.1.1 h1:dCrjGJRXIlbDsLAgTJZTjhwUJnnxVWl1OgNyYh5nyDc= +github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0= github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c h1:yKN46XJHYC/gvgH2UsisJ31+n4K3S7QYZSfU2uAWjuI= github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c/go.mod h1:L92h+dgwElEyUuShEwjbiHjseW410WIcNz+Bjutc8YQ= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= @@ -1347,6 +1351,7 @@ github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusrec github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.121.0/go.mod h1:3axnebi8xUm9ifbs1myzehw2nODtIMrQlL566sJ4bYw= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1 h1:XkxqUEoukMWXF+EpEWeM9itXKt62yKi13Lzd8ZEASP4= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1/go.mod h1:CuCZVPz+yn88b5vhZPAlxaMrVuhAVexUV6f8b07lpUc= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= @@ -1906,7 +1911,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= @@ -1950,10 +1954,12 @@ gocloud.dev/secrets/hashivault v0.42.0/go.mod h1:LXprr1XLEAT7BVZ+Y66dJEHQMzDsowI golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= @@ -2061,6 +2067,7 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= @@ -2085,7 +2092,6 @@ golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= @@ -2235,7 +2241,6 @@ gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzE gopkg.in/vmihailenco/msgpack.v2 v2.9.2 h1:gjPqo9orRVlSAH/065qw3MsFCDpH7fa1KpiizXyllY4= gopkg.in/vmihailenco/msgpack.v2 v2.9.2/go.mod h1:/3Dn1Npt9+MYyLpYYXjInO/5jvMLamn+AEGwNEOatn8= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.3.2 h1:ytYb4rOqyp1TSa2EPvNVwtPQJctSELKaMyLfqNP4+34= honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= diff --git a/package.json b/package.json index 36d93996980..72a8f638cb6 100644 --- a/package.json +++ b/package.json @@ -462,7 +462,7 @@ "js-yaml@npm:4.1.0": "^4.1.0", "js-yaml@npm:=4.1.0": "^4.1.0", "nodemailer": "7.0.11", - "@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch" + "@storybook/core@npm:8.6.15": "patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch" }, "workspaces": { "packages": [ diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 10b6e54448b..fa0ee6e5168 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -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" diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 031b1990b04..f72eab55212 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -139,6 +139,12 @@ "types": "./dist/types/clients/rtkq/logsdrilldown/v1alpha1/index.d.ts", "import": "./dist/esm/clients/rtkq/logsdrilldown/v1alpha1/index.mjs", "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1alpha1/index.cjs" + }, + "./rtkq/logsdrilldown/v1beta1": { + "@grafana-app/source": "./src/clients/rtkq/logsdrilldown/v1beta1/index.ts", + "types": "./dist/types/clients/rtkq/logsdrilldown/v1beta1/index.d.ts", + "import": "./dist/esm/clients/rtkq/logsdrilldown/v1beta1/index.mjs", + "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1beta1/index.cjs" } }, "publishConfig": { @@ -170,7 +176,7 @@ }, "peerDependencies": { "@grafana/runtime": ">=11.6 <= 12.x", - "@reduxjs/toolkit": "^2.8.0", + "@reduxjs/toolkit": "^2.10.0", "rxjs": "7.8.2" } } diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index f7e6e3772c9..708a7b04d5b 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -9,6 +9,7 @@ import { generatedAPI as folderAPIv1beta1 } from './folder/v1beta1'; import { generatedAPI as historianAlertingAPIv0alpha1 } from './historian.alerting/v0alpha1'; import { generatedAPI as iamAPIv0alpha1 } from './iam/v0alpha1'; import { generatedAPI as logsdrilldownAPIv1alpha1 } from './logsdrilldown/v1alpha1'; +import { generatedAPI as logsdrilldownAPIv1beta1 } from './logsdrilldown/v1beta1'; import { generatedAPI as migrateToCloudAPI } from './migrate-to-cloud'; import { generatedAPI as notificationsAlertingAPIv0alpha1 } from './notifications.alerting/v0alpha1'; import { generatedAPI as playlistAPIv0alpha1 } from './playlist/v0alpha1'; @@ -38,6 +39,7 @@ export const allMiddleware = [ notificationsAlertingAPIv0alpha1.middleware, rulesAlertingAPIv0alpha1.middleware, historianAlertingAPIv0alpha1.middleware, + logsdrilldownAPIv1beta1.middleware, logsdrilldownAPIv1alpha1.middleware, // PLOP_INJECT_MIDDLEWARE ] as const; @@ -61,6 +63,7 @@ export const allReducers = { [rulesAlertingAPIv0alpha1.reducerPath]: rulesAlertingAPIv0alpha1.reducer, [historianAlertingAPIv0alpha1.reducerPath]: historianAlertingAPIv0alpha1.reducer, [logsdrilldownAPIv1alpha1.reducerPath]: logsdrilldownAPIv1alpha1.reducer, + [logsdrilldownAPIv1beta1.reducerPath]: logsdrilldownAPIv1beta1.reducer, // PLOP_INJECT_REDUCER }; diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 4f17bb46ecf..4ceed793cad 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -1021,6 +1021,7 @@ const injectedRtkApi = api typeFilter: queryArg.typeFilter, excludeUid: queryArg.excludeUid, folderFilter: queryArg.folderFilter, + folderFilterUIDs: queryArg.folderFilterUiDs, perPage: queryArg.perPage, page: queryArg.page, }, @@ -2915,8 +2916,11 @@ export type GetLibraryElementsApiArg = { typeFilter?: string; /** Element UID to exclude from search results. */ excludeUid?: string; - /** A comma separated list of folder ID(s) to filter the elements by. */ + /** A comma separated list of folder ID(s) to filter the elements by. + Deprecated: Use FolderFilterUIDs instead. */ folderFilter?: string; + /** A comma separated list of folder UID(s) to filter the elements by. */ + folderFilterUiDs?: string; /** The number of results per page. */ perPage?: number; /** The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1. */ @@ -5312,7 +5316,8 @@ export type PatchPrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type UpdatePrefsCmd = { @@ -5325,7 +5330,8 @@ export type UpdatePrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark' | 'system'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type OrgUserDto = { @@ -5334,6 +5340,7 @@ export type OrgUserDto = { }; authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; isDisabled?: boolean; isExternallySynced?: boolean; @@ -5554,6 +5561,7 @@ export type ReportDashboard = { }; export type Type = string; export type ReportOptions = { + csvEncoding?: string; layout?: string; orientation?: string; pdfCombineOneFile?: boolean; @@ -6111,6 +6119,7 @@ export type ChangeUserPasswordCommand = { export type UserSearchHitDto = { authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; id?: number; isAdmin?: boolean; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts new file mode 100644 index 00000000000..484d5084e89 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'logsdrilldown.grafana.app' as const; +export const API_VERSION = 'v1beta1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'logsdrilldownAPIv1beta1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts new file mode 100644 index 00000000000..65395d2279d --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts @@ -0,0 +1,652 @@ +import { api } from './baseAPI'; +export const addTagTypes = ['API Discovery', 'LogsDrilldownDefaultColumns'] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getApiResources: build.query({ + query: () => ({ url: `/` }), + providesTags: ['API Discovery'], + }), + listLogsDrilldownDefaultColumns: build.query< + ListLogsDrilldownDefaultColumnsApiResponse, + ListLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + 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: ['LogsDrilldownDefaultColumns'], + }), + createLogsDrilldownDefaultColumns: build.mutation< + CreateLogsDrilldownDefaultColumnsApiResponse, + CreateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'POST', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deletecollectionLogsDrilldownDefaultColumns: build.mutation< + DeletecollectionLogsDrilldownDefaultColumnsApiResponse, + DeletecollectionLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + 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: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumns: build.query< + GetLogsDrilldownDefaultColumnsApiResponse, + GetLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumns: build.mutation< + ReplaceLogsDrilldownDefaultColumnsApiResponse, + ReplaceLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deleteLogsDrilldownDefaultColumns: build.mutation< + DeleteLogsDrilldownDefaultColumnsApiResponse, + DeleteLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumns: build.mutation< + UpdateLogsDrilldownDefaultColumnsApiResponse, + UpdateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumnsStatus: build.query< + GetLogsDrilldownDefaultColumnsStatusApiResponse, + GetLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumnsStatus: build.mutation< + ReplaceLogsDrilldownDefaultColumnsStatusApiResponse, + ReplaceLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumnsStatus: build.mutation< + UpdateLogsDrilldownDefaultColumnsStatusApiResponse, + UpdateLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${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: ['LogsDrilldownDefaultColumns'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; +export type GetApiResourcesApiArg = void; +export type ListLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumnsList; +export type ListLogsDrilldownDefaultColumnsApiArg = { + /** 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 CreateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns + | /** status 202 Accepted */ LogsDrilldownDefaultColumns; +export type CreateLogsDrilldownDefaultColumnsApiArg = { + /** 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; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeletecollectionLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ Status; +export type DeletecollectionLogsDrilldownDefaultColumnsApiArg = { + /** 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 GetLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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 ReplaceLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeleteLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | Status + | /** status 202 Accepted */ Status; +export type DeleteLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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 UpdateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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 GetLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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 ReplaceLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type UpdateLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + 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 ApiResource = { + /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ + categories?: string[]; + /** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */ + group?: string; + /** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */ + kind: string; + /** name is the plural name of the resource. */ + name: string; + /** namespaced indicates if a resource is namespaced or not. */ + namespaced: boolean; + /** shortNames is a list of suggested short names of the resource. */ + shortNames?: string[]; + /** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */ + singularName: string; + /** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */ + storageVersionHash?: string; + /** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */ + verbs: string[]; + /** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */ + version?: string; +}; +export type ApiResourceList = { + /** 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; + /** groupVersion is the group and version this APIResourceList is for. */ + groupVersion: 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; + /** resources contains the name of the resources and if they are namespaced. */ + resources: ApiResource[]; +}; +export type Time = string; +export type FieldsV1 = object; +export type ManagedFieldsEntry = { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string; + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string; + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1; + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string; + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string; + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string; + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time; +}; +export type OwnerReference = { + /** API version of the referent. */ + apiVersion: string; + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean; + /** If true, this reference points to the managing controller. */ + controller?: boolean; + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string; + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string; +}; +export type ObjectMeta = { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [key: string]: string; + }; + /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + creationTimestamp?: Time; + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + deletionGracePeriodSeconds?: number; + /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + deletionTimestamp?: Time; + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: string[]; + /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ + generateName?: string; + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + generation?: number; + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [key: string]: string; + }; + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: ManagedFieldsEntry[]; + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string; + /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ + namespace?: string; + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: OwnerReference[]; + /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . 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; + /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel = { + key: string; + value: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels = LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel[]; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord = { + columns: string[]; + labels: LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords = + LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord[]; +export type LogsDrilldownDefaultColumnsSpec = { + records: LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords; +}; +export type LogsDrilldownDefaultColumnsOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: { + [key: string]: any; + }; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. + It is limited to three possible states for machine evaluation. */ + state: 'success' | 'in_progress' | 'failed'; +}; +export type LogsDrilldownDefaultColumnsStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: { + [key: string]: any; + }; + }; + /** operatorStates is a map of operator ID to operator state evaluations. + Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: LogsDrilldownDefaultColumnsOperatorState; + }; +}; +export type LogsDrilldownDefaultColumns = { + /** 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; + spec: LogsDrilldownDefaultColumnsSpec; + status?: LogsDrilldownDefaultColumnsStatus; +}; +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 LogsDrilldownDefaultColumnsList = { + /** 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: LogsDrilldownDefaultColumns[]; + /** 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 const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListLogsDrilldownDefaultColumnsQuery, + useLazyListLogsDrilldownDefaultColumnsQuery, + useCreateLogsDrilldownDefaultColumnsMutation, + useDeletecollectionLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsQuery, + useLazyGetLogsDrilldownDefaultColumnsQuery, + useReplaceLogsDrilldownDefaultColumnsMutation, + useDeleteLogsDrilldownDefaultColumnsMutation, + useUpdateLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsStatusQuery, + useLazyGetLogsDrilldownDefaultColumnsStatusQuery, + useReplaceLogsDrilldownDefaultColumnsStatusMutation, + useUpdateLogsDrilldownDefaultColumnsStatusMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts index 71107ce1072..93b5259f144 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts @@ -86,7 +86,8 @@ export type PatchPrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type UpdatePrefsCmd = { @@ -99,7 +100,8 @@ export type UpdatePrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark' | 'system'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export const { diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index be22503f3bf..b6519295c66 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -122,6 +122,10 @@ const injectedRtkApi = api }), invalidatesTags: ['Connection'], }), + getConnectionRepositories: build.query({ + query: (queryArg) => ({ url: `/connections/${queryArg.name}/repositories` }), + providesTags: ['Connection'], + }), getConnectionStatus: build.query({ 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, diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index 5f529bfbf3d..8610da06920 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -111,6 +111,7 @@ const config: ConfigFile = { ...createAPIConfig('notifications.alerting', 'v0alpha1'), ...createAPIConfig('rules.alerting', 'v0alpha1'), ...createAPIConfig('historian.alerting', 'v0alpha1'), + ...createAPIConfig('logsdrilldown', 'v1beta1'), ...createAPIConfig('logsdrilldown', 'v1alpha1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator }, diff --git a/packages/grafana-data/src/datetime/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts index f53bbe8e6e5..3abf59ea43f 100644 --- a/packages/grafana-data/src/datetime/datemath.test.ts +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -165,9 +165,17 @@ describe('DateMath', () => { expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); }); - it('should handle multiple math expressions', () => { - const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5])); - expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf()); + it.each([ + ['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]], + ['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]], + ['-2d-1d', [2014, 1, 5], [2014, 1, 2]], + ['-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 5, 10, 30]], + ['-1d-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 4, 10, 30]], + ['+1d-6h', [2014, 1, 5], [2014, 1, 5, 18]], + ['-1w-1d', [2014, 1, 14], [2014, 1, 6]], + ])('should handle multiple math expressions: %s', (expression, inputDate, expectedDate) => { + const date = dateMath.parseDateMath(expression, dateTime(inputDate)); + expect(date!.valueOf()).toEqual(dateTime(expectedDate).valueOf()); }); it('should return false when invalid expression', () => { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ec009948a9b..6e35e460055 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -207,6 +207,10 @@ export interface FeatureToggles { */ reportingRetries?: boolean; /** + * Enables CSV encoding options in the reporting feature + */ + reportingCsvEncodingOptions?: boolean; + /** * Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. */ sseGroupByDatasource?: boolean; @@ -400,10 +404,6 @@ export interface FeatureToggles { */ tableSharedCrosshair?: boolean; /** - * Use the kubernetes API for feature toggle management in the frontend - */ - kubernetesFeatureToggles?: boolean; - /** * Enabled grafana cloud specific RBAC roles */ cloudRBACRoles?: boolean; @@ -782,20 +782,11 @@ export interface FeatureToggles { */ elasticsearchCrossClusterSearch?: boolean; /** - * Displays the navigation history so the user can navigate back to previous pages - */ - unifiedHistory?: boolean; - /** * Defaults to using the Loki `/labels` API instead of `/series` * @default true */ lokiLabelNamesQueryApi?: boolean; /** - * Enable the investigations backend API - * @default false - */ - investigationsBackend?: boolean; - /** * Enable folder's api server counts * @default false */ @@ -1263,4 +1254,8 @@ export interface FeatureToggles { * Enables the creation of keepers that manage secrets stored on AWS secrets manager */ secretsManagementAppPlatformAwsKeeper?: boolean; + /** + * Enables profiles exemplars support in profiles drilldown + */ + profilesExemplars?: boolean; } diff --git a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts index f323c53b037..c297dc86114 100644 --- a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts @@ -25,6 +25,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { * Allows to group the results. */ groupBy: Array; + /** + * If set to true, exemplars will be requested + */ + includeExemplars: boolean; /** * Specifies the query label selectors. */ @@ -49,6 +53,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { export const defaultGrafanaPyroscopeDataQuery: Partial = { groupBy: [], + includeExemplars: false, labelSelector: '{}', spanSelector: [], }; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 478c44704ed..e7b908fc08c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -137,23 +137,23 @@ "@babel/core": "7.28.0", "@faker-js/faker": "^9.0.0", "@rollup/plugin-node-resolve": "16.0.1", - "@storybook/addon-a11y": "^8.6.2", - "@storybook/addon-actions": "^8.6.2", - "@storybook/addon-docs": "^8.6.2", - "@storybook/addon-essentials": "^8.6.2", - "@storybook/addon-storysource": "^8.6.2", + "@storybook/addon-a11y": "^8.6.15", + "@storybook/addon-actions": "^8.6.15", + "@storybook/addon-docs": "^8.6.15", + "@storybook/addon-essentials": "^8.6.15", + "@storybook/addon-storysource": "^8.6.15", "@storybook/addon-webpack5-compiler-swc": "^2.1.0", - "@storybook/blocks": "^8.6.2", - "@storybook/components": "^8.6.2", - "@storybook/core-events": "^8.6.2", - "@storybook/manager-api": "^8.6.2", + "@storybook/blocks": "^8.6.15", + "@storybook/components": "^8.6.15", + "@storybook/core-events": "^8.6.15", + "@storybook/manager-api": "^8.6.15", "@storybook/mdx2-csf": "1.1.0", "@storybook/preset-scss": "1.0.3", - "@storybook/preview-api": "^8.6.2", - "@storybook/react": "^8.6.2", - "@storybook/react-webpack5": "^8.6.2", + "@storybook/preview-api": "^8.6.15", + "@storybook/react": "^8.6.15", + "@storybook/react-webpack5": "^8.6.15", "@storybook/test-runner": "^0.23.0", - "@storybook/theming": "^8.6.2", + "@storybook/theming": "^8.6.15", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", @@ -200,7 +200,7 @@ "rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-svg-import": "3.0.0", "sass-loader": "16.0.5", - "storybook": "^8.6.2", + "storybook": "^8.6.15", "style-loader": "4.0.0", "typescript": "5.9.2", "webpack": "5.101.0" diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 00039adfc3f..7d115e3f9d0 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -1,9 +1,7 @@ import { css, cx } from '@emotion/css'; +import { FloatingFocusManager, useFloating } from '@floating-ui/react'; import RcDrawer from '@rc-component/drawer'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; -import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useId, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -81,17 +79,16 @@ export function Drawer({ const styles = useStyles2(getStyles); const wrapperStyles = useStyles2(getWrapperStyles, size); const dragStyles = useStyles2(getDragStyles); + const titleId = useId(); - const overlayRef = React.useRef(null); - const { dialogProps, titleProps } = useDialog({}, overlayRef); - const { overlayProps } = useOverlay( - { - isDismissable: false, - isOpen: true, - onClose, + const { context, refs } = useFloating({ + open: true, + onOpenChange: (open) => { + if (!open) { + onClose?.(); + } }, - overlayRef - ); + }); // Adds body class while open so the toolbar nav can hide some actions while drawer is open useBodyClassWhileOpen(); @@ -117,6 +114,8 @@ export function Drawer({ minWidth, }, }} + aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined} + aria-labelledby={typeof title !== 'string' ? titleId : undefined} width={''} motion={{ motionAppear: true, @@ -129,18 +128,8 @@ export function Drawer({ motionName: styles.maskMotion, }} > - -
+ +
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
{typeof title === 'string' ? ( - + {title} {subtitle && ( @@ -169,13 +158,13 @@ export function Drawer({ )} ) : ( - title +
{title}
)} {tabs &&
{tabs}
}
{!scrollableContent ? content : {content}}
- +
); } diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index aaeb2c3e426..f6ea73c3d31 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,9 +1,7 @@ import { cx } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import { PropsWithChildren, useRef, type JSX } from 'react'; -import * as React from 'react'; +import { FloatingFocusManager, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react'; +import { OverlayContainer } from '@react-aria/overlays'; +import { PropsWithChildren, ReactNode, useId, type JSX } from 'react'; import { t } from '@grafana/i18n'; @@ -66,23 +64,26 @@ export function Modal(props: PropsWithChildren) { trapFocus = true, } = props; const styles = useStyles2(getModalStyles); + const titleId = useId(); - const ref = useRef(null); - - // Handle interacting outside the dialog and pressing - // the Escape key to close the modal. - const { overlayProps, underlayProps } = useOverlay( - { isKeyboardDismissDisabled: !closeOnEscape, isOpen, onClose: onDismiss }, - ref - ); - - // Get props for the dialog and its title - const { dialogProps, titleProps } = useDialog( - { - 'aria-label': ariaLabel, + const { context, refs } = useFloating({ + open: isOpen, + onOpenChange: (open) => { + if (!open) { + onDismiss?.(); + } }, - ref - ); + }); + + const dismiss = useDismiss(context, { + enabled: closeOnEscape, + }); + + const role = useRole(context, { + role: 'dialog', + }); + + const { getFloatingProps } = useInteractions([dismiss, role]); if (!isOpen) { return null; @@ -96,12 +97,17 @@ export function Modal(props: PropsWithChildren) { role="presentation" className={styles.modalBackdrop} onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)} - {...underlayProps} /> - -
+ +
- {typeof title === 'string' && } + {typeof title === 'string' && } { // FIXME: custom title components won't get an accessible title. // Do we really want to support them or shall we just limit this ModalTabsHeader? @@ -118,12 +124,12 @@ export function Modal(props: PropsWithChildren) {
{children}
- +
); } -function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) { +function ModalButtonRow({ leftItems, children }: { leftItems?: ReactNode; children: ReactNode }) { const styles = useStyles2(getModalStyles); if (leftItems) { diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index 6d6d05047d2..f60fafe36f3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,8 +1,9 @@ -import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react'; +import { useId, memo, HTMLAttributes, SVGProps } from 'react'; import { FieldDisplay } from '@grafana/data'; -import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; +import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks'; +import { getBarEndcapColors, getGradientCss } from './colors'; import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; import { drawRadialArcPath, toRad } from './utils'; @@ -29,11 +30,6 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase { type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; -const ENDPOINT_MARKER_MIN_ANGLE = 10; -const DOT_OPACITY = 0.5; -const DOT_RADIUS_FACTOR = 0.4; -const MAX_DOT_RADIUS = 8; - export const RadialArcPath = memo( ({ arcLengthDeg, @@ -68,67 +64,25 @@ export const RadialArcPath = memo( const xEnd = centerX + radius * Math.cos(endRadians); const yEnd = centerY + radius * Math.sin(endRadians); - const dotRadius = - endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; - const bgDivStyle: HTMLAttributes['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX }; - const pathProps: SVGProps = {}; - let barEndcapColors: [string, string] | undefined; - let endpointMarks: ReactNode = null; if (isGradient) { bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); - - if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { - switch (endpointMarker) { - case 'point': - const [pointColorStart, pointColorEnd] = getEndpointMarkerColors( - rest.gradient!, - fieldDisplay.display.percent - ); - endpointMarks = ( - <> - {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( - - )} - - - ); - break; - case 'glow': - const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); - const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); - const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); - endpointMarks = - arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? ( - - ) : null; - break; - default: - break; - } - } - - if (barEndcaps) { - barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); - } - pathProps.fill = 'none'; pathProps.stroke = 'white'; } else { bgDivStyle.backgroundColor = rest.color; - pathProps.fill = 'none'; pathProps.stroke = rest.color; } + let barEndcapColors: [string, string] | undefined; + if (barEndcaps) { + barEndcapColors = isGradient + ? getBarEndcapColors(rest.gradient, fieldDisplay.display.percent) + : [rest.color, rest.color]; + } + const pathEl = ( ); @@ -158,7 +112,23 @@ export const RadialArcPath = memo( )} - {endpointMarks} + {endpointMarker && ( + + )} ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx new file mode 100644 index 00000000000..af601e65c21 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx @@ -0,0 +1,143 @@ +import { render, RenderResult } from '@testing-library/react'; + +import { FieldDisplay } from '@grafana/data'; + +import { RadialArcPathEndpointMarks, RadialArcPathEndpointMarksProps } from './RadialArcPathEndpointMarks'; +import { RadialGaugeDimensions } from './types'; + +const ser = new XMLSerializer(); + +const expectHTML = (result: RenderResult, expected: string) => { + let actual = ser.serializeToString(result.asFragment()).replace(/xmlns=".*?" /g, ''); + expect(actual).toEqual(expected.replace(/^\s*|\n/gm, '')); +}; + +describe('RadialArcPathEndpointMarks', () => { + const defaultDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + vizWidth: 200, + vizHeight: 200, + margin: 10, + barIndex: 0, + thresholdsBarRadius: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }) satisfies RadialGaugeDimensions; + + const defaultFieldDisplay = Object.freeze({ + name: 'Test', + field: {}, + display: { text: '50', numeric: 50, color: '#FF0000' }, + hasLinks: false, + }) satisfies FieldDisplay; + + const defaultProps = Object.freeze({ + arcLengthDeg: 90, + dimensions: defaultDimensions, + fieldDisplay: defaultFieldDisplay, + startAngle: 0, + xStart: 100, + xEnd: 150, + yStart: 100, + yEnd: 50, + }) satisfies Omit; + + it('renders the expected marks when endpointMarker is "point" w/ a static color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "point" w/ a gradient color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "glow" w/ a static color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "glow" w/ a gradient color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render the start mark when arcLengthDeg is less than the minimum angle for "point" endpointMarker', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render anything when arcLengthDeg is less than the minimum angle for "glow" endpointMarker', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render anything if endpointMarker is some other value', () => { + expectHTML( + render( + + {/* @ts-ignore: confirming the component doesn't throw */} + + + ), + '' + ); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx new file mode 100644 index 00000000000..7bd9a8c435f --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx @@ -0,0 +1,98 @@ +import { FieldDisplay } from '@grafana/data'; + +import { getEndpointMarkerColors, getGuideDotColor } from './colors'; +import { GradientStop, RadialGaugeDimensions } from './types'; +import { toRad } from './utils'; + +interface RadialArcPathEndpointMarksPropsBase { + arcLengthDeg: number; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; + endpointMarker: 'point' | 'glow'; + roundedBars?: boolean; + startAngle: number; + glowFilter?: string; + endpointMarkerGlowFilter?: string; + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +} + +interface RadialArcPathEndpointMarksPropsWithColor extends RadialArcPathEndpointMarksPropsBase { + color: string; +} + +interface RadialArcPathEndpointMarksPropsWithGradient extends RadialArcPathEndpointMarksPropsBase { + gradient: GradientStop[]; +} + +export type RadialArcPathEndpointMarksProps = + | RadialArcPathEndpointMarksPropsWithColor + | RadialArcPathEndpointMarksPropsWithGradient; + +const ENDPOINT_MARKER_MIN_ANGLE = 10; +const DOT_OPACITY = 0.5; +const DOT_RADIUS_FACTOR = 0.4; +const MAX_DOT_RADIUS = 8; + +export function RadialArcPathEndpointMarks({ + startAngle: angle, + arcLengthDeg, + dimensions, + endpointMarker, + fieldDisplay, + xStart, + xEnd, + yStart, + yEnd, + roundedBars, + endpointMarkerGlowFilter, + glowFilter, + ...rest +}: RadialArcPathEndpointMarksProps) { + const isGradient = 'gradient' in rest; + const { radius, centerX, centerY, barWidth } = dimensions; + const endRadians = toRad(angle + arcLengthDeg); + + switch (endpointMarker) { + case 'point': { + const [pointColorStart, pointColorEnd] = isGradient + ? getEndpointMarkerColors(rest.gradient, fieldDisplay.display.percent) + : [getGuideDotColor(rest.color), getGuideDotColor(rest.color)]; + + const dotRadius = + endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + + return ( + <> + {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( + + )} + + + ); + } + case 'glow': + const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); + const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); + const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); + if (arcLengthDeg <= ENDPOINT_MARKER_MIN_ANGLE) { + break; + } + return ( + + ); + default: + break; + } + + return null; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index 3eb81c10899..a05817a7a54 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -175,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape const GRAY_05 = '#111217'; const GRAY_90 = '#fbfbfb'; const CONTRAST_THRESHOLD_MAX = 4.5; -const getGuideDotColor = (color: string): string => { +export const getGuideDotColor = (color: string): string => { const darkColor = GRAY_05; const lightColor = GRAY_90; return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index c1402c4da2d..cb7259ce7d0 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -120,7 +120,7 @@ const defaultConfig: GraphFieldConfig = { drawStyle: GraphDrawStyle.Line, showPoints: VisibilityMode.Auto, axisPlacement: AxisPlacement.Hidden, - pointSize: 2, + pointSize: 0, }; export const prepareSeries = ( @@ -204,6 +204,7 @@ export const prepareConfig = ( scaleKey: 'x', theme, placement: AxisPlacement.Hidden, + show: false, }); for (let i = 0; i < dataFrame.fields.length; i++) { @@ -230,6 +231,7 @@ export const prepareConfig = ( scaleKey, theme, placement: AxisPlacement.Hidden, + show: false, }); const colorMode = getFieldColorModeForField(field); diff --git a/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx b/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx index 1b6e78213da..524556f258b 100644 --- a/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx +++ b/packages/grafana-ui/src/components/TagsInput/TagsInput.tsx @@ -54,6 +54,7 @@ export const TagsInput = forwardRef( const [newTagName, setNewTagName] = useState(''); const styles = useStyles2(getStyles); const theme = useTheme2(); + const isTagTooLong = newTagName.length > 50; const onNameChange = useCallback((event: React.ChangeEvent) => { setNewTagName(event.target.value); @@ -65,6 +66,9 @@ export const TagsInput = forwardRef( const onAdd = (event?: React.MouseEvent | React.KeyboardEvent) => { event?.preventDefault(); + if (newTagName.length > 50) { + return; + } if (!tags.includes(newTagName)) { onChange(tags.concat(newTagName)); } @@ -94,14 +98,17 @@ export const TagsInput = forwardRef( value={newTagName} onKeyDown={onKeyboardAdd} onBlur={onBlur} - invalid={invalid} + invalid={invalid || isTagTooLong} suffix={ diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx b/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx index 53b75f5b5cb..fcfea97a45f 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx @@ -75,5 +75,3 @@ return ( ); ``` - - diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx index 1c3cbb57919..a7006fd70eb 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx @@ -1,6 +1,11 @@ import { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; import { Button } from '../Button/Button'; +import { Drawer } from '../Drawer/Drawer'; +import { Field } from '../Forms/Field'; +import { Input } from '../Input/Input'; +import { Modal } from '../Modal/Modal'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; import mdx from '../Toggletip/Toggletip.mdx'; @@ -133,4 +138,86 @@ LongContent.parameters = { }, }; +export const InsideDrawer: StoryFn = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + return ( + <> + + {isDrawerOpen && ( + setIsDrawerOpen(false)}> +
+

This demonstrates using Toggletip inside a Drawer.

+ + + + + +
+ } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + +
+ + )} + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + +export const InsideModal: StoryFn = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + setIsModalOpen(false)}> +
+

This demonstrates using Toggletip inside a Modal.

+ + + + + + +
+ } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + + +
+ + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + export default meta; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx index b32145da0da..04718af9493 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -23,7 +23,7 @@ export interface UsersIndicatorProps { * https://developers.grafana.com/ui/latest/index.html?path=/docs/iconography-usersindicator--docs */ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => { - const styles = useStyles2(getStyles); + const styles = useStyles2(getStyles, limit); if (!users.length) { return null; } @@ -39,6 +39,9 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp className={styles.container} aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')} > + {users.slice(0, limitReached ? limit : limit + 1).map((userView, idx, arr) => ( + + ))} {limitReached && ( {tooManyUsers @@ -47,26 +50,30 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp : `+${extraUsers}`} )} - {users - .slice(0, limitReached ? limit : limit + 1) - .reverse() - .map((userView) => ( - - ))} ); }; -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, limit: number) => { return { container: css({ display: 'flex', justifyContent: 'center', - flexDirection: 'row-reverse', marginLeft: theme.spacing(1), + isolation: 'isolate', '& > button': { marginLeft: theme.spacing(-1), // Overlay the elements a bit on top of each other + + // Ensure overlaying user icons are stacked correctly with z-index on each element + ...Object.fromEntries( + Array.from({ length: limit }).map((_, idx) => [ + `&:nth-of-type(${idx + 1})`, + { + zIndex: limit - idx, + }, + ]) + ), }, }), dots: css({ diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 0c2859453eb..b654a2d3ac6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,6 +119,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { + width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 56ec6cb733e..6de77fd660b 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -134,6 +134,7 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'LegendLabelCell', maxWidth: 0, width: '100%', + minWidth: theme.spacing(16), }), labelCellInner: css({ label: 'LegendLabelCellInner', diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index d7dc89d248f..0fe733ea1b5 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response. func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds()) }() ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID()) @@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds()) }() uid := web.Params(c.Req)[":uid"] @@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error { func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds()) }() cmd := datasources.AddDataSourceCommand{} @@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response. func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds()) }() cmd := datasources.UpdateDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index dd0b39e13a7..8a0e27639d4 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram Namespace: "grafana", Name: "ds_config_handler_requests_duration_seconds", Help: "Duration of requests handled by datasource configuration handlers", - }, []string{"code_path", "handler"}) + }, []string{"handler"}) promRegister.MustRegister(dsConfigHandlerRequestsDuration) return promRegister, dsConfigHandlerRequestsDuration } diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index cc7da29b550..252810d8bd4 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -13,7 +13,7 @@ type UpdatePrefsCmd struct { // Deprecated: Use HomeDashboardUID instead HomeDashboardID int64 `json:"homeDashboardId"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` - // Enum: utc,browser + // Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string Timezone string `json:"timezone"` WeekStart string `json:"weekStart"` QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"` @@ -31,7 +31,7 @@ type PatchPrefsCmd struct { // Default:0 // Deprecated: Use HomeDashboardUID instead HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` - // Enum: utc,browser + // Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string Timezone *string `json:"timezone,omitempty"` WeekStart *string `json:"weekStart,omitempty"` Language *string `json:"language,omitempty"` diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 0898a5ecb66..591941d8bf5 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi Namespace: "grafana", Name: "ds_config_handler_requests_duration_seconds", Help: "Duration of requests handled by datasource configuration handlers", - }, []string{"code_path", "handler"}), + }, []string{"handler"}), } promRegister.MustRegister(hs.htmlHandlerRequestsDuration) diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 99561233366..002965a1606 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -928,9 +928,10 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) features := featuremgmt.WithFeatures() quotaService := quotatest.New(false, nil) + dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features) dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features) require.NoError(t, err) @@ -1050,9 +1051,11 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets var routes []*plugins.Route features := featuremgmt.WithFeatures() quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), + var sqlStore db.DB = nil + dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features) + dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features) require.NoError(t, err) @@ -1106,9 +1109,11 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger()) features := featuremgmt.WithFeatures() - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), + var sqlStore db.DB = nil + dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features) + dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) tracer := tracing.InitializeTracerForTest() diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index 785b69f2b7c..9650b2d92c2 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) { + return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil) + } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index feaf1755c94..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -46,7 +46,6 @@ import ( _ "sigs.k8s.io/randfill" _ "xorm.io/builder" - _ "github.com/aws/aws-sdk-go-v2/service/secretsmanager" _ "github.com/grafana/authlib/authn" _ "github.com/grafana/authlib/authz" _ "github.com/grafana/authlib/cache" diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index f8b340d12df..276014c7d6b 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) { case "rateLimiting": return newRateLimiter(ots.cfg.SamplerParam), nil case "remote": - return jaegerremote.New("grafana", + return jaegerremote.New(ots.cfg.ServiceName, jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL), jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)), ), nil diff --git a/pkg/operators/provisioning/config.go b/pkg/operators/provisioning/config.go index 05552e56095..868d2a8d717 100644 --- a/pkg/operators/provisioning/config.go +++ b/pkg/operators/provisioning/config.go @@ -36,7 +36,6 @@ import ( type provisioningControllerConfig struct { provisioningClient *client.Clientset resyncInterval time.Duration - repoFactory repository.Factory unified resources.ResourceStore clients resources.ClientFactory tokenExchangeClient *authn.TokenExchangeClient @@ -129,16 +128,6 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll return nil, fmt.Errorf("failed to create provisioning client: %w", err) } - decrypter, err := setupDecrypter(cfg, tracer, tokenExchangeClient) - if err != nil { - return nil, fmt.Errorf("failed to setup decrypter: %w", err) - } - - repoFactory, err := setupRepoFactory(cfg, decrypter, provisioningClient, registry) - if err != nil { - return nil, fmt.Errorf("failed to setup repository getter: %w", err) - } - // HACK: This logic directly connects to unified storage. We are doing this for now as there is no global // search endpoint. But controllers, in general, should not connect directly to unified storage and instead // go through the api server. Once there is a global search endpoint, we will switch to that here as well. @@ -195,7 +184,6 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll return &provisioningControllerConfig{ provisioningClient: provisioningClient, - repoFactory: repoFactory, unified: unified, clients: clients, resyncInterval: operatorSec.Key("resync_interval").MustDuration(60 * time.Second), diff --git a/pkg/operators/provisioning/connection_operator.go b/pkg/operators/provisioning/connection_operator.go new file mode 100644 index 00000000000..34624f4fe47 --- /dev/null +++ b/pkg/operators/provisioning/connection_operator.go @@ -0,0 +1,86 @@ +package provisioning + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/prometheus/client_golang/prometheus" + "k8s.io/client-go/tools/cache" + + appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/setting" +) + +// RunConnectionController starts the connection controller operator. +func RunConnectionController(deps server.OperatorDependencies) error { + logger := logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })).With("logger", "provisioning-connection-controller") + logger.Info("Starting provisioning connection controller") + + controllerCfg, err := getConnectionControllerConfig(deps.Config, deps.Registerer) + if err != nil { + return fmt.Errorf("failed to setup operator: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("Received shutdown signal, stopping controllers") + cancel() + }() + + informerFactory := informer.NewSharedInformerFactoryWithOptions( + controllerCfg.provisioningClient, + controllerCfg.resyncInterval, + ) + + statusPatcher := appcontroller.NewConnectionStatusPatcher(controllerCfg.provisioningClient.ProvisioningV0alpha1()) + connInformer := informerFactory.Provisioning().V0alpha1().Connections() + + connController, err := controller.NewConnectionController( + controllerCfg.provisioningClient.ProvisioningV0alpha1(), + connInformer, + statusPatcher, + ) + if err != nil { + return fmt.Errorf("failed to create connection controller: %w", err) + } + + informerFactory.Start(ctx.Done()) + if !cache.WaitForCacheSync(ctx.Done(), connInformer.Informer().HasSynced) { + return fmt.Errorf("failed to sync informer cache") + } + + connController.Run(ctx, controllerCfg.workerCount) + return nil +} + +type connectionControllerConfig struct { + provisioningControllerConfig + workerCount int +} + +func getConnectionControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) (*connectionControllerConfig, error) { + controllerCfg, err := setupFromConfig(cfg, registry) + if err != nil { + return nil, err + } + + return &connectionControllerConfig{ + provisioningControllerConfig: *controllerCfg, + workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), + }, nil +} diff --git a/pkg/operators/provisioning/repo_operator.go b/pkg/operators/provisioning/repo_operator.go index 416eb5c0e3f..c3a038b9378 100644 --- a/pkg/operators/provisioning/repo_operator.go +++ b/pkg/operators/provisioning/repo_operator.go @@ -106,6 +106,7 @@ func RunRepoController(deps server.OperatorDependencies) error { type repoControllerConfig struct { provisioningControllerConfig + repoFactory repository.Factory workerCount int parallelOperations int allowedTargets []string @@ -119,6 +120,17 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( return nil, err } + // Setup repository factory for repo controller + decrypter, err := setupDecrypter(cfg, tracing.NewNoopTracerService(), controllerCfg.tokenExchangeClient) + if err != nil { + return nil, fmt.Errorf("failed to setup decrypter: %w", err) + } + + repoFactory, err := setupRepoFactory(cfg, decrypter, controllerCfg.provisioningClient, registry) + if err != nil { + return nil, fmt.Errorf("failed to setup repository factory: %w", err) + } + allowedTargets := []string{} cfg.SectionWithEnvOverrides("provisioning").Key("allowed_targets").Strings("|") if len(allowedTargets) == 0 { @@ -127,6 +139,7 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( return &repoControllerConfig{ provisioningControllerConfig: *controllerCfg, + repoFactory: repoFactory, allowedTargets: allowedTargets, workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), parallelOperations: cfg.SectionWithEnvOverrides("operator").Key("parallel_operations").MustInt(10), diff --git a/pkg/operators/register.go b/pkg/operators/register.go index b31b9837fb0..4d42591ca7b 100644 --- a/pkg/operators/register.go +++ b/pkg/operators/register.go @@ -13,6 +13,12 @@ func init() { RunFunc: provisioning.RunRepoController, }) + server.RegisterOperator(server.Operator{ + Name: "provisioning-connection", + Description: "Watch provisioning connections", + RunFunc: provisioning.RunConnectionController, + }) + server.RegisterOperator(server.Operator{ Name: "iam-folder-reconciler", Description: "Reconcile folder resources into Zanzana", diff --git a/pkg/plugins/go.mod b/pkg/plugins/go.mod new file mode 100644 index 00000000000..9bcc74e80f6 --- /dev/null +++ b/pkg/plugins/go.mod @@ -0,0 +1,130 @@ +module github.com/grafana/grafana/pkg/plugins + +go 1.25.5 + +require ( + github.com/Machiel/slugify v1.0.1 + github.com/ProtonMail/go-crypto v1.3.0 + github.com/gobwas/glob v0.2.3 + github.com/google/go-cmp v0.7.0 + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 + github.com/grafana/grafana/pkg/apimachinery v0.0.0 + github.com/grafana/grafana/pkg/semconv v0.0.0 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.7.0 + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apache/arrow-go/v18 v18.4.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cheekybits/genny v1.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/googleapis v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect + github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect + github.com/grafana/otel-profiling-go v0.5.1 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/jaegertracing/jaeger-idl v0.5.0 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattetti/filebuffer v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.40.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect + k8s.io/apimachinery v0.34.3 // indirect + k8s.io/apiserver v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect +) + +replace ( + github.com/grafana/grafana/pkg/apimachinery => ../apimachinery + github.com/grafana/grafana/pkg/semconv => ../semconv +) diff --git a/pkg/plugins/go.sum b/pkg/plugins/go.sum new file mode 100644 index 00000000000..2bd8adfbad2 --- /dev/null +++ b/pkg/plugins/go.sum @@ -0,0 +1,347 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= +github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= +github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= +github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= +github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= +github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE= +github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0/go.mod h1:rjbQTDEPQymPE0YnRQp9/NuPwwtL0sesz/fnqRW/v84= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 h1:nXGeLvT1QtCAhkASkP/ksjkTKZALIaQBIW+JSIw1KIc= +go.opentelemetry.io/contrib/propagators/jaeger v1.38.0/go.mod h1:oMvOXk78ZR3KEuPMBgp/ThAMDy9ku/eyUVztr+3G6Wo= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 h1:oPW/SRFyHgIgxrvNhSBzqvZER2N5kRlci3/rGTOuyWo= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bnmZNO6gBbBta6nohD/1Z+f9waH2oXyBs= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.3 h1:uGH1qpDvSiYG4HVFqc6A3L4CKiX+aBWDrrsxHYK0Bdo= +k8s.io/apiserver v0.34.3/go.mod h1:QPnnahMO5C2m3lm6fPW3+JmyQbvHZQ8uudAu/493P2w= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 0b7ffa04ea4..0fa8b7d7115 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -224,7 +224,7 @@ func (a *dashboardSqlAccess) CountResources(ctx context.Context, opts MigrateOpt case "folder.grafana.app/folders": summary := &resourcepb.BulkResponse_Summary{} summary.Group = folders.GROUP - summary.Group = folders.RESOURCE + summary.Resource = folders.RESOURCE _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ " WHERE is_folder=TRUE AND org_id=?", orgId).Get(&summary.Count) rsp.Summary = append(rsp.Summary, summary) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eed79dd6f0d..6973a443b12 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A return apierrors.NewBadRequest(err.Error()) } + // Validate tags + if err := validateDashboardTags(dashObj); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + id, err := identity.GetRequester(ctx) if err != nil { return fmt.Errorf("error getting requester: %w", err) @@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A return apierrors.NewBadRequest(err.Error()) } + // Validate tags + if err := validateDashboardTags(newDashObj); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + // Validate folder existence if specified and changed if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" { id, err := identity.GetRequester(ctx) @@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) { return title, refresh, nil } +// validateDashboardTags validates that all dashboard tags are within the maximum length +func validateDashboardTags(obj runtime.Object) error { + var tags []string + + switch d := obj.(type) { + case *dashv0.Dashboard: + tags = d.Spec.GetNestedStringSlice("tags") + case *dashv1.Dashboard: + tags = d.Spec.GetNestedStringSlice("tags") + case *dashv2alpha1.Dashboard: + tags = d.Spec.Tags + case *dashv2beta1.Dashboard: + tags = d.Spec.Tags + default: + return fmt.Errorf("unsupported dashboard version: %T", obj) + } + + for _, tag := range tags { + if len(tag) > 50 { + return dashboards.ErrDashboardTagTooLong + } + } + + return nil +} + func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storageOpts := apistore.StorageOptions{ EnableFolderSupport: true, diff --git a/pkg/registry/apis/datasource/legacy_store.go b/pkg/registry/apis/datasource/legacy_store.go index e0526dd2409..c263129a157 100644 --- a/pkg/registry/apis/datasource/legacy_store.go +++ b/pkg/registry/apis/datasource/legacy_store.go @@ -57,6 +57,12 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec } func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + if s.dsConfigHandlerRequestsDuration != nil { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.List"), time.Since(start).Seconds()) + }() + } return s.datasources.ListDataSources(ctx) } @@ -64,7 +70,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds()) }() } @@ -76,7 +82,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds()) }() } @@ -92,7 +98,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds()) }() } @@ -135,7 +141,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds()) }() } @@ -145,6 +151,13 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio // DeleteCollection implements rest.CollectionDeleter. func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + if s.dsConfigHandlerRequestsDuration != nil { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.DeleteCollection"), time.Since(start).Seconds()) + }() + } + dss, err := s.datasources.ListDataSources(ctx) if err != nil { return nil, err diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 4b6f27da5c0..9222c020681 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -21,6 +21,7 @@ import ( datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/sources" @@ -69,10 +70,10 @@ func RegisterAPIService( dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "grafana", - Name: "ds_config_handler_requests_duration_seconds", - Help: "Duration of requests handled by datasource configuration handlers", - }, []string{"code_path", "handler"}) - regErr := reg.Register(dataSourceCRUDMetric) + Name: "ds_config_handler_apis_requests_duration_seconds", + Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers", + }, []string{"handler"}) + regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric) if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) { return nil, regErr } diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 8abc67bf885..efcf6fa5b39 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -53,7 +53,7 @@ func newIAMAuthorizer( resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer - resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = allowAuthorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer resourceAuthorizer["searchUsers"] = serviceAuthorizer resourceAuthorizer["searchTeams"] = serviceAuthorizer diff --git a/pkg/registry/apis/iam/authorizer/external_group_mapping.go b/pkg/registry/apis/iam/authorizer/external_group_mapping.go new file mode 100644 index 00000000000..0537acbe81d --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/external_group_mapping.go @@ -0,0 +1,150 @@ +package authorizer + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + "k8s.io/apimachinery/pkg/runtime" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +type ExternalGroupMappingAuthorizer struct { + accessClient types.AccessClient +} + +var _ storewrapper.ResourceStorageAuthorizer = (*ExternalGroupMappingAuthorizer)(nil) + +func NewExternalGroupMappingAuthorizer( + accessClient types.AccessClient, +) *ExternalGroupMappingAuthorizer { + return &ExternalGroupMappingAuthorizer{ + accessClient: accessClient, + } +} + +// AfterGet implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.ExternalGroupMapping) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbGetPermissions, + Name: teamName, + } + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.ExternalGroupMappingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot access team %s", teamName), + ) + } + return nil +} + +// BeforeCreate implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeDelete implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeUpdate implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error { + // Update is not supported for ExternalGroupMapping resources and update attempts are blocked at a lower level, + // so this is just a safeguard. + return apierrors.NewMethodNotSupported(iamv0.ExternalGroupMappingResourceInfo.GroupResource(), "PUT/PATCH") +} + +func (r *ExternalGroupMappingAuthorizer) beforeWrite(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.ExternalGroupMapping) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbSetPermissions, + Name: teamName, + } + + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.ExternalGroupMappingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot write team %s", teamName), + ) + } + return nil +} + +// FilterList implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return nil, storewrapper.ErrUnauthenticated + } + + l, ok := list.(*iamv0.ExternalGroupMappingList) + if !ok { + return nil, apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMappingList, got %T: %w", list, storewrapper.ErrUnexpectedType)) + } + + var filteredItems []iamv0.ExternalGroupMapping + + listReq := types.ListRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbGetPermissions, + } + canView, _, err := r.accessClient.Compile(ctx, authInfo, listReq) + if err != nil { + return nil, apierrors.NewInternalError(err) + } + + for _, item := range l.Items { + if canView(item.Spec.TeamRef.Name, "") { + filteredItems = append(filteredItems, item) + } + } + + l.Items = filteredItems + return l, nil +} diff --git a/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go b/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go new file mode 100644 index 00000000000..69ee7e5fed9 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go @@ -0,0 +1,229 @@ +package authorizer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/authlib/types" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func newExternalGroupMapping(teamName, name string) *iamv0.ExternalGroupMapping { + return &iamv0.ExternalGroupMapping{ + ObjectMeta: metav1.ObjectMeta{Namespace: "org-2", Name: name}, + Spec: iamv0.ExternalGroupMappingSpec{ + TeamRef: iamv0.ExternalGroupMappingTeamRef{ + Name: teamName, + }, + }, + } +} + +func TestExternalGroupMapping_AfterGet(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow access", + shouldAllow: true, + }, + { + name: "deny access", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.AfterGet(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestExternalGroupMapping_FilterList(t *testing.T) { + list := &iamv0.ExternalGroupMappingList{ + Items: []iamv0.ExternalGroupMapping{ + *newExternalGroupMapping("team-1", "mapping-1"), + *newExternalGroupMapping("team-2", "mapping-2"), + }, + ListMeta: metav1.ListMeta{ + SelfLink: "/apis/iam.grafana.app/v0alpha1/namespaces/org-2/externalgroupmappings", + }, + } + + compileFunc := func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + + return func(name, folder string) bool { + return name == "team-1" + }, &types.NoopZookie{}, nil + } + + accessClient := &fakeAccessClient{compileFunc: compileFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + obj, err := authz.FilterList(ctx, list) + require.NoError(t, err) + require.NotNil(t, list) + require.True(t, accessClient.compileCalled) + + filtered, ok := obj.(*iamv0.ExternalGroupMappingList) + require.True(t, ok) + require.Len(t, filtered.Items, 1) + require.Equal(t, "mapping-1", filtered.Items[0].Name) +} + +func TestExternalGroupMapping_BeforeCreate(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow create", + shouldAllow: true, + }, + { + name: "deny create", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeCreate(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestExternalGroupMapping_BeforeUpdate(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + accessClient := &fakeAccessClient{ + checkFunc: func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.Fail(t, "check should not be called") + return types.CheckResponse{}, nil + }, + } + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeUpdate(ctx, mapping) + require.Error(t, err) + require.True(t, apierrors.IsMethodNotSupported(err)) + require.Contains(t, err.Error(), "PUT/PATCH") + require.False(t, accessClient.checkCalled) +} + +func TestExternalGroupMapping_BeforeDelete(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow delete", + shouldAllow: true, + }, + { + name: "deny delete", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeDelete(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions.go b/pkg/registry/apis/iam/authorizer/resource_permissions.go index 0fbf413adac..098037c93b7 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions.go @@ -179,19 +179,17 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run canViewFuncs = map[schema.GroupResource]types.ItemChecker{} ) for _, item := range l.Items { - gr := schema.GroupResource{ - Group: item.Spec.Resource.ApiGroup, - Resource: item.Spec.Resource.Resource, - } + target := item.Spec.Resource + targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource} // Reuse the same canView for items with the same resource - canView, found := canViewFuncs[gr] + canView, found := canViewFuncs[targetGR] if !found { listReq := types.ListRequest{ Namespace: item.Namespace, - Group: item.Spec.Resource.ApiGroup, - Resource: item.Spec.Resource.Resource, + Group: target.ApiGroup, + Resource: target.Resource, Verb: utils.VerbGetPermissions, } @@ -200,12 +198,9 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run return nil, err } - canViewFuncs[gr] = canView + canViewFuncs[targetGR] = 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. diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go index df777ee31e9..9e12bd87ebf 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go @@ -4,35 +4,15 @@ import ( "context" "testing" - "github.com/go-jose/go-jose/v4/jwt" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" ) -var ( - user = authn.NewIDTokenAuthInfo( - authn.Claims[authn.AccessTokenClaims]{ - Claims: jwt.Claims{Issuer: "grafana", - Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, - Rest: authn.AccessTokenClaims{ - Namespace: "*", - Permissions: identity.ServiceIdentityClaims.Rest.Permissions, - DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, - }, - }, &authn.Claims[authn.IDTokenClaims]{ - Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, - Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, - }, - ) -) - func newResourcePermission(apiGroup, resource, name string) *iamv0.ResourcePermission { return &iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{Namespace: "org-2"}, @@ -222,26 +202,6 @@ func TestResourcePermissions_beforeWrite(t *testing.T) { } } -// fakeAccessClient is a mock implementation of claims.AccessClient -type fakeAccessClient struct { - checkCalled bool - checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) - compileCalled bool - compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) -} - -func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { - m.checkCalled = true - return m.checkFunc(id, &req, folder) -} - -func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { - m.compileCalled = true - return m.compileFunc(id, req) -} - -var _ types.AccessClient = (*fakeAccessClient)(nil) - type fakeParentProvider struct { hasParent bool getParentCalled bool diff --git a/pkg/registry/apis/iam/authorizer/testutil.go b/pkg/registry/apis/iam/authorizer/testutil.go new file mode 100644 index 00000000000..37e40596583 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/testutil.go @@ -0,0 +1,48 @@ +package authorizer + +import ( + "context" + + "github.com/go-jose/go-jose/v4/jwt" + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +var ( + // Shared test user identity + user = authn.NewIDTokenAuthInfo( + authn.Claims[authn.AccessTokenClaims]{ + Claims: jwt.Claims{Issuer: "grafana", + Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, + Rest: authn.AccessTokenClaims{ + Namespace: "*", + Permissions: identity.ServiceIdentityClaims.Rest.Permissions, + DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, + }, + }, &authn.Claims[authn.IDTokenClaims]{ + Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, + Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, + }, + ) +) + +var _ types.AccessClient = (*fakeAccessClient)(nil) + +// fakeAccessClient is a mock implementation of claims.AccessClient +type fakeAccessClient struct { + checkCalled bool + checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) + compileCalled bool + compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) +} + +func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { + m.checkCalled = true + return m.checkFunc(id, &req, folder) +} + +func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + m.compileCalled = true + return m.compileFunc(id, req) +} diff --git a/pkg/registry/apis/iam/common/common.go b/pkg/registry/apis/iam/common/common.go index b508084bcae..a5409ce5ac4 100644 --- a/pkg/registry/apis/iam/common/common.go +++ b/pkg/registry/apis/iam/common/common.go @@ -12,6 +12,7 @@ import ( legacyiamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/team" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // OptonalFormatInt formats num as a string. If num is less or equal than 0 @@ -39,23 +40,17 @@ func MapUserTeamPermission(p team.PermissionType) legacyiamv0.TeamPermission { } } -// Resource is required to be implemented for list return types so we can -// perform authorization. -type Resource interface { - AuthID() string -} - -type ListResponse[T Resource] struct { +type ListResponse[T metav1.Object] struct { Items []T RV int64 Continue int64 } -type ListFunc[T Resource] func(ctx context.Context, ns authlib.NamespaceInfo, p Pagination) (*ListResponse[T], error) +type ListFunc[T metav1.Object] func(ctx context.Context, ns authlib.NamespaceInfo, p Pagination) (*ListResponse[T], error) // List is a helper function that will perform access check on resources if // prvovided with a authlib.AccessClient. -func List[T Resource]( +func List[T metav1.Object]( ctx context.Context, resource utils.ResourceInfo, ac authlib.AccessClient, @@ -78,7 +73,7 @@ func List[T Resource]( check, _, err = ac.Compile(ctx, ident, authlib.ListRequest{ Resource: resource.GroupResource().Resource, Group: resource.GroupResource().Group, - Verb: "list", + Verb: utils.VerbList, Namespace: ns.Value, }) @@ -95,7 +90,7 @@ func List[T Resource]( } for _, item := range first.Items { - if !check(item.AuthID(), "") { + if !check(item.GetName(), "") { continue } res.Items = append(res.Items, item) @@ -118,7 +113,7 @@ outer: break outer } - if !check(item.AuthID(), "") { + if !check(item.GetName(), "") { continue } diff --git a/pkg/registry/apis/iam/common/common_test.go b/pkg/registry/apis/iam/common/common_test.go index d835c238b8c..ea079043fd1 100644 --- a/pkg/registry/apis/iam/common/common_test.go +++ b/pkg/registry/apis/iam/common/common_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/endpoints/request" authlib "github.com/grafana/authlib/types" @@ -15,14 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" ) -type item struct { - id string -} - -func (i item) AuthID() string { - return i.id -} - func TestList(t *testing.T) { ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) @@ -83,8 +77,8 @@ func TestList(t *testing.T) { assert.NoError(t, err) assert.Len(t, res.Items, 2) - assert.Equal(t, "1", res.Items[0].AuthID()) - assert.Equal(t, "3", res.Items[1].AuthID()) + assert.Equal(t, "1", res.Items[0].GetName()) + assert.Equal(t, "3", res.Items[1].GetName()) }) } @@ -103,3 +97,158 @@ func newIdent(permissions ...accesscontrol.Permission) *identity.StaticRequester Permissions: map[int64]map[string][]string{1: pmap}, } } + +var _ metav1.Object = (*item)(nil) + +type item struct { + id string +} + +// GetAnnotations implements v1.Object. +func (i item) GetAnnotations() map[string]string { + panic("unimplemented") +} + +// GetCreationTimestamp implements v1.Object. +func (i item) GetCreationTimestamp() metav1.Time { + panic("unimplemented") +} + +// GetDeletionGracePeriodSeconds implements v1.Object. +func (i item) GetDeletionGracePeriodSeconds() *int64 { + panic("unimplemented") +} + +// GetDeletionTimestamp implements v1.Object. +func (i item) GetDeletionTimestamp() *metav1.Time { + panic("unimplemented") +} + +// GetFinalizers implements v1.Object. +func (i item) GetFinalizers() []string { + panic("unimplemented") +} + +// GetGenerateName implements v1.Object. +func (i item) GetGenerateName() string { + panic("unimplemented") +} + +// GetGeneration implements v1.Object. +func (i item) GetGeneration() int64 { + panic("unimplemented") +} + +// GetLabels implements v1.Object. +func (i item) GetLabels() map[string]string { + panic("unimplemented") +} + +// GetManagedFields implements v1.Object. +func (i item) GetManagedFields() []metav1.ManagedFieldsEntry { + panic("unimplemented") +} + +// GetNamespace implements v1.Object. +func (i item) GetNamespace() string { + panic("unimplemented") +} + +// GetOwnerReferences implements v1.Object. +func (i item) GetOwnerReferences() []metav1.OwnerReference { + panic("unimplemented") +} + +// GetResourceVersion implements v1.Object. +func (i item) GetResourceVersion() string { + panic("unimplemented") +} + +// GetSelfLink implements v1.Object. +func (i item) GetSelfLink() string { + panic("unimplemented") +} + +// GetUID implements v1.Object. +func (i item) GetUID() types.UID { + panic("unimplemented") +} + +// SetAnnotations implements v1.Object. +func (i item) SetAnnotations(annotations map[string]string) { + panic("unimplemented") +} + +// SetCreationTimestamp implements v1.Object. +func (i item) SetCreationTimestamp(timestamp metav1.Time) { + panic("unimplemented") +} + +// SetDeletionGracePeriodSeconds implements v1.Object. +func (i item) SetDeletionGracePeriodSeconds(*int64) { + panic("unimplemented") +} + +// SetDeletionTimestamp implements v1.Object. +func (i item) SetDeletionTimestamp(timestamp *metav1.Time) { + panic("unimplemented") +} + +// SetFinalizers implements v1.Object. +func (i item) SetFinalizers(finalizers []string) { + panic("unimplemented") +} + +// SetGenerateName implements v1.Object. +func (i item) SetGenerateName(name string) { + panic("unimplemented") +} + +// SetGeneration implements v1.Object. +func (i item) SetGeneration(generation int64) { + panic("unimplemented") +} + +// SetLabels implements v1.Object. +func (i item) SetLabels(labels map[string]string) { + panic("unimplemented") +} + +// SetManagedFields implements v1.Object. +func (i item) SetManagedFields(managedFields []metav1.ManagedFieldsEntry) { + panic("unimplemented") +} + +// SetName implements v1.Object. +func (i item) SetName(name string) { + panic("unimplemented") +} + +// SetNamespace implements v1.Object. +func (i item) SetNamespace(namespace string) { + panic("unimplemented") +} + +// SetOwnerReferences implements v1.Object. +func (i item) SetOwnerReferences([]metav1.OwnerReference) { + panic("unimplemented") +} + +// SetResourceVersion implements v1.Object. +func (i item) SetResourceVersion(version string) { + panic("unimplemented") +} + +// SetSelfLink implements v1.Object. +func (i item) SetSelfLink(selfLink string) { + panic("unimplemented") +} + +// SetUID implements v1.Object. +func (i item) SetUID(uid types.UID) { + panic("unimplemented") +} + +func (i item) GetName() string { + return i.id +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 8a3dcb9586b..7c9d8c558c4 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -102,7 +102,7 @@ func RegisterAPIService( store: store, userLegacyStore: user.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), saLegacyStore: serviceaccount.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), - legacyTeamStore: team.NewLegacyStore(store, legacyAccessClient, enableAuthnMutation, tracing), + legacyTeamStore: team.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), teamBindingLegacyStore: teambinding.NewLegacyBindingStore(store, enableAuthnMutation, tracing), ssoLegacyStore: sso.NewLegacyStore(ssoService, tracing), coreRolesStorage: coreRolesStorage, @@ -246,6 +246,8 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge //nolint:staticcheck // not yet migrated to OpenFeature enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync) + //nolint:staticcheck // not yet migrated to OpenFeature + enableAuthzApis := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) // teams + users must have shorter names because they are often used as part of another name opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{ @@ -255,6 +257,60 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge MaximumNameLength: 80, }) + if err := b.UpdateTeamsAPIGroup(opts, storage); err != nil { + return err + } + + if err := b.UpdateTeamBindingsAPIGroup(opts, storage, enableZanzanaSync); err != nil { + return err + } + + if err := b.UpdateUsersAPIGroup(opts, storage, enableZanzanaSync); err != nil { + return err + } + + if err := b.UpdateServiceAccountsAPIGroup(opts, storage); err != nil { + return err + } + + // SSO settings apis + if b.ssoLegacyStore != nil { + ssoResource := legacyiamv0.SSOSettingResourceInfo + storage[ssoResource.StoragePath()] = b.ssoLegacyStore + } + + if err := b.UpdateExternalGroupMappingAPIGroup(apiGroupInfo, opts, storage); err != nil { + return err + } + + if enableAuthzApis { + // v0alpha1 + if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + + // Role registration is delegated to the RoleApiInstaller + if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { + return err + } + + if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + //nolint:staticcheck // not yet migrated to OpenFeature + if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { + if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateTeamsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage) error { teamResource := iamv0.TeamResourceInfo teamUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamResource, opts.OptsGetter) if err != nil { @@ -271,11 +327,15 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[teamResource.StoragePath()] = dw } - storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) + storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store, b.accessClient) if b.teamGroupsHandler != nil { storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler } + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateTeamBindingsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage, enableZanzanaSync bool) error { teamBindingResource := iamv0.TeamBindingResourceInfo teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter) if err != nil { @@ -298,8 +358,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } storage[teamBindingResource.StoragePath()] = dw } + return nil +} - // User store registration +func (b *IdentityAccessManagementAPIBuilder) UpdateUsersAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage, enableZanzanaSync bool) error { userResource := iamv0.UserResourceInfo userUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, userResource, opts.OptsGetter) if err != nil { @@ -325,7 +387,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store) - // Service Accounts store registration + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateServiceAccountsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage) error { saResource := iamv0.ServiceAccountResourceInfo saUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, saResource, opts.OptsGetter) if err != nil { @@ -343,17 +408,17 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[saResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) - if b.ssoLegacyStore != nil { - ssoResource := legacyiamv0.SSOSettingResourceInfo - storage[ssoResource.StoragePath()] = b.ssoLegacyStore - } + return nil +} +func (b *IdentityAccessManagementAPIBuilder) UpdateExternalGroupMappingAPIGroup(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions, storage map[string]rest.Storage) error { extGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo extGroupMappingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, extGroupMappingResource, opts.OptsGetter) if err != nil { return err } - storage[extGroupMappingResource.StoragePath()] = extGroupMappingUniStore + + var extGroupMappingStore storewrapper.K8sStorage = extGroupMappingUniStore if b.externalGroupMappingStorage != nil { extGroupMappingLegacyStore, err := NewLocalStore(extGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage) @@ -365,50 +430,57 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } - storage[extGroupMappingResource.StoragePath()] = dw - } - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { - // v0alpha1 - coreRoleStore, err := NewLocalStore(iamv0.CoreRoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.coreRolesStorage) - if err != nil { - return err - } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") - h := NewRoleHooks(b.zClient, b.zTickets, b.logger) - coreRoleStore.AfterCreate = h.AfterRoleCreate - coreRoleStore.AfterDelete = h.AfterRoleDelete - coreRoleStore.BeginUpdate = h.BeginRoleUpdate - } - storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore - - // Role registration is delegated to the RoleApiInstaller - if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { - return err - } - - roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) - if err != nil { - return err - } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") - roleBindingStore.AfterCreate = b.AfterRoleBindingCreate - roleBindingStore.AfterDelete = b.AfterRoleBindingDelete - roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate - } - storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore - } - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { - if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { - return err + var ok bool + extGroupMappingStore, ok = dw.(storewrapper.K8sStorage) + if !ok { + return fmt.Errorf("expected storewrapper.K8sStorage, got %T", dw) } } - apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage + authzWrapper := storewrapper.New(extGroupMappingStore, iamauthorizer.NewExternalGroupMappingAuthorizer(b.accessClient)) + storage[extGroupMappingResource.StoragePath()] = authzWrapper + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateCoreRolesAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableZanzanaSync bool, +) error { + coreRoleStore, err := NewLocalStore(iamv0.CoreRoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.coreRolesStorage) + if err != nil { + return err + } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") + h := NewRoleHooks(b.zClient, b.zTickets, b.logger) + coreRoleStore.AfterCreate = h.AfterRoleCreate + coreRoleStore.AfterDelete = h.AfterRoleDelete + coreRoleStore.BeginUpdate = h.BeginRoleUpdate + } + storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateRoleBindingsAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableZanzanaSync bool, +) error { + roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) + if err != nil { + return err + } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") + roleBindingStore.AfterCreate = b.AfterRoleBindingCreate + roleBindingStore.AfterDelete = b.AfterRoleBindingDelete + roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate + } + storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore return nil } diff --git a/pkg/registry/apis/iam/serviceaccount/store.go b/pkg/registry/apis/iam/serviceaccount/store.go index db4709a4e2e..6d1ac774174 100644 --- a/pkg/registry/apis/iam/serviceaccount/store.go +++ b/pkg/registry/apis/iam/serviceaccount/store.go @@ -178,7 +178,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, resource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.ServiceAccount], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.ServiceAccount], error) { found, err := s.store.ListServiceAccounts(ctx, ns, legacy.ListServiceAccountsQuery{ Pagination: p, }) @@ -187,12 +187,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - items := make([]iamv0alpha1.ServiceAccount, 0, len(found.Items)) + items := make([]*iamv0alpha1.ServiceAccount, 0, len(found.Items)) for _, sa := range found.Items { - items = append(items, s.toSAItem(sa, ns.Value)) + saItem := s.toSAItem(sa, ns.Value) + items = append(items, &saItem) } - return &common.ListResponse[iamv0alpha1.ServiceAccount]{ + return &common.ListResponse[*iamv0alpha1.ServiceAccount]{ Items: items, RV: found.RV, Continue: found.Continue, @@ -204,7 +205,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - obj := &iamv0alpha1.ServiceAccountList{Items: res.Items} + items := make([]iamv0alpha1.ServiceAccount, len(res.Items)) + for i, sa := range res.Items { + items[i] = *sa + } + + obj := &iamv0alpha1.ServiceAccountList{Items: items} obj.Continue = common.OptionalFormatInt(res.Continue) obj.ResourceVersion = common.OptionalFormatInt(res.RV) return obj, nil diff --git a/pkg/registry/apis/iam/team/rest_members.go b/pkg/registry/apis/iam/team/rest_members.go index 6659823b9fa..586b074d710 100644 --- a/pkg/registry/apis/iam/team/rest_members.go +++ b/pkg/registry/apis/iam/team/rest_members.go @@ -2,14 +2,20 @@ package team import ( "context" + "fmt" "net/http" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" claims "github.com/grafana/authlib/types" + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "github.com/grafana/grafana/pkg/registry/apis/iam/common" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -23,12 +29,13 @@ var ( _ rest.Connecter = (*LegacyTeamMemberREST)(nil) ) -func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore) *LegacyTeamMemberREST { - return &LegacyTeamMemberREST{store} +func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyTeamMemberREST { + return &LegacyTeamMemberREST{store: store, ac: ac} } type LegacyTeamMemberREST struct { store legacy.LegacyIdentityStore + ac claims.AccessClient } // New implements rest.Storage. @@ -62,6 +69,30 @@ func (s *LegacyTeamMemberREST) Connect(ctx context.Context, name string, options } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ident, err := identity.GetRequester(ctx) + if err != nil { + responder.Error(err) + return + } + + checkResp, err := s.ac.Check(ctx, ident, claims.CheckRequest{ + Group: iamv0alpha1.TeamResourceInfo.GroupResource().Group, + Resource: iamv0alpha1.TeamResourceInfo.GroupResource().Resource, + Name: name, + Namespace: ns.Value, + Verb: utils.VerbGetPermissions, + }, "") + + if err != nil { + responder.Error(err) + return + } + + if !checkResp.Allowed { + responder.Error(apierrors.NewForbidden(iamv0alpha1.TeamResourceInfo.GroupResource(), name, fmt.Errorf("permission denied"))) + return + } + res, err := s.store.ListTeamMembers(ctx, ns, legacy.ListTeamMembersQuery{ UID: name, Pagination: common.PaginationFromListQuery(r.URL.Query()), diff --git a/pkg/registry/apis/iam/team/store.go b/pkg/registry/apis/iam/team/store.go index c667c8ec374..834bba00335 100644 --- a/pkg/registry/apis/iam/team/store.go +++ b/pkg/registry/apis/iam/team/store.go @@ -174,7 +174,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, resource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.Team], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.Team], error) { found, err := s.store.ListTeams(ctx, ns, legacy.ListTeamQuery{ Pagination: p, }) @@ -183,12 +183,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - teams := make([]iamv0alpha1.Team, 0, len(found.Teams)) + teams := make([]*iamv0alpha1.Team, 0, len(found.Teams)) for _, t := range found.Teams { - teams = append(teams, toTeamObject(t, ns)) + team := toTeamObject(t, ns) + teams = append(teams, &team) } - return &common.ListResponse[iamv0alpha1.Team]{ + return &common.ListResponse[*iamv0alpha1.Team]{ Items: teams, RV: found.RV, Continue: found.Continue, @@ -200,7 +201,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, fmt.Errorf("failed to list teams: %w", err) } - list := &iamv0alpha1.TeamList{Items: res.Items} + items := make([]iamv0alpha1.Team, len(res.Items)) + for i, t := range res.Items { + items[i] = *t + } + + list := &iamv0alpha1.TeamList{Items: items} list.Continue = common.OptionalFormatInt(res.Continue) list.ResourceVersion = common.OptionalFormatInt(res.RV) diff --git a/pkg/registry/apis/iam/user/store.go b/pkg/registry/apis/iam/user/store.go index 53a146412ea..5cc9343111a 100644 --- a/pkg/registry/apis/iam/user/store.go +++ b/pkg/registry/apis/iam/user/store.go @@ -183,7 +183,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, userResource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.User], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.User], error) { found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{ Pagination: p, }) @@ -192,12 +192,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - users := make([]iamv0alpha1.User, 0, len(found.Items)) + users := make([]*iamv0alpha1.User, 0, len(found.Items)) for _, u := range found.Items { - users = append(users, toUserItem(&u, ns.Value)) + user := toUserItem(&u, ns.Value) + users = append(users, &user) } - return &common.ListResponse[iamv0alpha1.User]{ + return &common.ListResponse[*iamv0alpha1.User]{ Items: users, RV: found.RV, Continue: found.Continue, @@ -209,7 +210,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - obj := &iamv0alpha1.UserList{Items: res.Items} + items := make([]iamv0alpha1.User, len(res.Items)) + for i, u := range res.Items { + items[i] = *u + } + + obj := &iamv0alpha1.UserList{Items: items} obj.Continue = common.OptionalFormatInt(res.Continue) obj.ResourceVersion = common.OptionalFormatInt(res.RV) return obj, nil diff --git a/pkg/registry/apis/preferences/legacy/preferences.go b/pkg/registry/apis/preferences/legacy/preferences.go index ab7eb2d4891..6f0210fa694 100644 --- a/pkg/registry/apis/preferences/legacy/preferences.go +++ b/pkg/registry/apis/preferences/legacy/preferences.go @@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti // Create implements rest.Creater. func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, err + } + } return s.save(ctx, obj) } @@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res return nil, false, err } + if updateValidation != nil { + if err := updateValidation(ctx, obj, old); err != nil { + return nil, false, err + } + } + obj, err = s.save(ctx, obj) return obj, false, err } diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index e0e6ff947fc..59e6a0b9c05 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -1,9 +1,14 @@ package preferences import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" @@ -24,7 +29,8 @@ import ( ) var ( - _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupValidation = (*APIBuilder)(nil) ) type APIBuilder struct { @@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) return b.merger.GetAPIRoutes(defs) } + +// Validate validates that the preference object has valid theme and timezone (if specified) +func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { + if a.GetResource().Resource != "preferences" { + return nil + } + + op := a.GetOperation() + if op != admission.Create && op != admission.Update { + return nil + } + + obj := a.GetObject() + p, ok := obj.(*preferences.Preferences) + if !ok { + return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj)) + } + + if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) { + return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string") + } + + if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) { + return apierrors.NewBadRequest("invalid theme") + } + + return nil +} diff --git a/pkg/registry/apis/provisioning/connection_repositories.go b/pkg/registry/apis/provisioning/connection_repositories.go new file mode 100644 index 00000000000..c2e908fc685 --- /dev/null +++ b/pkg/registry/apis/provisioning/connection_repositories.go @@ -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) +) diff --git a/pkg/registry/apis/provisioning/connection_repositories_test.go b/pkg/registry/apis/provisioning/connection_repositories_test.go new file mode 100644 index 00000000000..792eb52bb64 --- /dev/null +++ b/pkg/registry/apis/provisioning/connection_repositories_test.go @@ -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 +} diff --git a/pkg/registry/apis/provisioning/controller/connection.go b/pkg/registry/apis/provisioning/controller/connection.go new file mode 100644 index 00000000000..be90908bd49 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/connection.go @@ -0,0 +1,254 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1" + listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1" +) + +const connectionLoggerName = "provisioning-connection-controller" + +const ( + connectionMaxAttempts = 3 + // connectionHealthyDuration defines how recent a health check must be to be considered "recent" when healthy + connectionHealthyDuration = 5 * time.Minute + // connectionUnhealthyDuration defines how recent a health check must be to be considered "recent" when unhealthy + connectionUnhealthyDuration = 1 * time.Minute +) + +type connectionQueueItem struct { + key string + attempts int +} + +// ConnectionStatusPatcher defines the interface for updating connection status. +// +//go:generate mockery --name=ConnectionStatusPatcher +type ConnectionStatusPatcher interface { + Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error +} + +// ConnectionController controls Connection resources. +type ConnectionController struct { + client client.ProvisioningV0alpha1Interface + connLister listers.ConnectionLister + connSynced cache.InformerSynced + logger logging.Logger + + statusPatcher ConnectionStatusPatcher + + queue workqueue.TypedRateLimitingInterface[*connectionQueueItem] +} + +// NewConnectionController creates a new ConnectionController. +func NewConnectionController( + provisioningClient client.ProvisioningV0alpha1Interface, + connInformer informer.ConnectionInformer, + statusPatcher ConnectionStatusPatcher, +) (*ConnectionController, error) { + cc := &ConnectionController{ + client: provisioningClient, + connLister: connInformer.Lister(), + connSynced: connInformer.Informer().HasSynced, + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[*connectionQueueItem](), + workqueue.TypedRateLimitingQueueConfig[*connectionQueueItem]{ + Name: "provisioningConnectionController", + }, + ), + statusPatcher: statusPatcher, + logger: logging.DefaultLogger.With("logger", connectionLoggerName), + } + + _, err := connInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: cc.enqueue, + UpdateFunc: func(oldObj, newObj interface{}) { + cc.enqueue(newObj) + }, + }) + if err != nil { + return nil, err + } + + return cc, nil +} + +func (cc *ConnectionController) enqueue(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + cc.logger.Error("failed to get key for object", "error", err) + return + } + cc.queue.Add(&connectionQueueItem{key: key}) +} + +// Run starts the ConnectionController. +func (cc *ConnectionController) Run(ctx context.Context, workerCount int) { + defer utilruntime.HandleCrash() + defer cc.queue.ShutDown() + + cc.logger.Info("starting connection controller", "workers", workerCount) + + for i := 0; i < workerCount; i++ { + go wait.UntilWithContext(ctx, cc.runWorker, time.Second) + } + + <-ctx.Done() + cc.logger.Info("shutting down connection controller") +} + +func (cc *ConnectionController) runWorker(ctx context.Context) { + for cc.processNextWorkItem(ctx) { + } +} + +func (cc *ConnectionController) processNextWorkItem(ctx context.Context) bool { + item, quit := cc.queue.Get() + if quit { + return false + } + defer cc.queue.Done(item) + + logger := logging.FromContext(ctx).With("work_key", item.key) + logger.Info("ConnectionController processing key") + + err := cc.process(ctx, item) + if err == nil { + cc.queue.Forget(item) + return true + } + + item.attempts++ + logger = logger.With("error", err, "attempts", item.attempts) + logger.Error("ConnectionController failed to process key") + + if item.attempts >= connectionMaxAttempts { + logger.Error("ConnectionController failed too many times") + cc.queue.Forget(item) + return true + } + + if !apierrors.IsServiceUnavailable(err) { + logger.Info("ConnectionController will not retry") + cc.queue.Forget(item) + return true + } + + logger.Info("ConnectionController will retry as service is unavailable") + utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err)) + cc.queue.AddRateLimited(item) + + return true +} + +func (cc *ConnectionController) process(ctx context.Context, item *connectionQueueItem) error { + logger := cc.logger.With("key", item.key) + ctx = logging.Context(ctx, logger) + + namespace, name, err := cache.SplitMetaNamespaceKey(item.key) + if err != nil { + return err + } + + conn, err := cc.connLister.Connections(namespace).Get(name) + switch { + case apierrors.IsNotFound(err): + return errors.New("connection not found in cache") + case err != nil: + return err + } + + // Skip if being deleted + if conn.DeletionTimestamp != nil { + logger.Info("connection is being deleted, skipping") + return nil + } + + hasSpecChanged := conn.Generation != conn.Status.ObservedGeneration + shouldCheckHealth := cc.shouldCheckHealth(conn) + + // Determine the main triggering condition + switch { + case hasSpecChanged: + logger.Info("spec changed, reconciling", "generation", conn.Generation, "observedGeneration", conn.Status.ObservedGeneration) + case shouldCheckHealth: + logger.Info("health is stale, refreshing", "lastChecked", conn.Status.Health.Checked, "healthy", conn.Status.Health.Healthy) + default: + logger.Debug("skipping as conditions are not met", "generation", conn.Generation, "observedGeneration", conn.Status.ObservedGeneration) + return nil + } + + // For now, just update the state to connected, health to healthy, and observed generation + // Future: Add credential validation logic here + patchOperations := []map[string]interface{}{} + + // Only update observedGeneration when spec changes + if hasSpecChanged { + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/observedGeneration", + "value": conn.Generation, + }) + } + + // Always update state and health + patchOperations = append(patchOperations, + map[string]interface{}{ + "op": "replace", + "path": "/status/state", + "value": provisioning.ConnectionStateConnected, + }, + map[string]interface{}{ + "op": "replace", + "path": "/status/health", + "value": provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + ) + + if err := cc.statusPatcher.Patch(ctx, conn, patchOperations...); err != nil { + return fmt.Errorf("failed to update connection status: %w", err) + } + + logger.Info("connection reconciled successfully") + return nil +} + +// shouldCheckHealth determines if a connection health check should be performed. +func (cc *ConnectionController) shouldCheckHealth(conn *provisioning.Connection) bool { + // If the connection has been updated, always check health + if conn.Generation != conn.Status.ObservedGeneration { + return true + } + + // Check if health check is stale + return !cc.hasRecentHealthCheck(conn.Status.Health) +} + +// hasRecentHealthCheck checks if a health check was performed recently. +func (cc *ConnectionController) hasRecentHealthCheck(healthStatus provisioning.HealthStatus) bool { + if healthStatus.Checked == 0 { + return false // Never checked + } + + age := time.Since(time.UnixMilli(healthStatus.Checked)) + if healthStatus.Healthy { + return age <= connectionHealthyDuration + } + return age <= connectionUnhealthyDuration +} diff --git a/pkg/registry/apis/provisioning/controller/connection_test.go b/pkg/registry/apis/provisioning/controller/connection_test.go new file mode 100644 index 00000000000..b033ddb39a9 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/connection_test.go @@ -0,0 +1,287 @@ +package controller + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestConnectionController_shouldCheckHealth(t *testing.T) { + testCases := []struct { + name string + conn *provisioning.Connection + expected bool + }{ + { + name: "should check health when generation differs from observed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 2, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + }, + }, + expected: true, + }, + { + name: "should check health when never checked before", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Checked: 0, + }, + }, + }, + expected: true, + }, + { + name: "should check health when healthy check is stale (>5 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-6 * time.Minute).UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should check health when unhealthy check is stale (>1 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should not check health when healthy check is recent (<5 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + expected: false, + }, + { + name: "should not check health when unhealthy check is recent (<1 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-30 * time.Second).UnixMilli(), + }, + }, + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + result := cc.shouldCheckHealth(tc.conn) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConnectionController_hasRecentHealthCheck(t *testing.T) { + testCases := []struct { + name string + healthStatus provisioning.HealthStatus + expected bool + }{ + { + name: "never checked", + healthStatus: provisioning.HealthStatus{ + Checked: 0, + }, + expected: false, + }, + { + name: "healthy and recent", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + expected: true, + }, + { + name: "healthy and stale", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + expected: false, + }, + { + name: "unhealthy and recent", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-30 * time.Second).UnixMilli(), + }, + expected: true, + }, + { + name: "unhealthy and stale", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + result := cc.hasRecentHealthCheck(tc.healthStatus) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConnectionController_reconcileConditions(t *testing.T) { + testCases := []struct { + name string + conn *provisioning.Connection + expectReconcile bool + expectSpecChanged bool + description string + }{ + { + name: "skip when being deleted", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + }, + expectReconcile: false, + expectSpecChanged: false, + description: "deleted connections should be skipped", + }, + { + name: "skip when no changes needed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expectReconcile: false, + expectSpecChanged: false, + description: "no reconcile when generation matches and health is recent", + }, + { + name: "reconcile when spec changed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 2, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expectReconcile: true, + expectSpecChanged: true, + description: "reconcile when generation differs", + }, + { + name: "reconcile when health is stale", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + }, + }, + expectReconcile: true, + expectSpecChanged: false, + description: "reconcile when health check is stale", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + + // Test the core reconciliation conditions + if tc.conn.DeletionTimestamp != nil { + assert.False(t, tc.expectReconcile, tc.description) + return + } + + hasSpecChanged := tc.conn.Generation != tc.conn.Status.ObservedGeneration + shouldCheckHealth := cc.shouldCheckHealth(tc.conn) + + needsReconcile := hasSpecChanged || shouldCheckHealth + + assert.Equal(t, tc.expectReconcile, needsReconcile, tc.description) + assert.Equal(t, tc.expectSpecChanged, hasSpecChanged, "spec changed check") + }) + } +} + +func TestConnectionController_processNextWorkItem(t *testing.T) { + t.Run("returns false when queue is shut down", func(t *testing.T) { + cc := &ConnectionController{} + // This test verifies the structure is correct + assert.NotNil(t, cc) + }) +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 26e97f56b3c..026797eb474 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -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)) @@ -806,8 +817,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH sharedInformerFactory := informers.NewSharedInformerFactory(c, 60*time.Second) repoInformer := sharedInformerFactory.Provisioning().V0alpha1().Repositories() jobInformer := sharedInformerFactory.Provisioning().V0alpha1().Jobs() + connInformer := sharedInformerFactory.Provisioning().V0alpha1().Connections() go repoInformer.Informer().Run(postStartHookCtx.Done()) go jobInformer.Informer().Run(postStartHookCtx.Done()) + go connInformer.Informer().Run(postStartHookCtx.Done()) // Create the repository resources factory repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) { @@ -928,6 +941,18 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH go repoController.Run(postStartHookCtx.Context, repoControllerWorkers) + // Create and run connection controller + connStatusPatcher := appcontroller.NewConnectionStatusPatcher(b.GetClient()) + connController, err := controller.NewConnectionController( + b.GetClient(), + connInformer, + connStatusPatcher, + ) + if err != nil { + return err + } + go connController.Run(postStartHookCtx.Context, repoControllerWorkers) + // If Loki not used, initialize the API client-based history writer and start the controller for history jobs if b.jobHistoryLoki == nil { // Create HistoryJobController for cleanup of old job history entries @@ -1247,6 +1272,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 { diff --git a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go index 84c0fe9f7fd..f8f663a8853 100644 --- a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" ) var ( @@ -25,7 +24,7 @@ var ( ) type ReceiverService interface { - GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error) + GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error) GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error) CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) @@ -116,22 +115,12 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption return nil, err } - name, err := legacy_storage.UidToName(uid) - if err != nil { - return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid) - } - q := ngmodels.GetReceiverQuery{ - OrgID: info.OrgID, - Name: name, - Decrypt: false, - } - user, err := identity.GetRequester(ctx) if err != nil { return nil, err } - r, err := s.service.GetReceiver(ctx, q, user) + r, err := s.service.GetReceiver(ctx, uid, false, user) if err != nil { return nil, err } diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index a1ec8aafd65..dc52f4658e0 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -107,7 +106,6 @@ func ProvideBuilderRunners( registrar builder.APIRegistrar, restConfigProvider apiserver.RestConfigProvider, features featuremgmt.FeatureToggles, - investigationAppProvider *investigations.InvestigationsAppProvider, grafanaCfg *setting.Cfg, ) (*Service, error) { cfgWrapper := func(ctx context.Context) (*rest.Config, error) { @@ -127,11 +125,6 @@ func ProvideBuilderRunners( var apiGroupRunner *runner.APIGroupRunner var err error providers := []app.Provider{} - //nolint:staticcheck // not yet migrated to OpenFeature - if features.IsEnabledGlobally(featuremgmt.FlagInvestigationsBackend) { - logger.Debug("Investigations backend is enabled") - providers = append(providers, investigationAppProvider) - } apiGroupRunner, err = runner.NewAPIGroupRunner(cfg, providers...) if err != nil { diff --git a/pkg/registry/apps/investigations/authorizer.go b/pkg/registry/apps/investigations/authorizer.go deleted file mode 100644 index 3178f3d2f9c..00000000000 --- a/pkg/registry/apps/investigations/authorizer.go +++ /dev/null @@ -1,38 +0,0 @@ -package investigations - -import ( - "context" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/accesscontrol" - - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func GetAuthorizer() authorizer.Authorizer { - return authorizer.AuthorizerFunc(func( - ctx context.Context, attr authorizer.Attributes, - ) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - u, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "valid user is required", err - } - - p := u.GetPermissions() - if len(p) == 0 { - return authorizer.DecisionDeny, "no permissions", nil - } - - _, ok := p[accesscontrol.ActionDatasourcesExplore] - if !ok { - // defer to the default authorizer if datasources:explore is not present - return authorizer.DecisionNoOpinion, "", nil - } - - return authorizer.DecisionAllow, "", nil - }) -} diff --git a/pkg/registry/apps/investigations/authorizer_test.go b/pkg/registry/apps/investigations/authorizer_test.go deleted file mode 100644 index c6d870bc7e7..00000000000 --- a/pkg/registry/apps/investigations/authorizer_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package investigations - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/stretchr/testify/assert" - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func TestGetAuthorizer(t *testing.T) { - tests := []struct { - name string - ctx context.Context - attr authorizer.Attributes - expectedDecision authorizer.Decision - expectedReason string - expectedErr error - }{ - { - name: "non-resource request", - ctx: context.TODO(), - attr: &mockAttributes{resourceRequest: false}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user has datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{permissions: map[string][]string{accesscontrol.ActionDatasourcesExplore: {}}}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionAllow, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user does not have datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionDeny, - expectedReason: "no permissions", - expectedErr: nil, - }, - { - name: "user does not have datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{permissions: map[string][]string{"foo": {}}}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - auth := GetAuthorizer() - decision, reason, err := auth.Authorize(tt.ctx, tt.attr) - assert.Equal(t, tt.expectedDecision, decision) - assert.Equal(t, tt.expectedReason, reason) - assert.Equal(t, tt.expectedErr, err) - }) - } -} - -type mockAttributes struct { - authorizer.Attributes - resourceRequest bool -} - -func (m *mockAttributes) IsResourceRequest() bool { - return m.resourceRequest -} - -// Implement other methods of authorizer.Attributes as needed - -type mockUser struct { - identity.Requester - permissions map[string][]string -} - -func (m *mockUser) GetPermissions() map[string][]string { - return m.permissions -} - -// Implement other methods of identity.Requester as needed diff --git a/pkg/registry/apps/investigations/register.go b/pkg/registry/apps/investigations/register.go deleted file mode 100644 index 6fbf2603491..00000000000 --- a/pkg/registry/apps/investigations/register.go +++ /dev/null @@ -1,33 +0,0 @@ -package investigations - -import ( - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/simple" - "github.com/grafana/grafana/apps/investigations/pkg/apis" - investigationv0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" - investigationapp "github.com/grafana/grafana/apps/investigations/pkg/app" - "github.com/grafana/grafana/pkg/services/apiserver/builder" - "github.com/grafana/grafana/pkg/services/apiserver/builder/runner" - "github.com/grafana/grafana/pkg/setting" -) - -type InvestigationsAppProvider struct { - app.Provider - cfg *setting.Cfg -} - -func RegisterApp( - cfg *setting.Cfg, -) *InvestigationsAppProvider { - provider := &InvestigationsAppProvider{ - cfg: cfg, - } - appCfg := &runner.AppBuilderConfig{ - OpenAPIDefGetter: investigationv0alpha1.GetOpenAPIDefinitions, - ManagedKinds: investigationapp.GetKinds(), - Authorizer: GetAuthorizer(), - AllowedV0Alpha1Resources: []string{builder.AllResourcesAllowed}, - } - provider.Provider = simple.NewAppProvider(apis.LocalManifest(), appCfg, investigationapp.New) - return provider -} diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index 68082f59713..d29da1eb6c4 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -36,9 +37,13 @@ func ProvideAppInstaller( pluginStore pluginstore.Store, pluginAssetsService *pluginassets.Service, accessControlService accesscontrol.Service, accessClient authlib.AccessClient, + features featuremgmt.FeatureToggles, ) (*AppInstaller, error) { - if err := registerAccessControlRoles(accessControlService); err != nil { - return nil, fmt.Errorf("registering access control roles: %w", err) + //nolint:staticcheck // not yet migrated to OpenFeature + if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) { + if err := registerAccessControlRoles(accessControlService); err != nil { + return nil, fmt.Errorf("registering access control roles: %w", err) + } } localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService) diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index a2cef7b2d55..d2d4feb2b5c 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -21,7 +20,6 @@ var WireSet = wire.NewSet( ProvideAppInstallers, ProvideBuilderRunners, playlist.RegisterAppInstaller, - investigations.RegisterApp, plugins.ProvideAppInstaller, shorturl.RegisterAppInstaller, correlations.RegisterAppInstaller, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 9864bed4e12..141079b1513 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -330,6 +330,7 @@ var wireBasicSet = wire.NewSet( dashsnapstore.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)), dashsnapsvc.ProvideService, + datasourceservice.ProvideDataSourceRetriever, datasourceservice.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)), datasourceservice.ProvideLegacyDataSourceLookup, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 1cb7301fb36..65e6ebeb36e 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -84,7 +84,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/annotation" correlations2 "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -639,7 +638,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api datasourcePermissionsService := ossaccesscontrol.ProvideDatasourcePermissionsService(cfg, featureToggles, sqlStore) requestConfigProvider := pluginconfig.NewRequestConfigProvider(pluginInstanceCfg, defaultSettingsProvider) baseProvider := plugincontext.ProvideBaseService(cfg, requestConfigProvider) - service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) + dataSourceRetriever := service9.ProvideDataSourceRetriever(sqlStore, featureToggles) + service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider, dataSourceRetriever) if err != nil { return nil, err } @@ -785,7 +785,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient) + appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles) if err != nil { return nil, err } @@ -847,8 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) - investigationsAppProvider := investigations.RegisterApp(cfg) - appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) + appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, cfg) if err != nil { return nil, err } @@ -1295,7 +1294,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac datasourcePermissionsService := ossaccesscontrol.ProvideDatasourcePermissionsService(cfg, featureToggles, sqlStore) requestConfigProvider := pluginconfig.NewRequestConfigProvider(pluginInstanceCfg, defaultSettingsProvider) baseProvider := plugincontext.ProvideBaseService(cfg, requestConfigProvider) - service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) + dataSourceRetriever := service9.ProvideDataSourceRetriever(sqlStore, featureToggles) + service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider, dataSourceRetriever) if err != nil { return nil, err } @@ -1447,7 +1447,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient) + appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles) if err != nil { return nil, err } @@ -1509,8 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) - investigationsAppProvider := investigations.RegisterApp(cfg) - appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) + appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, cfg) if err != nil { return nil, err } @@ -1801,7 +1800,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideDataSourceRetriever, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index 28ebd1edb02..87d00f0224b 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -4,6 +4,8 @@ import ( "context" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" @@ -435,6 +437,11 @@ func anonymousRoleBindingsCollector(cfg *setting.Cfg, store db.DB) legacyTupleCo func zanzanaCollector(relations []string) zanzanaTupleCollector { return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.zanzanaTupleCollector", + trace.WithAttributes(attribute.String("namespace", namespace)), + ) + defer span.End() + // list will use continuation token to collect all tuples for object and relation list := func(relation string) ([]*openfgav1.Tuple, error) { first, err := client.Read(ctx, &authzextv1.ReadRequest{ diff --git a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go index 0adf365ebde..c51e6a771c7 100644 --- a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go @@ -6,6 +6,8 @@ import ( "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" claims "github.com/grafana/authlib/types" @@ -48,6 +50,12 @@ func newResourceReconciler(name string, legacy legacyTupleCollector, zanzanaColl } func (r resourceReconciler) reconcile(ctx context.Context, namespace string) error { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.reconcile", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + defer span.End() + info, err := claims.ParseNamespace(namespace) if err != nil { return err @@ -63,7 +71,12 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err } // 1. Fetch grafana resources stored in grafana db. - res, err := r.legacy(ctx, info.OrgID) + legacyCtx, legacySpan := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.legacyCollector", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + res, err := r.legacy(legacyCtx, info.OrgID) + legacySpan.End() if err != nil { return fmt.Errorf("failed to collect legacy tuples for %s: %w", r.name, err) } @@ -211,6 +224,12 @@ func (r resourceReconciler) collectOrphanDeletes( } func (r resourceReconciler) readAllTuples(ctx context.Context, namespace string) ([]*authzextv1.Tuple, error) { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.zanzana.readAllTuples", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + defer span.End() + var ( out []*authzextv1.Tuple continueToken string diff --git a/pkg/services/apiserver/auth/authorizer/authorizer.go b/pkg/services/apiserver/auth/authorizer/authorizer.go index dab8167deb0..b0dcbbc7872 100644 --- a/pkg/services/apiserver/auth/authorizer/authorizer.go +++ b/pkg/services/apiserver/auth/authorizer/authorizer.go @@ -3,7 +3,6 @@ package authorizer import ( "context" - "github.com/grafana/grafana/pkg/setting" "k8s.io/apimachinery/pkg/runtime/schema" k8suser "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" @@ -29,9 +28,9 @@ type GrafanaAuthorizer struct { // 4. We check authorizer that is configured speficially for an api. // 5. As a last fallback we check Role, this will only happen if an api have not configured // an authorizer or return authorizer.DecisionNoOpinion -func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer { +func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer { authorizers := []authorizer.Authorizer{ - newImpersonationAuthorizer(), + NewImpersonationAuthorizer(), authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup), newNamespaceAuthorizer(), } diff --git a/pkg/services/apiserver/auth/authorizer/impersonation.go b/pkg/services/apiserver/auth/authorizer/impersonation.go index c736173bd5f..067d364ef56 100644 --- a/pkg/services/apiserver/auth/authorizer/impersonation.go +++ b/pkg/services/apiserver/auth/authorizer/impersonation.go @@ -8,7 +8,7 @@ import ( var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil) -func newImpersonationAuthorizer() *impersonationAuthorizer { +func NewImpersonationAuthorizer() *impersonationAuthorizer { return &impersonationAuthorizer{} } diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index c535443a91e..774f194e2a8 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -76,19 +76,7 @@ var PathRewriters = []filters.PathRewriter{ func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc { return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { - requestHandler, err := GetCustomRoutesHandler( - delegateHandler, - c.LoopbackClientConfig, - builders, - reg, - c.MergedResourceConfig, - ) - if err != nil { - panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error())) - } - - // Needs to run last in request chain to function as expected, hence we register it first. - handler := filters.WithTracingHTTPLoggingAttributes(requestHandler) + handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler) // filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context handler = filters.WithRequester(handler) diff --git a/pkg/services/apiserver/builder/request_handler.go b/pkg/services/apiserver/builder/request_handler.go index 50761f1d42c..4e5a355f453 100644 --- a/pkg/services/apiserver/builder/request_handler.go +++ b/pkg/services/apiserver/builder/request_handler.go @@ -3,146 +3,306 @@ package builder import ( "fmt" "net/http" + "strings" + "github.com/emicklei/go-restful/v3" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" serverstorage "k8s.io/apiserver/pkg/server/storage" - restclient "k8s.io/client-go/rest" klog "k8s.io/klog/v2" "k8s.io/kube-openapi/pkg/spec3" ) -type requestHandler struct { - router *mux.Router +// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction +// It extracts path parameters from restful.Request and populates them in the request context +// so that mux.Vars can read them (for backward compatibility with handlers that use mux.Vars) +func convertHandlerToRouteFunction(handler http.HandlerFunc) restful.RouteFunction { + return func(req *restful.Request, resp *restful.Response) { + // Extract path parameters from restful.Request and populate mux.Vars + // This is needed for backward compatibility with handlers that use mux.Vars(r) + vars := make(map[string]string) + + // Get all path parameters from the restful.Request + // The restful.Request has PathParameters() method that returns a map + pathParams := req.PathParameters() + for key, value := range pathParams { + vars[key] = value + } + + // Set the vars in the request context using mux.SetURLVars + // This makes mux.Vars(r) work correctly + if len(vars) > 0 { + req.Request = mux.SetURLVars(req.Request, vars) + } + + handler(resp.ResponseWriter, req.Request) + } } -func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) { - useful := false // only true if any routes exist anywhere - router := mux.NewRouter() +// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices +// in the container. +func AugmentWebServicesWithCustomRoutes( + container *restful.Container, + builders []APIGroupBuilder, + metricsRegistry prometheus.Registerer, + apiResourceConfig *serverstorage.ResourceConfig, +) error { + if container == nil { + return fmt.Errorf("container cannot be nil") + } metrics := NewCustomRouteMetrics(metricsRegistry) - for _, builder := range builders { - provider, ok := builder.(APIGroupRouteProvider) + // Build a map of existing WebServices by root path + existingWebServices := make(map[string]*restful.WebService) + for _, ws := range container.RegisteredWebServices() { + existingWebServices[ws.RootPath()] = ws + } + + for _, b := range builders { + provider, ok := b.(APIGroupRouteProvider) if !ok || provider == nil { continue } - for _, gv := range GetGroupVersions(builder) { - // filter out api groups that are disabled in APIEnablementOptions + for _, gv := range GetGroupVersions(b) { + // Filter out disabled API groups gvr := gv.WithResource("") if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) { - klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String()) + klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String()) continue } + routes := provider.GetAPIRoutes(gv) if routes == nil { continue } - prefix := "/apis/" + gv.String() - - // Root handlers - var sub *mux.Router - for _, route := range routes.Root { - if sub == nil { - sub = router.PathPrefix(prefix).Subrouter() - sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} - } - - useful = true - methods, err := methodsFromSpec(route.Path, route.Spec) - if err != nil { - return nil, err - } - - instrumentedHandler := metrics.InstrumentHandler( - gv.Group, - gv.Version, - route.Path, // Use path as resource identifier - route.Handler, - ) - - sub.HandleFunc("/"+route.Path, instrumentedHandler). - Methods(methods...) + // Find or create WebService for this group version + rootPath := "/apis/" + gv.String() + ws, exists := existingWebServices[rootPath] + if !exists { + // Create a new WebService if one doesn't exist + ws = new(restful.WebService) + ws.Path(rootPath) + container.Add(ws) + existingWebServices[rootPath] = ws } - // Namespace handlers - sub = nil - prefix += "/namespaces/{namespace}" - for _, route := range routes.Namespace { - if sub == nil { - sub = router.PathPrefix(prefix).Subrouter() - sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} - } - - useful = true - methods, err := methodsFromSpec(route.Path, route.Spec) - if err != nil { - return nil, err - } - + // Add root handlers using OpenAPI specs + for _, route := range routes.Root { instrumentedHandler := metrics.InstrumentHandler( gv.Group, gv.Version, - route.Path, // Use path as resource identifier + route.Path, route.Handler, ) + routeFunction := convertHandlerToRouteFunction(instrumentedHandler) - sub.HandleFunc("/"+route.Path, instrumentedHandler). - Methods(methods...) + // Use OpenAPI spec to configure routes properly + if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil { + return fmt.Errorf("failed to add root route %s: %w", route.Path, err) + } + } + + // Add namespace handlers using OpenAPI specs + for _, route := range routes.Namespace { + instrumentedHandler := metrics.InstrumentHandler( + gv.Group, + gv.Version, + route.Path, + route.Handler, + ) + routeFunction := convertHandlerToRouteFunction(instrumentedHandler) + + // Use OpenAPI spec to configure routes properly + if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil { + return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err) + } } } } - if !useful { - return delegateHandler, nil - } - - // Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509 - // default handler must come last - router.PathPrefix("/").Handler(delegateHandler) - - return &requestHandler{ - router: router, - }, nil + return nil } -func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - h.router.ServeHTTP(w, req) +// addRouteFromSpec adds routes to a WebService using OpenAPI specs +func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error { + if pathProps == nil { + return fmt.Errorf("pathProps cannot be nil for route %s", routePath) + } + + // Build the full path (relative to WebService root) + var fullPath string + if isNamespaced { + fullPath = "/namespaces/{namespace}/" + routePath + } else { + fullPath = "/" + routePath + } + + // Add routes for each HTTP method defined in the OpenAPI spec + operations := map[string]*spec3.Operation{ + "GET": pathProps.Get, + "POST": pathProps.Post, + "PUT": pathProps.Put, + "PATCH": pathProps.Patch, + "DELETE": pathProps.Delete, + } + + for method, operation := range operations { + if operation == nil { + continue + } + + // Create route builder for this method + var routeBuilder *restful.RouteBuilder + switch method { + case "GET": + routeBuilder = ws.GET(fullPath) + case "POST": + routeBuilder = ws.POST(fullPath) + case "PUT": + routeBuilder = ws.PUT(fullPath) + case "PATCH": + routeBuilder = ws.PATCH(fullPath) + case "DELETE": + routeBuilder = ws.DELETE(fullPath) + } + + // Set operation ID from OpenAPI spec (with K8s verb prefix if needed) + operationID := operation.OperationId + if operationID == "" { + // Generate from path if not specified + operationID = generateOperationNameFromPath(routePath) + } + operationID = prefixRouteIDWithK8sVerbIfNotPresent(operationID, method) + routeBuilder = routeBuilder.Operation(operationID) + + // Add description from OpenAPI spec + if operation.Description != "" { + routeBuilder = routeBuilder.Doc(operation.Description) + } + + // Check if namespace parameter is already in the OpenAPI spec + hasNamespaceParam := false + if operation.Parameters != nil { + for _, param := range operation.Parameters { + if param.Name == "namespace" && param.In == "path" { + hasNamespaceParam = true + break + } + } + } + + // Add namespace parameter for namespaced routes if not already in spec + if isNamespaced && !hasNamespaceParam { + routeBuilder = routeBuilder.Param(restful.PathParameter("namespace", "object name and auth scope, such as for teams and projects")) + } + + // Add parameters from OpenAPI spec + if operation.Parameters != nil { + for _, param := range operation.Parameters { + switch param.In { + case "path": + routeBuilder = routeBuilder.Param(restful.PathParameter(param.Name, param.Description)) + case "query": + routeBuilder = routeBuilder.Param(restful.QueryParameter(param.Name, param.Description)) + case "header": + routeBuilder = routeBuilder.Param(restful.HeaderParameter(param.Name, param.Description)) + } + } + } + + // Note: Request/response schemas are already defined in the OpenAPI spec from builders + // and will be added to the OpenAPI document via addBuilderRoutes in openapi.go. + // We don't duplicate that information here since restful uses the route metadata + // for OpenAPI generation, which is handled separately in this codebase. + + // Register the route with handler + ws.Route(routeBuilder.To(handler)) + } + + return nil } -func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) { - if props == nil { - return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil +func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string { + for _, verb := range allowedK8sVerbs { + if len(operationID) > len(verb) && operationID[:len(verb)] == verb { + return operationID + } } - - methods := make([]string, 0) - if props.Get != nil { - methods = append(methods, "GET") - } - if props.Post != nil { - methods = append(methods, "POST") - } - if props.Put != nil { - methods = append(methods, "PUT") - } - if props.Patch != nil { - methods = append(methods, "PATCH") - } - if props.Delete != nil { - methods = append(methods, "DELETE") - } - - if len(methods) == 0 { - return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug) - } - - return methods, nil + return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID) } -type methodNotAllowedHandler struct{} +var allowedK8sVerbs = []string{ + "get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch", +} -func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(405) // method not allowed +var httpMethodToK8sVerb = map[string]string{ + http.MethodGet: "get", + http.MethodPost: "create", + http.MethodPut: "replace", + http.MethodPatch: "patch", + http.MethodDelete: "delete", + http.MethodConnect: "connect", + http.MethodOptions: "connect", // No real equivalent to options and head + http.MethodHead: "connect", +} + +// generateOperationNameFromPath creates an operation name from a route path. +// The operation name is used by the OpenAPI generator and should be descriptive. +// It uses meaningful path segments to create readable yet unique operation names. +// Examples: +// - "/search" -> "Search" +// - "/snapshots/create" -> "SnapshotsCreate" +// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags" +// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey" +func generateOperationNameFromPath(routePath string) string { + // Remove leading slash and split by path segments + parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/") + + // Filter to keep meaningful segments and path parameters + var nameParts []string + skipPrefixes := map[string]bool{ + "namespaces": true, + "apis": true, + } + + for _, part := range parts { + if part == "" { + continue + } + + // Extract parameter name from {paramName} format + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + paramName := part[1 : len(part)-1] + // Skip generic parameters like {namespace}, but keep specific ones like {flagKey} + if paramName != "namespace" && paramName != "name" { + nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:]) + } + continue + } + + // Skip common prefixes + if skipPrefixes[strings.ToLower(part)] { + continue + } + + // Skip version segments like v1, v0alpha1, v2beta1, etc. + if strings.HasPrefix(strings.ToLower(part), "v") && + (len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) { + continue + } + + // Capitalize first letter and add to parts + if len(part) > 0 { + nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:]) + } + } + + if len(nameParts) == 0 { + return "Route" + } + + return strings.Join(nameParts, "") } diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 499cdb4df6f..a0a9f007bcd 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -5,7 +5,6 @@ import ( "net" "path/filepath" "strconv" - "strings" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -41,15 +40,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") runtimeConfig := apiserverCfg.Key("runtime_config").String() - runtimeConfigSplit := strings.Split(runtimeConfig, ",") - - // TODO: temporary fix to allow disabling local features service and still being able to use its authz handler - if !cfg.OpenFeature.APIEnabled { - runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false") - } - - runtimeConfig = strings.Join(runtimeConfigSplit, ",") - if runtimeConfig != "" { if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil { return fmt.Errorf("failed to set runtime config: %w", err) diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 6c92350ec2a..7fd5e51a96b 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -155,7 +155,7 @@ func ProvideService( features: features, rr: rr, builders: []builder.APIGroupBuilder{}, - authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg), + authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(), tracing: tracing, db: db, // For Unified storage metrics: reg, @@ -443,6 +443,19 @@ func (s *service) start(ctx context.Context) error { return err } + // Augment existing WebServices with custom routes from builders + // This directly adds routes to existing WebServices using the OpenAPI specs from builders + if server.Handler != nil && server.Handler.GoRestfulContainer != nil { + if err := builder.AugmentWebServicesWithCustomRoutes( + server.Handler.GoRestfulContainer, + builders, + s.metrics, + serverConfig.MergedResourceConfig, + ); err != nil { + return fmt.Errorf("failed to augment web services with custom routes: %w", err) + } + } + // stash the options for later use s.options = o diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index dcf2432fb2c..fd0b4f78b42 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -182,25 +182,6 @@ func newFolderTranslation() translation { return folderTranslation } -func newExternalGroupMappingTranslation() translation { - return translation{ - resource: "teams.permissions", - attribute: "uid", - verbMapping: map[string]string{ - utils.VerbGet: "teams.permissions:read", - utils.VerbList: "teams.permissions:read", - utils.VerbWatch: "teams.permissions:read", - utils.VerbCreate: "teams.permissions:write", - utils.VerbUpdate: "teams.permissions:write", - utils.VerbPatch: "teams.permissions:write", - utils.VerbDelete: "teams.permissions:write", - utils.VerbGetPermissions: "teams.permissions:write", - utils.VerbSetPermissions: "teams.permissions:write", - }, - folderSupport: false, - } -} - func NewMapperRegistry() MapperRegistry { skipScopeOnAllVerbs := map[string]bool{ utils.VerbCreate: true, @@ -229,8 +210,6 @@ func NewMapperRegistry() MapperRegistry { "serviceaccounts": newResourceTranslation("serviceaccounts", "uid", false, map[string]bool{utils.VerbCreate: true}), // Teams is a special case. We translate user permissions from id to uid based. "teams": newResourceTranslation("teams", "uid", false, map[string]bool{utils.VerbCreate: true}), - // ExternalGroupMappings is a special case. We translate team permissions from id to uid based. - "externalgroupmappings": newExternalGroupMappingTranslation(), "coreroles": translation{ resource: "roles", attribute: "uid", diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 79eb19a8b5b..b1d49d9ee17 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -90,7 +90,7 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea authzv1.RegisterAuthzServiceServer(channel, srv) authzextv1.RegisterAuthzExtentionServiceServer(channel, srv) - client, err := zClient.New(channel) + client, err := zClient.New(channel, reg) if err != nil { return nil, fmt.Errorf("failed to initialize zanzana client: %w", err) } @@ -169,7 +169,7 @@ func NewRemoteZanzanaClient(cfg ZanzanaClientConfig, reg prometheus.Registerer) return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err) } - client, err := zClient.New(conn) + client, err := zClient.New(conn, reg) if err != nil { return nil, fmt.Errorf("failed to initialize zanzana client: %w", err) } diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 3c51d707561..ac19d9b3fdc 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -9,6 +9,7 @@ import ( authzlib "github.com/grafana/authlib/authz" authzv1 "github.com/grafana/authlib/authz/proto/v1" authlib "github.com/grafana/authlib/types" + "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/infra/log" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" @@ -25,15 +26,17 @@ type Client struct { authz authzv1.AuthzServiceClient authzext authzextv1.AuthzExtentionServiceClient authzlibclient *authzlib.ClientImpl + metrics *clientMetrics } -func New(cc grpc.ClientConnInterface) (*Client, error) { +func New(cc grpc.ClientConnInterface, reg prometheus.Registerer) (*Client, error) { authzlibclient := authzlib.NewClient(cc, authzlib.WithTracerClientOption(tracer)) c := &Client{ authzlibclient: authzlibclient, authz: authzv1.NewAuthzServiceClient(cc), authzext: authzextv1.NewAuthzExtentionServiceClient(cc), logger: log.New("zanzana.client"), + metrics: newClientMetrics(reg), } return c, nil @@ -43,6 +46,9 @@ func (c *Client) Check(ctx context.Context, id authlib.AuthInfo, req authlib.Che ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Check", req.Namespace)) + defer timer.ObserveDuration() + return c.authzlibclient.Check(ctx, id, req, folder) } @@ -50,6 +56,9 @@ func (c *Client) Compile(ctx context.Context, id authlib.AuthInfo, req authlib.L ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Compile") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Compile", req.Namespace)) + defer timer.ObserveDuration() + return c.authzlibclient.Compile(ctx, id, req) } @@ -64,6 +73,9 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Write") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Write", req.Namespace)) + defer timer.ObserveDuration() + _, err := c.authzext.Write(ctx, req) return err } @@ -72,6 +84,9 @@ func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckReque ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("BatchCheck", req.Namespace)) + defer timer.ObserveDuration() + return c.authzext.BatchCheck(ctx, req) } @@ -87,6 +102,9 @@ func (c *Client) Mutate(ctx context.Context, req *authzextv1.MutateRequest) erro ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Mutate") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Mutate", req.Namespace)) + defer timer.ObserveDuration() + _, err := c.authzext.Mutate(ctx, req) return err } @@ -95,5 +113,8 @@ func (c *Client) Query(ctx context.Context, req *authzextv1.QueryRequest) (*auth ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Query") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Query", req.Namespace)) + defer timer.ObserveDuration() + return c.authzext.Query(ctx, req) } diff --git a/pkg/services/authz/zanzana/client/metrics.go b/pkg/services/authz/zanzana/client/metrics.go index 3fe7b1dd590..070f1cecf2c 100644 --- a/pkg/services/authz/zanzana/client/metrics.go +++ b/pkg/services/authz/zanzana/client/metrics.go @@ -7,10 +7,10 @@ import ( const ( metricsNamespace = "iam" - metricsSubSystem = "authz_zanzana" + metricsSubSystem = "authz_zanzana_client" ) -type metrics struct { +type shadowClientMetrics struct { // evaluationsSeconds is a summary for evaluating access for a specific engine (RBAC and zanzana) evaluationsSeconds *prometheus.HistogramVec // compileSeconds is a summary for compiling item checker for a specific engine (RBAC and zanzana) @@ -19,8 +19,13 @@ type metrics struct { evaluationStatusTotal *prometheus.CounterVec } -func newShadowClientMetrics(reg prometheus.Registerer) *metrics { - return &metrics{ +type clientMetrics struct { + // requestDurationSeconds is a summary for zanzana client request duration + requestDurationSeconds *prometheus.HistogramVec +} + +func newShadowClientMetrics(reg prometheus.Registerer) *shadowClientMetrics { + return &shadowClientMetrics{ evaluationsSeconds: promauto.With(reg).NewHistogramVec( prometheus.HistogramOpts{ Name: "engine_evaluations_seconds", @@ -52,3 +57,18 @@ func newShadowClientMetrics(reg prometheus.Registerer) *metrics { ), } } + +func newClientMetrics(reg prometheus.Registerer) *clientMetrics { + return &clientMetrics{ + requestDurationSeconds: promauto.With(reg).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "request_duration_seconds", + Help: "Histogram for zanzana client request duration", + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), + }, + []string{"method", "request_namespace"}, + ), + } +} diff --git a/pkg/services/authz/zanzana/client/shadow_client.go b/pkg/services/authz/zanzana/client/shadow_client.go index 6f6e6dc6836..1e23c9b20f2 100644 --- a/pkg/services/authz/zanzana/client/shadow_client.go +++ b/pkg/services/authz/zanzana/client/shadow_client.go @@ -20,7 +20,7 @@ type ShadowClient struct { logger log.Logger accessClient authlib.AccessClient zanzanaClient authlib.AccessClient - metrics *metrics + metrics *shadowClientMetrics } // WithShadowClient returns a new access client that runs zanzana checks in the background. diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index ea37867d8c0..43fb5ce9a53 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm tags := dash.GetTags() if len(tags) > 0 { for _, tag := range tags { + if len(tag) > 50 { + return nil, dashboards.ErrDashboardTagTooLong + } if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil { return nil, err } diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index e39b1ed4b21..b41d2d0bb23 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -79,6 +79,11 @@ var ( Reason: "message too long, max 500 characters", StatusCode: 400, } + ErrDashboardTagTooLong = dashboardaccess.DashboardErr{ + Reason: "dashboard tag too long, max 50 characters", + StatusCode: 400, + Status: "tag-too-long", + } ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{ Reason: "Cannot save provisioned dashboard", StatusCode: 400, diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 84a3e14141c..3d9c451553a 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -51,6 +51,7 @@ type Service struct { pluginStore pluginstore.Store pluginClient plugins.Client basePluginContextProvider plugincontext.BasePluginContextProvider + retriever DataSourceRetriever ptc proxyTransportCache } @@ -70,6 +71,7 @@ func ProvideService( features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService, quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client, basePluginContextProvider plugincontext.BasePluginContextProvider, + retriever DataSourceRetriever, ) (*Service, error) { dslogger := log.New("datasources") store := &SqlStore{db: db, logger: dslogger, features: features} @@ -89,6 +91,7 @@ func ProvideService( pluginStore: pluginStore, pluginClient: pluginClient, basePluginContextProvider: basePluginContextProvider, + retriever: retriever, } ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store)) @@ -175,11 +178,11 @@ func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttr } func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { - return s.SQLStore.GetDataSource(ctx, query) + return s.retriever.GetDataSource(ctx, query) } func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) { - return s.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group) + return s.retriever.GetDataSourceInNamespace(ctx, namespace, name, group) } func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) { diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 1aeca6af087..797fe6d049c 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -832,8 +832,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) { quotaService := quotatest.New(false, nil) permissionSvc := acmock.NewMockedPermissionsService() permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() - - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) cmd := &datasources.DeleteDataSourceCommand{ @@ -857,7 +858,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) { permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() cfg := &setting.Cfg{} enableRBACManagedPermissions(t, cfg) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) // First add the datasource @@ -1124,7 +1127,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) @@ -1161,7 +1166,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1212,7 +1219,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1260,7 +1269,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1316,7 +1327,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1351,7 +1364,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1420,7 +1435,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1499,7 +1516,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1522,7 +1541,9 @@ func TestIntegrationService_getProxySettings(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) t.Run("Should default to disabled", func(t *testing.T) { @@ -1620,7 +1641,9 @@ func TestIntegrationService_getTimeout(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) for _, tc := range testCases { @@ -1645,7 +1668,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) jsonData := map[string]string{ @@ -1673,7 +1698,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) jsonData := map[string]string{ @@ -1699,7 +1726,9 @@ func TestIntegrationDataSource_CustomHeaders(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) dsService.cfg = setting.NewCfg() @@ -1788,7 +1817,9 @@ func initDSService(t *testing.T) *Service { quotaService := quotatest.New(false, nil) mockPermission := acmock.NewMockedPermissionsService() mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{ + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{ JSONData: plugins.JSONData{ ID: "test", @@ -1808,7 +1839,7 @@ func initDSService(t *testing.T) *Service { ObjectBytes: req.ObjectBytes, }, nil }, - }, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + }, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) return dsService diff --git a/pkg/services/datasources/service/datasourceretriever.go b/pkg/services/datasources/service/datasourceretriever.go new file mode 100644 index 00000000000..8448b49b8f5 --- /dev/null +++ b/pkg/services/datasources/service/datasourceretriever.go @@ -0,0 +1,34 @@ +package service + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +// DataSourceRetrieverImpl implements DataSourceRetriever by delegating to a Store. +type DataSourceRetrieverImpl struct { + store Store +} + +var _ DataSourceRetriever = (*DataSourceRetrieverImpl)(nil) + +// ProvideDataSourceRetriever creates a DataSourceRetriever for wire injection. +func ProvideDataSourceRetriever(db db.DB, features featuremgmt.FeatureToggles) DataSourceRetriever { + dslogger := log.New("datasources-retriever") + store := &SqlStore{db: db, logger: dslogger, features: features} + return &DataSourceRetrieverImpl{store: store} +} + +// GetDataSource gets a datasource. +func (r *DataSourceRetrieverImpl) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { + return r.store.GetDataSource(ctx, query) +} + +// GetDataSourceInNamespace gets a datasource by namespace, name (datasource uid), and group (datasource type). +func (r *DataSourceRetrieverImpl) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) { + return r.store.GetDataSourceInNamespace(ctx, namespace, name, group) +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d325597a04b..885079a5f5a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -322,6 +322,13 @@ var ( Owner: grafanaOperatorExperienceSquad, RequiresRestart: true, }, + { + Name: "reportingCsvEncodingOptions", + Description: "Enables CSV encoding options in the reporting feature", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaOperatorExperienceSquad, + }, { Name: "sseGroupByDatasource", Description: "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", @@ -650,13 +657,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaDatavizSquad, }, - { - Name: "kubernetesFeatureToggles", - Description: "Use the kubernetes API for feature toggle management in the frontend", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaOperatorExperienceSquad, - }, { Name: "cloudRBACRoles", Description: "Enabled grafana cloud specific RBAC roles", @@ -1290,13 +1290,6 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, - { - Name: "unifiedHistory", - Description: "Displays the navigation history so the user can navigate back to previous pages", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendSearchNavOrganise, - FrontendOnly: true, - }, { // Remove this flag once Loki v4 is released and the min supported version is v3.0+, // since users on v2.9 need it to disable the feature, as it doesn't work for them. @@ -1306,13 +1299,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", }, - { - Name: "investigationsBackend", - Description: "Enable the investigations backend API", - Stage: FeatureStageExperimental, - Owner: grafanaAppPlatformSquad, - Expression: "false", - }, { Name: "k8SFolderCounts", Description: "Enable folder's api server counts", @@ -2090,6 +2076,13 @@ var ( FrontendOnly: false, Owner: grafanaOperatorExperienceSquad, }, + { + Name: "profilesExemplars", + Description: "Enables profiles exemplars support in profiles drilldown", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityTracesAndProfilingSquad, + FrontendOnly: false, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index a5041e1a42d..8ddc448ef52 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -43,6 +43,7 @@ configurableSchedulerTick,experimental,@grafana/alerting-squad,false,true,false dashgpt,GA,@grafana/dashboards-squad,false,false,true aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,true reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,false +reportingCsvEncodingOptions,experimental,@grafana/grafana-operator-experience-squad,false,false,false sseGroupByDatasource,experimental,@grafana/grafana-datasources-core-services,false,false,false lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false @@ -90,7 +91,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true timeComparison,experimental,@grafana/dataviz-squad,false,false,true tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true -kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false @@ -178,9 +178,7 @@ alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false, alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false -unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false -investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false @@ -283,3 +281,4 @@ useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false +profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5ef42eb6543..c42229d8870 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -135,6 +135,10 @@ const ( // Enables rendering retries for the reporting feature FlagReportingRetries = "reportingRetries" + // FlagReportingCsvEncodingOptions + // Enables CSV encoding options in the reporting feature + FlagReportingCsvEncodingOptions = "reportingCsvEncodingOptions" + // FlagSseGroupByDatasource // Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. FlagSseGroupByDatasource = "sseGroupByDatasource" @@ -539,10 +543,6 @@ const ( // Defaults to using the Loki `/labels` API instead of `/series` FlagLokiLabelNamesQueryApi = "lokiLabelNamesQueryApi" - // FlagInvestigationsBackend - // Enable the investigations backend API - FlagInvestigationsBackend = "investigationsBackend" - // FlagK8SFolderCounts // Enable folder's api server counts FlagK8SFolderCounts = "k8SFolderCounts" @@ -785,4 +785,8 @@ const ( // FlagSecretsManagementAppPlatformAwsKeeper // Enables the creation of keepers that manage secrets stored on AWS secrets manager FlagSecretsManagementAppPlatformAwsKeeper = "secretsManagementAppPlatformAwsKeeper" + + // FlagProfilesExemplars + // Enables profiles exemplars support in profiles drilldown + FlagProfilesExemplars = "profilesExemplars" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6831c7045b3..09c4d0c9760 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1793,7 +1793,8 @@ "metadata": { "name": "investigationsBackend", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-18T08:31:03Z" + "creationTimestamp": "2024-12-18T08:31:03Z", + "deletionTimestamp": "2025-12-16T16:06:24Z" }, "spec": { "description": "Enable the investigations backend API", @@ -2044,7 +2045,8 @@ "metadata": { "name": "kubernetesFeatureToggles", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-01-18T05:32:44Z" + "creationTimestamp": "2024-01-18T05:32:44Z", + "deletionTimestamp": "2026-01-07T12:02:51Z" }, "spec": { "description": "Use the kubernetes API for feature toggle management in the frontend", @@ -2866,6 +2868,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "profilesExemplars", + "resourceVersion": "1767777507980", + "creationTimestamp": "2026-01-07T09:18:27Z" + }, + "spec": { + "description": "Enables profiles exemplars support in profiles drilldown", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling" + } + }, { "metadata": { "name": "prometheusAzureOverrideAudience", @@ -3123,6 +3137,18 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "reportingCsvEncodingOptions", + "resourceVersion": "1766080709938", + "creationTimestamp": "2025-12-18T17:58:29Z" + }, + "spec": { + "description": "Enables CSV encoding options in the reporting feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, { "metadata": { "name": "reportingRetries", @@ -3558,8 +3584,12 @@ { "metadata": { "name": "unifiedHistory", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-13T10:41:18Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-12-13T10:41:18Z", + "deletionTimestamp": "2025-11-13T16:25:53Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Displays the navigation history so the user can navigate back to previous pages", diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index df51717756e..5a00eca7401 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -501,9 +501,15 @@ type GetLibraryElementsParams struct { // required:false ExcludeUID string `json:"excludeUid"` // A comma separated list of folder ID(s) to filter the elements by. + // Deprecated: Use FolderFilterUIDs instead. // in:query // required:false + // deprecated:true FolderFilter string `json:"folderFilter"` + // A comma separated list of folder UID(s) to filter the elements by. + // in:query + // required:false + FolderFilterUIDs string `json:"folderFilterUIDs"` // The number of results per page. // in:query // required:false diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 5b9eb985bd8..a1ef1aab1ef 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -440,7 +440,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) { if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true, + Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true, }) } } diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 1601ddc94e5..106203e4e33 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -16,13 +16,6 @@ import ( "github.com/grafana/alerting/receivers/schema" ) -// GetReceiverQuery represents a query for a single receiver. -type GetReceiverQuery struct { - OrgID int64 - Name string - Decrypt bool -} - // GetReceiversQuery represents a query for receiver groups. type GetReceiversQuery struct { OrgID int64 diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index 246cd03d64d..04a651a174f 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -378,3 +378,29 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn } return encrypted, nil } + +// DecryptIntegrationSettings returns a function to decrypt integration settings. +func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn { + return func(value string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + decrypted, err := ss.Decrypt(ctx, decoded) + if err != nil { + return "", err + } + return string(decrypted), nil + } +} + +// EncryptIntegrationSettings returns a function to encrypt integration settings. +func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn { + return func(payload string) (string, error) { + encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encrypted), nil + } +} diff --git a/pkg/services/ngalert/notifier/errors.go b/pkg/services/ngalert/notifier/errors.go index 337dfa7cb3b..08f4c967049 100644 --- a/pkg/services/ngalert/notifier/errors.go +++ b/pkg/services/ngalert/notifier/errors.go @@ -1,9 +1,37 @@ package notifier -import "github.com/grafana/grafana/pkg/apimachinery/errutil" +import ( + "errors" + "slices" + + "github.com/grafana/alerting/receivers/schema" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" +) // WithPublicError sets the public message of an errutil error to the error message. func WithPublicError(err errutil.Error) error { err.PublicMessage = err.Error() return err } + +// If provided error is errutil.Error it appends fields that caused the error to public payload +func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { + var authzErr errutil.Error + if !errors.As(err, &authzErr) { + return err + } + if authzErr.PublicPayload == nil { + authzErr.PublicPayload = map[string]interface{}{} + } + fields := make(map[string][]string, len(diff)) + for field, paths := range diff { + fields[field] = make([]string, len(paths)) + for i, path := range paths { + fields[field][i] = path.String() + } + slices.Sort(fields[field]) + } + authzErr.PublicPayload["changed_protected_fields"] = fields + return authzErr +} diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index e05b12a2920..dc3e9d4b8eb 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -2,7 +2,6 @@ package notifier import ( "context" - "encoding/base64" "errors" "fmt" "strings" @@ -133,30 +132,33 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType()) } -// GetReceiver returns a receiver by name. +// GetReceiver returns a receiver by its UID. // The receiver's secure settings are decrypted if requested and the user has access to do so. -func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) { +func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) { + if user == nil { + return nil, errors.New("user is required") + } ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes( - attribute.Int64("query_org_id", q.OrgID), - attribute.String("query_name", q.Name), - attribute.Bool("query_decrypt", q.Decrypt), + attribute.Int64("query_org_id", user.GetOrgID()), + attribute.String("query_uid", uid), + attribute.Bool("query_decrypt", decrypt), )) defer span.End() - revision, err := rs.cfgStore.Get(ctx, q.OrgID) + revision, err := rs.cfgStore.Get(ctx, user.GetOrgID()) if err != nil { return nil, err } - prov, err := rs.loadProvenances(ctx, q.OrgID) + prov, err := rs.loadProvenances(ctx, user.GetOrgID()) if err != nil { return nil, err } - rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov) + rcv, err := revision.GetReceiver(uid, prov) if err != nil { if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported { - imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision) + imported := rs.getImportedReceivers(ctx, span, []string{uid}, revision) if len(imported) > 0 { rcv = imported[0] } @@ -171,14 +173,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver )) auth := rs.authz.AuthorizeReadDecrypted - if !q.Decrypt { + if !decrypt { auth = rs.authz.AuthorizeRead } if err := auth(ctx, user, rcv); err != nil { return nil, err } - if q.Decrypt { + if decrypt { err := rcv.Decrypt(rs.decryptor(ctx)) if err != nil { rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err) @@ -684,28 +686,12 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i // decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used. func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := rs.encryptionService.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } + return DecryptIntegrationSettings(ctx, rs.encryptionService) } // encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result. func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn { - return func(payload string) (string, error) { - s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(s), nil - } + return EncryptIntegrationSettings(ctx, rs.encryptionService) } // checkOptimisticConcurrency checks if the existing receiver's version matches the desired version. diff --git a/pkg/services/ngalert/notifier/receiver_svc_err.go b/pkg/services/ngalert/notifier/receiver_svc_err.go deleted file mode 100644 index 06b27602f75..00000000000 --- a/pkg/services/ngalert/notifier/receiver_svc_err.go +++ /dev/null @@ -1,30 +0,0 @@ -package notifier - -import ( - "errors" - "slices" - - "github.com/grafana/alerting/receivers/schema" - - "github.com/grafana/grafana/pkg/apimachinery/errutil" -) - -func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { - var authzErr errutil.Error - if !errors.As(err, &authzErr) { - return err - } - if authzErr.PublicPayload == nil { - authzErr.PublicPayload = map[string]interface{}{} - } - fields := make(map[string][]string, len(diff)) - for field, paths := range diff { - fields[field] = make([]string, len(paths)) - for i, path := range paths { - fields[field][i] = path.String() - } - slices.Sort(fields[field]) - } - authzErr.PublicPayload["changed_protected_fields"] = fields - return authzErr -} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 10e84f33b62..08c66411f16 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service gets receiver from AM config", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) require.Equal(t, "slack receiver", recv.Name) require.Len(t, recv.Integrations, 1) @@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service returns error when receiver does not exist", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) }) @@ -68,7 +68,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("gets imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.NoError(t, err) assert.Equal(t, models.ResourceOriginImported, recv.Origin) assert.Equal(t, "receiver1", recv.Name) @@ -81,9 +81,9 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) - _, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) }) }) @@ -187,12 +187,6 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { user: readUser, err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", }, - { - name: "service returns error if user is nil and decrypt is true", - decrypt: true, - user: nil, - err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", - }, { name: "service decrypts receivers with permission", decrypt: true, @@ -224,18 +218,16 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { }, } - for _, o := range origin { - for _, method := range getMethods { - t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, o := range origin { t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, o.opts...) var res *models.Receiver var err error if method == "single" { - q := singleQ(1, o.receiver) - q.Decrypt = tc.decrypt - res, err = sut.GetReceiver(context.Background(), q, tc.user) + res, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(o.receiver), tc.decrypt, tc.user) } else { q := multiQ(1, o.receiver) q.Decrypt = tc.decrypt @@ -267,8 +259,8 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey]) } }) - }) - } + } + }) } } } @@ -412,8 +404,7 @@ func TestReceiverService_Delete(t *testing.T) { // Ensure receiver saved to store is correct. name, err := legacy_storage.UidToName(tc.deleteUID) require.NoError(t, err) - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name} - _, err = sut.GetReceiver(context.Background(), q, writer) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer) assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) @@ -626,8 +617,7 @@ func TestReceiverService_Create(t *testing.T) { assert.Equal(t, tc.expectedCreate, *created) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption. @@ -931,8 +921,7 @@ func TestReceiverService_Update(t *testing.T) { assert.Equal(t, tc.expectedUpdate, *updated) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption. @@ -1054,7 +1043,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { sut.ruleNotificationsStore = ruleStore newReceiverName = "receiver1" - actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, models.ResourceOriginImported, actual.Origin) require.Equal(t, newReceiverName, actual.Name) @@ -1067,7 +1056,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { require.NotEqual(t, actual, recv) require.Equal(t, models.ResourceOriginGrafana, recv.Origin) - actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, recv.Name, actual.Name) }) @@ -1185,7 +1174,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { return false } for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisible(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1207,7 +1196,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { } sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true) for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisibleInProvisioning(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1766,7 +1755,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) { }, }} - r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin) + r, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, admin) require.NoError(t, err) t.Run("should override metadata for imported receivers", func(t *testing.T) { @@ -1842,13 +1831,6 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi return string(bytes) } -func singleQ(orgID int64, name string) models.GetReceiverQuery { - return models.GetReceiverQuery{ - OrgID: orgID, - Name: name, - } -} - func multiQ(orgID int64, names ...string) models.GetReceiversQuery { return models.GetReceiversQuery{ OrgID: orgID, diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index a7c99f8518c..60740d935af 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -40,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" "github.com/grafana/grafana/pkg/services/ngalert/sender" + "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -57,6 +58,7 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab } type Crypto interface { + Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) Decrypt(ctx context.Context, payload []byte) ([]byte, error) DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error } @@ -289,20 +291,6 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool { return configHash == am.defaultConfigHash } -func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := crypto.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } -} - // buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use. // It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs. func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) { @@ -317,7 +305,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea } // Decrypt the receivers in the configuration. - decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err) } @@ -619,7 +607,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, } func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) { - decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err) } diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index d6f66756454..1a1d3943324 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -43,7 +43,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/remote/client" ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -298,13 +297,7 @@ func TestIntegrationApplyConfig(t *testing.T) { var c apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c)) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) - encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) c.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -462,13 +455,7 @@ func TestCompareAndSendConfiguration(t *testing.T) { // Create a config with correctly encrypted and encoded secrets. var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg) @@ -663,13 +650,7 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) { var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -1037,13 +1018,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { { postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret)) require.NoError(t, err) - encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index ac0268e051c..fa11375d778 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -151,7 +151,7 @@ type OrgUserDTO struct { Role string `json:"role"` LastSeenAt time.Time `json:"lastSeenAt"` Updated time.Time `json:"-"` - Created time.Time `json:"-"` + Created time.Time `json:"created"` LastSeenAtAge string `json:"lastSeenAtAge"` AccessControl map[string]bool `json:"accessControl,omitempty"` IsDisabled bool `json:"isDisabled"` diff --git a/pkg/services/preference/prefapi/api.go b/pkg/services/preference/prefapi/api.go index 6bf8057ac68..15d5c22c4bc 100644 --- a/pkg/services/preference/prefapi/api.go +++ b/pkg/services/preference/prefapi/api.go @@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context, return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + if !pref.IsValidTimezone(dtoCmd.Timezone) { + return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil) + } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID diff --git a/pkg/services/preference/timezone.go b/pkg/services/preference/timezone.go new file mode 100644 index 00000000000..e69e8eda591 --- /dev/null +++ b/pkg/services/preference/timezone.go @@ -0,0 +1,21 @@ +package pref + +import ( + "time" +) + +// IsValidTimezone checks if the timezone string is valid. +// It accepts: +// - "" - uses default +// - "utc" +// - "browser" +// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London") +func IsValidTimezone(timezone string) bool { + if timezone == "" || timezone == "utc" || timezone == "browser" { + return true + } + + // try to load as IANA timezone + _, err := time.LoadLocation(timezone) + return err == nil +} diff --git a/pkg/services/preference/timezone_test.go b/pkg/services/preference/timezone_test.go new file mode 100644 index 00000000000..e9bfceb6203 --- /dev/null +++ b/pkg/services/preference/timezone_test.go @@ -0,0 +1,38 @@ +package pref + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidTimezone(t *testing.T) { + tests := []struct { + timezone string + valid bool + }{ + { + timezone: "utc", + valid: true, + }, + { + timezone: "browser", + valid: true, + }, + { + timezone: "Europe/London", + valid: true, + }, + { + timezone: "invalid", + valid: false, + }, + { + timezone: "", + valid: true, + }, + } + for _, test := range tests { + assert.Equal(t, test.valid, IsValidTimezone(test.timezone)) + } +} diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 834912a6af1..8c5ca87ae93 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -542,9 +542,10 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService()) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) + dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, featuremgmt.WithFeatures()) _, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext. - ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) m := metrics.NewNGAlert(prometheus.NewRegistry()) diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index fdb1860b674..1c018a653e2 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -37,9 +37,10 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS features := featuremgmt.WithFeatures() secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) quotaService := quotatest.New(false, nil) + dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, features) dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) migService := ProvideDataSourceMigrationService(dsService, kvStore, features) return migService diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index fc8d9589357..12b0ac6ed57 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -146,6 +146,7 @@ type UserSearchHitDTO struct { LastSeenAtAge string `json:"lastSeenAtAge"` AuthLabels []string `json:"authLabels"` AuthModule AuthModuleConversion `json:"-"` + Created time.Time `json:"created" xorm:"created"` } type GetUserProfileQuery struct { diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index a7773147373..64780c5c2eb 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -541,7 +541,7 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* sess.Limit(query.Limit, offset) } - sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned") + sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned", "u.created") if len(query.SortOpts) > 0 { for i := range query.SortOpts { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index aafc9167ce4..9667b82b9fa 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -588,8 +588,10 @@ type Cfg struct { // Unified Storage UnifiedStorage map[string]UnifiedStorageConfig // DisableDataMigrations will disable resources data migration to unified storage at startup - DisableDataMigrations bool - MaxPageSizeBytes int + DisableDataMigrations bool + MaxPageSizeBytes int + // IndexPath the directory where index files are stored. + // Note: Bleve locks index files, so mounts cannot be shared between multiple instances. IndexPath string IndexWorkers int IndexRebuildWorkers int @@ -637,6 +639,8 @@ type UnifiedStorageConfig struct { // EnableMigration indicates whether migration is enabled for the resource. // If not set, will use the default from MigratedUnifiedResources. EnableMigration bool + // AutoMigrationThreshold is the threshold below which a resource is automatically migrated. + AutoMigrationThreshold int } type InstallPlugin struct { diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go index 5730d27a74f..ed7386813ef 100644 --- a/pkg/setting/setting_secrets_manager.go +++ b/pkg/setting/setting_secrets_manager.go @@ -36,6 +36,8 @@ type SecretsManagerSettings struct { // How long to wait for the process to clean up a secure value to complete. GCWorkerPerSecureValueCleanupTimeout time.Duration + // Whether to register the MT CRUD API + RegisterAPIServer bool // Whether to create the MT secrets management database RunSecretsDBMigrations bool // Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. @@ -64,6 +66,7 @@ func (cfg *Cfg) readSecretsManagerSettings() { cfg.SecretsManagement.GCWorkerPollInterval = secretsMgmt.Key("gc_worker_poll_interval").MustDuration(1 * time.Minute) cfg.SecretsManagement.GCWorkerPerSecureValueCleanupTimeout = secretsMgmt.Key("gc_worker_per_request_timeout").MustDuration(5 * time.Second) + cfg.SecretsManagement.RegisterAPIServer = secretsMgmt.Key("register_api_server").MustBool(true) cfg.SecretsManagement.RunSecretsDBMigrations = secretsMgmt.Key("run_secrets_db_migrations").MustBool(true) cfg.SecretsManagement.RunDataKeyMigration = secretsMgmt.Key("run_data_key_migration").MustBool(true) diff --git a/pkg/setting/setting_secrets_manager_test.go b/pkg/setting/setting_secrets_manager_test.go index 34f88a481b5..c326c250821 100644 --- a/pkg/setting/setting_secrets_manager_test.go +++ b/pkg/setting/setting_secrets_manager_test.go @@ -171,6 +171,28 @@ domain = example.com assert.Empty(t, cfg.SecretsManagement.ConfiguredKMSProviders) }) + t.Run("should handle configuration with register_api_server disabled", func(t *testing.T) { + iniContent := ` +[secrets_manager] +register_api_server = false +` + cfg, err := NewCfgFromBytes([]byte(iniContent)) + require.NoError(t, err) + + assert.False(t, cfg.SecretsManagement.RegisterAPIServer) + }) + + t.Run("should handle configuration without register_api_server set", func(t *testing.T) { + iniContent := ` +[secrets_manager] +encryption_provider = aws_kms +` + cfg, err := NewCfgFromBytes([]byte(iniContent)) + require.NoError(t, err) + + assert.True(t, cfg.SecretsManagement.RegisterAPIServer) + }) + t.Run("should handle configuration with run_secrets_db_migrations disabled", func(t *testing.T) { iniContent := ` [secrets_manager] diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 8086151c40a..21a3f455993 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -8,6 +8,10 @@ import ( "github.com/grafana/grafana/pkg/util/osutil" ) +// DefaultAutoMigrationThreshold is the default threshold for auto migration switching. +// If a resource has entries at or below this count, it will be migrated. +const DefaultAutoMigrationThreshold = 10 + const ( PlaylistResource = "playlists.playlist.grafana.app" FolderResource = "folders.folder.grafana.app" @@ -21,6 +25,13 @@ var MigratedUnifiedResources = map[string]bool{ DashboardResource: false, } +// AutoMigratedUnifiedResources maps resources that support auto-migration +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +var AutoMigratedUnifiedResources = map[string]bool{ + FolderResource: true, + DashboardResource: true, +} + // read storage configs from ini file. They look like: // [unified_storage..] // = @@ -59,6 +70,13 @@ func (cfg *Cfg) setUnifiedStorageConfig() { enableMigration = section.Key("enableMigration").MustBool(MigratedUnifiedResources[resourceName]) } + // parse autoMigrationThreshold from resource section + autoMigrationThreshold := 0 + autoMigrate := AutoMigratedUnifiedResources[resourceName] + if autoMigrate { + autoMigrationThreshold = section.Key("autoMigrationThreshold").MustInt(DefaultAutoMigrationThreshold) + } + storageConfig[resourceName] = UnifiedStorageConfig{ DualWriterMode: rest.DualWriterMode(dualWriterMode), DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled, @@ -66,6 +84,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() { DataSyncerRecordsLimit: dataSyncerRecordsLimit, DataSyncerInterval: dataSyncerInterval, EnableMigration: enableMigration, + AutoMigrationThreshold: autoMigrationThreshold, } } cfg.UnifiedStorage = storageConfig @@ -73,13 +92,13 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // Set indexer config for unified storage section := cfg.Raw.Section("unified_storage") cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false) - if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" { + if !cfg.DisableDataMigrations && cfg.UnifiedStorageType() == "unified" { // Helper log to find instances running migrations in the future cfg.Logger.Info("Unified migration configs enforced") cfg.enforceMigrationToUnifiedConfigs() } else { // Helper log to find instances disabling migration - cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations) + cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.UnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations) } cfg.EnableSearch = section.Key("enable_search").MustBool(false) cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0) @@ -147,14 +166,15 @@ func (cfg *Cfg) enforceMigrationToUnifiedConfigs() { DualWriterMode: 5, DualWriterMigrationDataSyncDisabled: true, EnableMigration: true, + AutoMigrationThreshold: resourceCfg.AutoMigrationThreshold, } } } -// getUnifiedStorageType returns the configured storage type without creating or mutating keys. +// UnifiedStorageType returns the configured storage type without creating or mutating keys. // Precedence: env > ini > default ("unified"). // Used to decide unified storage behavior early without side effects. -func (cfg *Cfg) getUnifiedStorageType() string { +func (cfg *Cfg) UnifiedStorageType() string { const ( grafanaAPIServerSectionName = "grafana-apiserver" storageTypeKeyName = "storage_type" @@ -168,3 +188,23 @@ func (cfg *Cfg) getUnifiedStorageType() string { } return defaultStorageType } + +// UnifiedStorageConfig returns the UnifiedStorageConfig for a resource. +func (cfg *Cfg) UnifiedStorageConfig(resource string) UnifiedStorageConfig { + if cfg.UnifiedStorage == nil { + return UnifiedStorageConfig{} + } + return cfg.UnifiedStorage[resource] +} + +// EnableMode5 enables migration and sets mode 5 for a resource. +func (cfg *Cfg) EnableMode5(resource string) { + if cfg.UnifiedStorage == nil { + cfg.UnifiedStorage = make(map[string]UnifiedStorageConfig) + } + config := cfg.UnifiedStorage[resource] + config.DualWriterMode = rest.Mode5 + config.DualWriterMigrationDataSyncDisabled = true + config.EnableMigration = true + cfg.UnifiedStorage[resource] = config +} diff --git a/pkg/setting/setting_unified_storage_test.go b/pkg/setting/setting_unified_storage_test.go index c112dc85962..58f0fe56a60 100644 --- a/pkg/setting/setting_unified_storage_test.go +++ b/pkg/setting/setting_unified_storage_test.go @@ -43,10 +43,16 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { } assert.Equal(t, exists, true, migratedResource) + expectedThreshold := 0 + if AutoMigratedUnifiedResources[migratedResource] { + expectedThreshold = DefaultAutoMigrationThreshold + } + assert.Equal(t, UnifiedStorageConfig{ DualWriterMode: 5, DualWriterMigrationDataSyncDisabled: true, EnableMigration: isEnabled, + AutoMigrationThreshold: expectedThreshold, }, resourceCfg, migratedResource) } } @@ -71,6 +77,7 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { DualWriterPeriodicDataSyncJobEnabled: true, DataSyncerRecordsLimit: 1001, DataSyncerInterval: time.Minute * 10, + AutoMigrationThreshold: 0, }) validateMigratedResources(false) diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index 0896ab89df4..b8c241ccf7f 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -127,6 +127,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, // function call happens after this. sv, err := s.secureValueMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading secure value metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read secure value metadata storage: %v (%w)", err, contracts.ErrDecryptNotFound) } @@ -137,6 +141,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Status.Keeper, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading keeper config metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read keeper config metadata storage: %v (%w)", err, contracts.ErrDecryptFailed) } @@ -148,13 +156,22 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, if sv.Spec.Ref != nil { exposedValue, err := keeper.RetrieveReference(ctx, keeperConfig, *sv.Spec.Ref) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret using reference: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret using reference: %v (%w)", err, contracts.ErrDecryptFailed) } + return exposedValue, nil } exposedValue, err := keeper.Expose(ctx, keeperConfig, namespace, name, sv.Status.Version) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret: %v (%w)", err, contracts.ErrDecryptFailed) } diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index 36e49df0146..9d8b1b46e76 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -68,6 +68,40 @@ func TestIntegrationDecrypt(t *testing.T) { } }) + t.Run("when the context is cancelled, it returns an error", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + svcIdentity := "svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, svcIdentity, types.TypeUser) + + // Setup service + sut := testutils.Setup(t) + + // Create a secure value + spec := secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{svcIdentity}, + Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")), + } + sv := &secretv1beta1.SecureValue{Spec: spec} + sv.Name = "sv-test" + sv.Namespace = "default" + + _, err := sut.CreateSv(authCtx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + // Cancel immediately! + cancel() + + exposed, err := sut.DecryptStorage.Decrypt(authCtx, "default", "sv-test") + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, exposed) + }) + t.Run("when happy path with valid auth and permissions, it returns decrypted value", func(t *testing.T) { t.Parallel() diff --git a/pkg/storage/secret/metadata/metrics/metrics.go b/pkg/storage/secret/metadata/metrics/metrics.go index 094a19fa30d..3dce608d7d7 100644 --- a/pkg/storage/secret/metadata/metrics/metrics.go +++ b/pkg/storage/secret/metadata/metrics/metrics.go @@ -1,6 +1,7 @@ package metrics import ( + "context" "errors" "sync" @@ -185,6 +186,8 @@ func DecryptResultLabel(err error) string { return "error_not_found" } else if errors.Is(err, contracts.ErrDecryptNotAuthorized) { return "error_unauthorized" + } else if errors.Is(err, context.Canceled) { + return "error_context_canceled" } return "error_generic_failure" diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index e9bdbf88e37..aef27df8e05 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -293,15 +293,15 @@ overrides_path = overrides.yaml overrides_reload_period = 5s ``` -To overrides the default quota for a tenant, add the following to the overrides.yaml file: +To override the default quota for a tenant, add the following to the `overrides.yaml` file: ```yaml overrides: : quotas: - .: + /: limit: 10 ``` -Unless otherwise set, the NAMESPACE when running locally is `default`. +Unless otherwise set, the `NAMESPACE` when running locally is `default`. To access quotas, use the following API endpoint: ``` @@ -806,8 +806,10 @@ flowchart TD #### Setting Dual Writer Mode ```ini -[unified_storage.{resource}.{kind}.{group}] -dualWriterMode = {0-5} +; [unified_storage.{resource}.{group}] +[unified_storage.dashboards.dashboard.grafana.app] +; modes {0-5} +dualWriterMode = 0 ``` #### Background Sync Configuration @@ -1376,4 +1378,3 @@ disable_data_migrations = false ### Documentation For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md). - \ No newline at end of file diff --git a/pkg/storage/unified/migrations/migrator_test.go b/pkg/storage/unified/migrations/migrator_test.go index 8552092d066..cfd909a9255 100644 --- a/pkg/storage/unified/migrations/migrator_test.go +++ b/pkg/storage/unified/migrations/migrator_test.go @@ -214,8 +214,18 @@ func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) { for _, state := range testStates { t.Run(state.tc.name(), func(t *testing.T) { - // Verify resources now exist in unified storage after migration - state.tc.verify(t, helper, true) + shouldExist := true + for _, gvr := range state.tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + // Resources exist if they're either: + // 1. In MigratedUnifiedResources (enabled by default), OR + // 2. In AutoMigratedUnifiedResources (auto-migrated because count is below threshold) + if !setting.MigratedUnifiedResources[resourceKey] && !setting.AutoMigratedUnifiedResources[resourceKey] { + shouldExist = false + break + } + } + state.tc.verify(t, helper, shouldExist) }) } @@ -270,7 +280,7 @@ const ( var migrationIDsToDefault = map[string]bool{ playlistsID: true, - foldersAndDashboardsID: false, + foldersAndDashboardsID: true, // Auto-migrated when resource count is below threshold } func verifyRegisteredMigrations(t *testing.T, helper *apis.K8sTestHelper, onlyDefault bool, optOut bool) { diff --git a/pkg/storage/unified/migrations/resource_migration.go b/pkg/storage/unified/migrations/resource_migration.go index 5bbed9c5722..d551ae32bde 100644 --- a/pkg/storage/unified/migrations/resource_migration.go +++ b/pkg/storage/unified/migrations/resource_migration.go @@ -10,9 +10,11 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/xorm" + "github.com/grafana/grafana/pkg/util/xorm/core" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -31,6 +33,20 @@ type ResourceMigration struct { migrationID string validators []Validator // Optional: custom validation logic for this migration log log.Logger + cfg *setting.Cfg + autoMigrate bool // If true, auto-migrate resource if count is below threshold + hadErrors bool // Tracks if errors occurred during migration (used with ignoreErrors) +} + +// ResourceMigrationOption is a functional option for configuring ResourceMigration. +type ResourceMigrationOption func(*ResourceMigration) + +// WithAutoMigrate configures the migration to auto-migrate resource if count is below threshold. +func WithAutoMigrate(cfg *setting.Cfg) ResourceMigrationOption { + return func(m *ResourceMigration) { + m.cfg = cfg + m.autoMigrate = true + } } // NewResourceMigration creates a new migration for the specified resources. @@ -39,14 +55,24 @@ func NewResourceMigration( resources []schema.GroupResource, migrationID string, validators []Validator, + opts ...ResourceMigrationOption, ) *ResourceMigration { - return &ResourceMigration{ + m := &ResourceMigration{ migrator: migrator, resources: resources, migrationID: migrationID, validators: validators, log: log.New("storage.unified.resource_migration." + migrationID), } + for _, opt := range opts { + opt(m) + } + return m +} + +func (m *ResourceMigration) SkipMigrationLog() bool { + // Skip populating the log table if auto-migrate is enabled and errors occurred + return m.autoMigrate && m.hadErrors } var _ migrator.CodeMigration = (*ResourceMigration)(nil) @@ -57,7 +83,23 @@ func (m *ResourceMigration) SQL(_ migrator.Dialect) string { } // Exec implements migrator.CodeMigration interface. Executes the migration across all organizations. -func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { +func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) (err error) { + // Track any errors that occur during migration + defer func() { + if err != nil { + if m.autoMigrate { + m.log.Warn( + `[WARN] Resource migration failed and is currently skipped. +This migration will be enforced in the next major Grafana release, where failures will block startup or resource loading. + +This warning is intended to help you detect and report issues early. +Please investigate the failure and report it to the Grafana team so it can be addressed before the next major release.`, + "error", err) + } + m.hadErrors = true + } + }() + ctx := context.Background() orgs, err := m.getAllOrgs(sess) @@ -75,7 +117,8 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro if mg.Dialect.DriverName() == migrator.SQLite { // reuse transaction in SQLite to avoid "database is locked" errors - tx, err := sess.Tx() + var tx *core.Tx + tx, err = sess.Tx() if err != nil { m.log.Error("Failed to get transaction from session", "error", err) return fmt.Errorf("failed to get transaction: %w", err) @@ -85,12 +128,22 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro } for _, org := range orgs { - if err := m.migrateOrg(ctx, sess, org); err != nil { + if err = m.migrateOrg(ctx, sess, org); err != nil { return err } } + // Auto-enable mode 5 for resources after successful migration + // TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 + if m.autoMigrate { + for _, gr := range m.resources { + m.log.Info("Auto-enabling mode 5 for resource", "resource", gr.Resource+"."+gr.Group) + m.cfg.EnableMode5(gr.Resource + "." + gr.Group) + } + } + m.log.Info("Migration completed successfully for all organizations", "org_count", len(orgs)) + return nil } diff --git a/pkg/storage/unified/migrations/resources.go b/pkg/storage/unified/migrations/resources.go index ea32bb54322..debbb270642 100644 --- a/pkg/storage/unified/migrations/resources.go +++ b/pkg/storage/unified/migrations/resources.go @@ -1,11 +1,13 @@ package migrations import ( + "context" "fmt" v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" playlists "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" @@ -14,69 +16,70 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -type ResourceDefinition struct { - GroupResource schema.GroupResource - MigratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc. +type resourceDefinition struct { + groupResource schema.GroupResource + migratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc. } type migrationDefinition struct { name string + migrationID string // The ID stored in the migration log table (e.g., "playlists migration") resources []string - registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) + registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) } -var resourceRegistry = []ResourceDefinition{ +var resourceRegistry = []resourceDefinition{ { - GroupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE}, - MigratorFunc: "MigrateFolders", + groupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE}, + migratorFunc: "MigrateFolders", }, { - GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE}, - MigratorFunc: "MigrateLibraryPanels", + groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE}, + migratorFunc: "MigrateLibraryPanels", }, { - GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE}, - MigratorFunc: "MigrateDashboards", + groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE}, + migratorFunc: "MigrateDashboards", }, { - GroupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"}, - MigratorFunc: "MigratePlaylists", + groupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"}, + migratorFunc: "MigratePlaylists", }, } var migrationRegistry = []migrationDefinition{ { name: "playlists", + migrationID: "playlists migration", resources: []string{setting.PlaylistResource}, registerFunc: registerPlaylistMigration, }, { name: "folders and dashboards", + migrationID: "folders and dashboards migration", resources: []string{setting.FolderResource, setting.DashboardResource}, registerFunc: registerDashboardAndFolderMigration, }, } -func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) error { +func registerMigrations(ctx context.Context, + cfg *setting.Cfg, + mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + sqlStore db.DB, +) error { for _, migration := range migrationRegistry { - var ( - hasValue bool - allEnabled bool - ) - - for _, res := range migration.resources { - enabled := cfg.UnifiedStorage[res].EnableMigration - if !hasValue { - allEnabled = enabled - hasValue = true - continue - } - if enabled != allEnabled { - return fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources) - } + if shouldAutoMigrate(ctx, migration, cfg, sqlStore) { + migration.registerFunc(mg, migrator, client, WithAutoMigrate(cfg)) + continue } - if !allEnabled { + enabled, err := isMigrationEnabled(migration, cfg) + if err != nil { + return err + } + if !enabled { logger.Info("Migration is disabled in config, skipping", "migration", migration.name) continue } @@ -85,10 +88,193 @@ func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrato return nil } -func getResourceDefinition(group, resource string) *ResourceDefinition { +func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + opts ...ResourceMigrationOption, +) { + foldersDef := getResourceDefinition("folder.grafana.app", "folders") + dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards") + driverName := mg.Dialect.DriverName() + + folderCountValidator := NewCountValidator( + client, + foldersDef.groupResource, + "dashboard", + "org_id = ? and is_folder = true", + driverName, + ) + + dashboardCountValidator := NewCountValidator( + client, + dashboardsDef.groupResource, + "dashboard", + "org_id = ? and is_folder = false", + driverName, + ) + + folderTreeValidator := NewFolderTreeValidator(client, foldersDef.groupResource, driverName) + + dashboardsAndFolders := NewResourceMigration( + migrator, + []schema.GroupResource{foldersDef.groupResource, dashboardsDef.groupResource}, + "folders-dashboards", + []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, + opts..., + ) + mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) +} + +func registerPlaylistMigration(mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + opts ...ResourceMigrationOption, +) { + playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists") + driverName := mg.Dialect.DriverName() + + playlistCountValidator := NewCountValidator( + client, + playlistsDef.groupResource, + "playlist", + "org_id = ?", + driverName, + ) + + playlistsMigration := NewResourceMigration( + migrator, + []schema.GroupResource{playlistsDef.groupResource}, + "playlists", + []Validator{playlistCountValidator}, + opts..., + ) + mg.AddMigration("playlists migration", playlistsMigration) +} + +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +func shouldAutoMigrate(ctx context.Context, migration migrationDefinition, cfg *setting.Cfg, sqlStore db.DB) bool { + autoMigrate := false + + for _, res := range migration.resources { + config := cfg.UnifiedStorageConfig(res) + + if config.DualWriterMode == 5 { + return false + } + + if !setting.AutoMigratedUnifiedResources[res] { + continue + } + + if checkIfAlreadyMigrated(ctx, migration, sqlStore) { + for _, res := range migration.resources { + cfg.EnableMode5(res) + } + logger.Info("Auto-migration already completed, enabling mode 5 for resources", "migration", migration.name) + return true + } + + autoMigrate = true + threshold := int64(setting.DefaultAutoMigrationThreshold) + if config.AutoMigrationThreshold > 0 { + threshold = int64(config.AutoMigrationThreshold) + } + + count, err := countResource(ctx, sqlStore, res) + if err != nil { + logger.Warn("Failed to count resource for auto migration check", "resource", res, "error", err) + return false + } + + logger.Info("Resource count for auto migration check", "resource", res, "count", count, "threshold", threshold) + + if count > threshold { + return false + } + } + + if !autoMigrate { + return false + } + + logger.Info("Auto-migration enabled for migration", "migration", migration.name) + return true +} + +func checkIfAlreadyMigrated(ctx context.Context, migration migrationDefinition, sqlStore db.DB) bool { + if migration.migrationID == "" { + return false + } + + exists, err := migrationExists(ctx, sqlStore, migration.migrationID) + if err != nil { + logger.Warn("Failed to check if migration exists", "migration", migration.name, "error", err) + return false + } + + return exists +} + +func isMigrationEnabled(migration migrationDefinition, cfg *setting.Cfg) (bool, error) { + var ( + hasValue bool + allEnabled bool + ) + + for _, res := range migration.resources { + enabled := cfg.UnifiedStorage[res].EnableMigration + if !hasValue { + allEnabled = enabled + hasValue = true + continue + } + if enabled != allEnabled { + return false, fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources) + } + } + + return allEnabled, nil +} + +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +func countResource(ctx context.Context, sqlStore db.DB, resourceName string) (int64, error) { + var count int64 + err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + switch resourceName { + case setting.DashboardResource: + var err error + count, err = sess.Table("dashboard").Where("is_folder = ?", false).Count() + return err + case setting.FolderResource: + var err error + count, err = sess.Table("dashboard").Where("is_folder = ?", true).Count() + return err + default: + return fmt.Errorf("unknown resource: %s", resourceName) + } + }) + return count, err +} + +const migrationLogTableName = "unifiedstorage_migration_log" + +func migrationExists(ctx context.Context, sqlStore db.DB, migrationID string) (bool, error) { + var count int64 + err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var err error + count, err = sess.Table(migrationLogTableName).Where("migration_id = ?", migrationID).Count() + return err + }) + if err != nil { + return false, fmt.Errorf("failed to check migration existence: %w", err) + } + return count > 0, nil +} + +func getResourceDefinition(group, resource string) *resourceDefinition { for i := range resourceRegistry { r := &resourceRegistry[i] - if r.GroupResource.Group == group && r.GroupResource.Resource == resource { + if r.groupResource.Group == group && r.groupResource.Resource == resource { return r } } @@ -102,8 +288,8 @@ func buildResourceKey(group, resource, namespace string) *resourcepb.ResourceKey } return &resourcepb.ResourceKey{ Namespace: namespace, - Group: def.GroupResource.Group, - Resource: def.GroupResource.Resource, + Group: def.groupResource.Group, + Resource: def.groupResource.Resource, } } @@ -113,7 +299,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource return nil } - switch def.MigratorFunc { + switch def.migratorFunc { case "MigrateFolders": return accessor.MigrateFolders case "MigrateLibraryPanels": @@ -130,7 +316,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource func validateRegisteredResources() error { registeredMap := make(map[string]bool) for _, gr := range resourceRegistry { - key := fmt.Sprintf("%s.%s", gr.GroupResource.Resource, gr.GroupResource.Group) + key := fmt.Sprintf("%s.%s", gr.groupResource.Resource, gr.groupResource.Group) registeredMap[key] = true } diff --git a/pkg/storage/unified/migrations/resources_test.go b/pkg/storage/unified/migrations/resources_test.go index 5a519e9ad62..261cd49e244 100644 --- a/pkg/storage/unified/migrations/resources_test.go +++ b/pkg/storage/unified/migrations/resources_test.go @@ -1,12 +1,15 @@ package migrations import ( + "context" + "strings" "testing" sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" ) // TestRegisterMigrations exercises registerMigrations with various EnableMigration configs using a table-driven test. @@ -14,20 +17,28 @@ func TestRegisterMigrations(t *testing.T) { origRegistry := migrationRegistry t.Cleanup(func() { migrationRegistry = origRegistry }) + // Use fake resource names that are NOT in setting.AutoMigratedUnifiedResources + // to avoid triggering the auto-migrate code path which requires a non-nil sqlStore. + const ( + fakePlaylistResource = "fake.playlists.resource" + fakeFolderResource = "fake.folders.resource" + fakeDashboardResource = "fake.dashboards.resource" + ) + // helper to build a fake registry with custom register funcs that bump counters makeFakeRegistry := func(migrationCalls map[string]int) []migrationDefinition { return []migrationDefinition{ { name: "playlists", - resources: []string{setting.PlaylistResource}, - registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + resources: []string{fakePlaylistResource}, + registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) { migrationCalls["playlists"]++ }, }, { name: "folders and dashboards", - resources: []string{setting.FolderResource, setting.DashboardResource}, - registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + resources: []string{fakeFolderResource, fakeDashboardResource}, + registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) { migrationCalls["folders and dashboards"]++ }, }, @@ -38,7 +49,9 @@ func TestRegisterMigrations(t *testing.T) { makeCfg := func(vals map[string]bool) *setting.Cfg { cfg := &setting.Cfg{UnifiedStorage: make(map[string]setting.UnifiedStorageConfig)} for k, v := range vals { - cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{EnableMigration: v} + cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{ + EnableMigration: v, + } } return cfg } @@ -71,13 +84,13 @@ func TestRegisterMigrations(t *testing.T) { migrationRegistry = makeFakeRegistry(migrationCalls) cfg := makeCfg(map[string]bool{ - setting.PlaylistResource: tt.enablePlaylist, - setting.FolderResource: tt.enableFolder, - setting.DashboardResource: tt.enableDashboard, + fakePlaylistResource: tt.enablePlaylist, + fakeFolderResource: tt.enableFolder, + fakeDashboardResource: tt.enableDashboard, }) // We pass nils for migrator dependencies because our fake registerFuncs don't use them - err := registerMigrations(cfg, nil, nil, nil) + err := registerMigrations(context.Background(), cfg, nil, nil, nil, nil) if tt.wantErr { require.Error(t, err, "expected error for mismatched enablement") @@ -90,3 +103,176 @@ func TestRegisterMigrations(t *testing.T) { }) } } + +// TestResourceMigration_AutoMigrateEnablesMode5 verifies the autoMigrate behavior: +// - When autoMigrate=true AND cfg is set AND storage type is "unified", mode 5 should be enabled +// - In all other cases, mode 5 should NOT be enabled +func TestResourceMigration_AutoMigrateEnablesMode5(t *testing.T) { + // Helper to create a cfg with unified storage type + makeUnifiedCfg := func() *setting.Cfg { + cfg := setting.NewCfg() + cfg.Raw.Section("grafana-apiserver").Key("storage_type").SetValue("unified") + cfg.UnifiedStorage = make(map[string]setting.UnifiedStorageConfig) + return cfg + } + + // Helper to create a cfg with legacy storage type + makeLegacyCfg := func() *setting.Cfg { + cfg := setting.NewCfg() + cfg.Raw.Section("grafana-apiserver").Key("storage_type").SetValue("legacy") + cfg.UnifiedStorage = make(map[string]setting.UnifiedStorageConfig) + return cfg + } + + tests := []struct { + name string + autoMigrate bool + cfg *setting.Cfg + resources []string + wantMode5Enabled bool + description string + }{ + { + name: "autoMigrate enabled with unified storage", + autoMigrate: true, + cfg: makeUnifiedCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: true, + description: "Should enable mode 5 when autoMigrate=true and storage type is unified", + }, + { + name: "autoMigrate disabled with unified storage", + autoMigrate: false, + cfg: makeUnifiedCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when autoMigrate=false", + }, + { + name: "autoMigrate enabled with legacy storage", + autoMigrate: true, + cfg: makeLegacyCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when storage type is legacy", + }, + { + name: "autoMigrate enabled with nil cfg", + autoMigrate: true, + cfg: nil, + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when cfg is nil", + }, + { + name: "autoMigrate enabled with multiple resources", + autoMigrate: true, + cfg: makeUnifiedCfg(), + resources: []string{setting.FolderResource, setting.DashboardResource}, + wantMode5Enabled: true, + description: "Should enable mode 5 for all resources when autoMigrate=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build schema.GroupResource from resource strings + resources := make([]schema.GroupResource, 0, len(tt.resources)) + for _, r := range tt.resources { + parts := strings.SplitN(r, ".", 2) + resources = append(resources, schema.GroupResource{ + Resource: parts[0], + Group: parts[1], + }) + } + + // Create the migration with options + var opts []ResourceMigrationOption + if tt.autoMigrate { + opts = append(opts, WithAutoMigrate(tt.cfg)) + } + + m := NewResourceMigration(nil, resources, "test-auto-migrate", nil, opts...) + + // Simulate what happens at the end of a successful migration + // This is the logic from Exec() that we're testing + if m.autoMigrate && m.cfg != nil && m.cfg.UnifiedStorageType() == "unified" { + for _, gr := range m.resources { + m.cfg.EnableMode5(gr.Resource + "." + gr.Group) + } + } + + // Verify mode 5 was enabled (or not) for each resource + for _, resourceName := range tt.resources { + if tt.cfg == nil { + // If cfg is nil, we can't check - just verify we didn't panic + continue + } + config := tt.cfg.UnifiedStorageConfig(resourceName) + if tt.wantMode5Enabled { + require.Equal(t, 5, int(config.DualWriterMode), "%s: %s", tt.description, resourceName) + require.True(t, config.EnableMigration, "%s: EnableMigration should be true for %s", tt.description, resourceName) + require.True(t, config.DualWriterMigrationDataSyncDisabled, "%s: DualWriterMigrationDataSyncDisabled should be true for %s", tt.description, resourceName) + } else { + require.Equal(t, 0, int(config.DualWriterMode), "%s: mode should be 0 for %s", tt.description, resourceName) + } + } + }) + } +} + +// TestResourceMigration_SkipMigrationLog verifies the SkipMigrationLog behavior: +// - When ignoreErrors=true AND errors occurred (hadErrors=true), skip writing to migration log +// This allows the migration to be re-run on the next startup +// - In all other cases, write to migration log normally +// +// This is important for the folders/dashboards migration which uses WithIgnoreErrors() to handle +// partial failures gracefully while still allowing retry on next startup. +func TestResourceMigration_SkipMigrationLog(t *testing.T) { + tests := []struct { + name string + autoMigrate bool + hadErrors bool + want bool + description string + }{ + { + name: "normal migration success", + autoMigrate: false, + hadErrors: false, + want: false, + description: "Normal successful migration should write to log", + }, + { + name: "ignoreErrors migration success", + autoMigrate: true, + hadErrors: false, + want: false, + description: "Migration with ignoreErrors that succeeds should still write to log", + }, + { + name: "normal migration with errors", + autoMigrate: false, + hadErrors: true, + want: false, + description: "Migration that fails without ignoreErrors should write error to log", + }, + { + name: "ignoreErrors migration with errors - skip log", + autoMigrate: true, + hadErrors: true, + want: true, + description: "Migration with ignoreErrors that has errors should SKIP log to allow retry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &ResourceMigration{ + autoMigrate: tt.autoMigrate, + hadErrors: tt.hadErrors, + } + require.Equal(t, tt.want, m.SkipMigrationLog(), tt.description) + }) + } +} diff --git a/pkg/storage/unified/migrations/service.go b/pkg/storage/unified/migrations/service.go index 4ac9cdad218..8dec7ed019e 100644 --- a/pkg/storage/unified/migrations/service.go +++ b/pkg/storage/unified/migrations/service.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" - "k8s.io/apimachinery/pkg/runtime/schema" ) var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") @@ -54,6 +53,7 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error { logger.Info("Data migrations are disabled, skipping") return nil } + logger.Info("Running migrations for unified storage") metrics.MUnifiedStorageMigrationStatus.Set(3) return RegisterMigrations(ctx, p.migrator, p.cfg, p.sqlStore, p.client) @@ -79,7 +79,7 @@ func RegisterMigrations( return err } - if err := registerMigrations(cfg, mg, migrator, client); err != nil { + if err := registerMigrations(ctx, cfg, mg, migrator, client, sqlStore); err != nil { return err } @@ -92,65 +92,13 @@ func RegisterMigrations( db.SetMaxOpenConns(3) defer db.SetMaxOpenConns(maxOpenConns) } - if err := mg.RunMigrations(ctx, + err := mg.RunMigrations(ctx, sec.Key("migration_locking").MustBool(true), - sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { + sec.Key("locking_attempt_timeout_sec").MustInt()) + if err != nil { return fmt.Errorf("unified storage data migration failed: %w", err) } logger.Info("Unified storage migrations completed successfully") return nil } - -func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { - foldersDef := getResourceDefinition("folder.grafana.app", "folders") - dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards") - driverName := mg.Dialect.DriverName() - - folderCountValidator := NewCountValidator( - client, - foldersDef.GroupResource, - "dashboard", - "org_id = ? and is_folder = true", - driverName, - ) - - dashboardCountValidator := NewCountValidator( - client, - dashboardsDef.GroupResource, - "dashboard", - "org_id = ? and is_folder = false", - driverName, - ) - - folderTreeValidator := NewFolderTreeValidator(client, foldersDef.GroupResource, driverName) - - dashboardsAndFolders := NewResourceMigration( - migrator, - []schema.GroupResource{foldersDef.GroupResource, dashboardsDef.GroupResource}, - "folders-dashboards", - []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, - ) - mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) -} - -func registerPlaylistMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { - playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists") - driverName := mg.Dialect.DriverName() - - playlistCountValidator := NewCountValidator( - client, - playlistsDef.GroupResource, - "playlist", - "org_id = ?", - driverName, - ) - - playlistsMigration := NewResourceMigration( - migrator, - []schema.GroupResource{playlistsDef.GroupResource}, - "playlists", - []Validator{playlistCountValidator}, - ) - mg.AddMigration("playlists migration", playlistsMigration) -} diff --git a/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go b/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go new file mode 100644 index 00000000000..4a542b3ae0b --- /dev/null +++ b/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go @@ -0,0 +1,211 @@ +package threshold + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// TODO: remove this test before Grafana 13 GA +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// TestIntegrationAutoMigrateThresholdExceeded verifies that auto-migration is skipped when +// resource count exceeds the configured threshold. +// TODO: remove this test before Grafana 13 GA +func TestIntegrationAutoMigrateThresholdExceeded(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if db.IsTestDbSQLite() { + // Share the same SQLite DB file between steps + tmpDir := t.TempDir() + dbPath := tmpDir + "/shared-threshold-test.db" + + oldVal := os.Getenv("SQLITE_TEST_DB") + require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath)) + t.Cleanup(func() { + if oldVal == "" { + _ = os.Unsetenv("SQLITE_TEST_DB") + } else { + _ = os.Setenv("SQLITE_TEST_DB", oldVal) + } + }) + t.Logf("Using shared database path: %s", dbPath) + } + + var org1 *apis.OrgUsers + var orgB *apis.OrgUsers + + dashboardGVR := schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + } + folderGVR := schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + } + + dashboardKey := fmt.Sprintf("%s.%s", dashboardGVR.Resource, dashboardGVR.Group) + folderKey := fmt.Sprintf("%s.%s", folderGVR.Resource, folderGVR.Group) + playlistKey := "playlists.playlist.grafana.app" + + // Step 1: Create resources exceeding the threshold (3 resources, threshold=1) + t.Run("Step 1: Create resources exceeding threshold", func(t *testing.T) { + unifiedConfig := map[string]setting.UnifiedStorageConfig{} + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }) + org1 = &helper.Org1 + orgB = &helper.OrgB + + // Create 3 dashboards + for i := 1; i <= 3; i++ { + createTestDashboard(t, helper, fmt.Sprintf("Threshold Dashboard %d", i)) + } + + // Create 3 folders + for i := 1; i <= 3; i++ { + createTestFolder(t, helper, fmt.Sprintf("folder-%d", i), fmt.Sprintf("Threshold Folder %d", i), "") + } + + // Explicitly shutdown helper before Step 1 ends to ensure database is properly closed + helper.Shutdown() + }) + + // Set SKIP_DB_TRUNCATE to prevent truncation in subsequent steps + oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE") + require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true")) + t.Cleanup(func() { + if oldSkipTruncate == "" { + _ = os.Unsetenv("SKIP_DB_TRUNCATE") + } else { + _ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate) + } + }) + + // Step 2: Verify auto-migration is skipped due to threshold + t.Run("Step 2: Verify auto-migration skipped (threshold exceeded)", func(t *testing.T) { + // Set threshold=1, but we have 3 resources of each type, so migration should be skipped + // Disable playlists migration since we're only testing dashboard/folder threshold behavior + unifiedConfig := map[string]setting.UnifiedStorageConfig{ + dashboardKey: {AutoMigrationThreshold: 1, EnableMigration: false}, + folderKey: {AutoMigrationThreshold: 1, EnableMigration: false}, + playlistKey: {EnableMigration: false}, + } + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: false, // Allow migration system to run + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + namespace := authlib.OrgNamespaceFormatter(helper.Org1.OrgID) + + dashCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: dashboardGVR, + }) + verifyResourceCount(t, dashCli, 3) + + folderCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: folderGVR, + }) + verifyResourceCount(t, folderCli, 3) + + // Verify migration did NOT run by checking the migration log + count, err := helper.GetEnv().SQLStore.GetEngine().Table("unifiedstorage_migration_log"). + Where("migration_id = ?", "folders and dashboards migration"). + Count() + require.NoError(t, err) + require.Equal(t, int64(0), count, "Migration should not have run") + }) +} + +func createTestDashboard(t *testing.T, helper *apis.K8sTestHelper, title string) string { + t.Helper() + + payload := fmt.Sprintf(`{"dashboard": {"title": "%s", "panels": []}, "overwrite": false}`, title) + + result := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "POST", + Path: "/api/dashboards/db", + Body: []byte(payload), + }, &map[string]interface{}{}) + + require.NotNil(t, result.Response) + require.Equal(t, 200, result.Response.StatusCode) + + uid := (*result.Result)["uid"].(string) + require.NotEmpty(t, uid) + return uid +} + +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + return folderCreate.Result +} + +// verifyResourceCount verifies that the expected number of resources exist in K8s storage +func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) { + t.Helper() + + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + resources, err := meta.ExtractList(l) + require.NoError(t, err) + require.Equal(t, expectedCount, len(resources)) +} diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql index 8372eb73463..72026e26362 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql @@ -12,7 +12,7 @@ INSERT INTO {{ .Ident .TableName }} VALUES ( {{ .Arg .GUID }}, {{ .Arg .KeyPath }}, - COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Value }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, {{ .Arg .Namespace }}, diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql index 1f58bd28b43..2c034f4d757 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql @@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }} {{ .Ident "previous_resource_version" }} ) VALUES ( - COALESCE({{ .Arg .Value }}, ""), + (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}), {{ .Arg .GUID }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, @@ -19,13 +19,5 @@ VALUES ( {{ .Arg .Name }}, {{ .Arg .Action }}, {{ .Arg .Folder }}, - CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( - SELECT {{ .Ident "resource_version" }} - FROM {{ .Ident "resource" }} - WHERE {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} - AND {{ .Ident "name" }} = {{ .Arg .Name }} - ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 - ) END + {{ .Arg .PreviousRV }} ); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql index d52aac5063d..77680d190ff 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -7,38 +7,15 @@ INSERT INTO {{ .Ident "resource_history" }} {{ .Ident "namespace" }}, {{ .Ident "name" }}, {{ .Ident "action" }}, - {{ .Ident "folder" }}, - {{ .Ident "previous_resource_version" }}, - {{ .Ident "generation" }} + {{ .Ident "folder" }} ) VALUES ( - COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Value }}, {{ .Arg .GUID }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, {{ .Arg .Namespace }}, {{ .Arg .Name }}, {{ .Arg .Action }}, - {{ .Arg .Folder }}, - CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( - SELECT {{ .Ident "resource_version" }} - FROM {{ .Ident "resource_history" }} - WHERE {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} - AND {{ .Ident "name" }} = {{ .Arg .Name }} - ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 - ) END, - CASE - WHEN {{ .Arg .Action }} = 1 THEN 1 - WHEN {{ .Arg .Action }} = 3 THEN 0 - ELSE 1 + ( - SELECT COUNT(1) - FROM {{ .Ident "resource_history" }} - WHERE {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} - AND {{ .Ident "name" }} = {{ .Arg .Name }} - ) - END + {{ .Arg .Folder }} ); diff --git a/pkg/storage/unified/resource/data/sqlkv_save_event.sql b/pkg/storage/unified/resource/data/sqlkv_save_event.sql index 669497dbb19..0091c6e1347 100644 --- a/pkg/storage/unified/resource/data/sqlkv_save_event.sql +++ b/pkg/storage/unified/resource/data/sqlkv_save_event.sql @@ -5,7 +5,7 @@ INSERT INTO {{ .Ident .TableName }} ) VALUES ( {{ .Arg .KeyPath }}, - COALESCE({{ .Arg .Value }}, "") + {{ .Arg .Value }} ) {{- if eq .DialectName "mysql" }} ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }} diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql index 1565d0894a4..3c9f92d4de5 100644 --- a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql @@ -1,8 +1,10 @@ UPDATE {{ .Ident "resource" }} SET - {{ .Ident "value" }} = {{ .Arg .Value }}, + {{ .Ident "guid" }} = {{ .Arg .GUID }}, + {{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}), {{ .Ident "action" }} = {{ .Arg .Action }}, - {{ .Ident "folder" }} = {{ .Arg .Folder }} + {{ .Ident "folder" }} = {{ .Arg .Folder }}, + {{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }} WHERE {{ .Ident "group" }} = {{ .Arg .Group }} AND {{ .Ident "resource" }} = {{ .Arg .Resource }} AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql new file mode 100644 index 00000000000..ac1f135fd76 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql @@ -0,0 +1,5 @@ +UPDATE {{ .Ident "resource_history" }} +SET + {{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}, + {{ .Ident "generation" }} = {{ .Arg .Generation }} + WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}; diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 7a1b614323e..931a5b20560 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -12,6 +12,9 @@ import ( "time" "github.com/grafana/grafana/pkg/apimachinery/validation" + "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" gocache "github.com/patrickmn/go-cache" ) @@ -306,10 +309,6 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest return DataKey{}, fmt.Errorf("invalid get request key: %w", err) } - if rv == 0 { - rv = math.MaxInt64 - } - listKey := ListRequestKey(key) iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv}) @@ -598,7 +597,7 @@ func ParseKey(key string) (DataKey, error) { }, nil } -// Temporary while we need to support unified/sql/backend compatibility +// Temporary while we need to support unified/sql/backend compatibility. // Remove once we stop using RvManager in storage_backend.go func ParseKeyWithGUID(key string) (DataKey, error) { parts := strings.Split(key, "/") @@ -815,3 +814,127 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err return results, nil } + +// TODO: remove when backwards compatibility is no longer needed. +var ( + sqlKVUpdateLegacyResourceHistory = mustTemplate("sqlkv_update_legacy_resource_history.sql") + sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") + sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") +) + +// TODO: remove when backwards compatibility is no longer needed. +type sqlKVLegacySaveRequest struct { + sqltemplate.SQLTemplate + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string + PreviousRV int64 +} + +func (req sqlKVLegacySaveRequest) Validate() error { + return nil +} + +// TODO: remove when backwards compatibility is no longer needed. +type sqlKVLegacyUpdateHistoryRequest struct { + sqltemplate.SQLTemplate + GUID string + PreviousRV int64 + Generation int64 +} + +func (req sqlKVLegacyUpdateHistoryRequest) Validate() error { + return nil +} + +// applyBackwardsCompatibleChanges updates the `resource` and `resource_history` tables +// to make sure the sqlkv implementation is backwards-compatible with the existing sql backend. +// Specifically, it will update the `resource_history` table to include the previous resource version +// and generation, which come from the `WriteEvent`, and also make the corresponding change on the +// `resource` table, no longer used in the storage backend. +// +// TODO: remove when backwards compatibility is no longer needed. +func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.Tx, event WriteEvent, key DataKey) error { + kv, isSQLKV := d.kv.(*sqlKV) + if !isSQLKV { + return nil + } + + generation := event.Object.GetGeneration() + if key.Action == DataActionDeleted { + generation = 0 + } + _, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + GUID: key.GUID, + PreviousRV: event.PreviousRV, + Generation: generation, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err) + } + + var action int64 + switch key.Action { + case DataActionCreated: + action = 1 + case DataActionUpdated: + action = 2 + case DataActionDeleted: + action = 3 + } + + switch key.Action { + case DataActionCreated: + _, err := dbutil.Exec(ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + GUID: key.GUID, + Group: key.Group, + Resource: key.Resource, + Namespace: key.Namespace, + Name: key.Name, + Action: action, + Folder: key.Folder, + PreviousRV: event.PreviousRV, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err) + } + case DataActionUpdated: + _, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + GUID: key.GUID, + Group: key.Group, + Resource: key.Resource, + Namespace: key.Namespace, + Name: key.Name, + Action: action, + Folder: key.Folder, + PreviousRV: event.PreviousRV, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to update resource: %w", err) + } + case DataActionDeleted: + _, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + Group: key.Group, + Resource: key.Resource, + Namespace: key.Namespace, + Name: key.Name, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to delete from resource: %w", err) + } + } + + return nil +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 7c890e72b00..d722ce1ee9f 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/util/scheduler" ) @@ -815,7 +816,7 @@ func (s *server) update(ctx context.Context, user claims.AuthInfo, req *resource // TODO: once we know the client is always sending the RV, require ResourceVersion > 0 // See: https://github.com/grafana/grafana/pull/111866 - if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && !rvmanager.IsRvEqual(latest.ResourceVersion, req.ResourceVersion) { return &resourcepb.UpdateResponse{ Error: &ErrOptimisticLockingFailed, }, nil @@ -883,7 +884,7 @@ func (s *server) delete(ctx context.Context, user claims.AuthInfo, req *resource rsp.Error = latest.Error return rsp, nil } - if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && !rvmanager.IsRvEqual(latest.ResourceVersion, req.ResourceVersion) { rsp.Error = &ErrOptimisticLockingFailed return rsp, nil } diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 6d406294a96..9651c65f2f6 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -44,8 +44,6 @@ var ( sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql") sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql") sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql") - sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") - sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql") sqlKVDelete = mustTemplate("sqlkv_delete.sql") sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") @@ -157,26 +155,6 @@ func (req sqlKVSaveRequest) Validate() error { return req.sqlKVSectionKey.Validate() } -type sqlKVLegacySaveRequest struct { - sqltemplate.SQLTemplate - Value []byte - GUID string - Group string - Resource string - Namespace string - Name string - Action int64 - Folder string -} - -func (req sqlKVLegacySaveRequest) Validate() error { - return nil -} - -func (req sqlKVLegacySaveRequest) Results() ([]byte, error) { - return req.Value, nil -} - type sqlKVKeysRequest struct { sqltemplate.SQLTemplate sqlKVSection @@ -371,6 +349,11 @@ func (w *sqlWriteCloser) Close() error { } w.closed = true + value := w.buf.Bytes() + if value == nil { + // to prevent NOT NULL constraint violations + value = []byte{} + } // do regular kv save: simple key_path + value insert with conflict check. // can only do this on resource_events for now, until we drop the columns in resource_history @@ -378,7 +361,7 @@ func (w *sqlWriteCloser) Close() error { _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -392,7 +375,7 @@ func (w *sqlWriteCloser) Close() error { // used to keep backwards compatibility between sql-based kvstore and unified/sql/backend tx, ok := rvmanager.TxFromCtx(w.ctx) if !ok { - // temporary save for dataStore without rvmanager + // temporary save for dataStore without rvmanager (non backwards-compatible) // we can use the same template as the event one after we: // - move PK from GUID to key_path // - remove all unnecessary columns (or at least their NOT NULL constraints) @@ -402,7 +385,7 @@ func (w *sqlWriteCloser) Close() error { SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, GUID: uuid.New().String(), - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -419,7 +402,7 @@ func (w *sqlWriteCloser) Close() error { _, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -429,11 +412,12 @@ func (w *sqlWriteCloser) Close() error { return nil } - // special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore, - // as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that - // component will be responsible for populating the resource_version and key_path columns - // note that we are not touching resource_version table, neither the resource_version columns or the key_path column - // as the RvManager will be responsible for this + // special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant + // for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that + // component will be responsible for populating the resource_version and key_path columns. + // For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history + // table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the + // storage_backend for the full implementation. dataKey, err := ParseKeyWithGUID(w.sectionKey.Key) if err != nil { return fmt.Errorf("failed to parse key: %w", err) @@ -448,13 +432,13 @@ func (w *sqlWriteCloser) Close() error { case DataActionDeleted: action = 3 default: - return fmt.Errorf("failed to parse key: %w", err) + return fmt.Errorf("failed to parse key: invalid action") } _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), - sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + sqlKVSectionKey: w.sectionKey, // unused: key_path is set by rvmanager + Value: value, GUID: dataKey.GUID, Group: dataKey.Group, Resource: dataKey.Resource, @@ -468,52 +452,6 @@ func (w *sqlWriteCloser) Close() error { return fmt.Errorf("failed to save to resource_history: %w", err) } - switch dataKey.Action { - case DataActionCreated: - _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ - SQLTemplate: sqltemplate.New(w.kv.dialect), - Value: w.buf.Bytes(), - GUID: dataKey.GUID, - Group: dataKey.Group, - Resource: dataKey.Resource, - Namespace: dataKey.Namespace, - Name: dataKey.Name, - Action: action, - Folder: dataKey.Folder, - }) - - if err != nil { - return fmt.Errorf("failed to insert to resource: %w", err) - } - case DataActionUpdated: - _, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ - SQLTemplate: sqltemplate.New(w.kv.dialect), - Value: w.buf.Bytes(), - Group: dataKey.Group, - Resource: dataKey.Resource, - Namespace: dataKey.Namespace, - Name: dataKey.Name, - Action: action, - Folder: dataKey.Folder, - }) - - if err != nil { - return fmt.Errorf("failed to update resource: %w", err) - } - case DataActionDeleted: - _, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ - SQLTemplate: sqltemplate.New(w.kv.dialect), - Group: dataKey.Group, - Resource: dataKey.Resource, - Namespace: dataKey.Namespace, - Name: dataKey.Name, - }) - - if err != nil { - return fmt.Errorf("failed to delete from resource: %w", err) - } - } - return nil } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 13f2b9d6159..55843905c72 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -332,18 +332,21 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in dataKey.GUID = uuid.New().String() var err error rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) { - err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)) - if err != nil { + if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil { return "", fmt.Errorf("failed to write data: %w", err) } + if err := k.dataStore.applyBackwardsCompatibleChanges(ctx, tx, event, dataKey); err != nil { + return "", fmt.Errorf("failed to apply backwards compatible updates: %w", err) + } + return dataKey.GUID, nil }) if err != nil { return 0, fmt.Errorf("failed to write data: %w", err) } - dataKey.ResourceVersion = rv + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(rv) } else { err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) if err != nil { @@ -369,22 +372,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened - if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { + if latestKey.ResourceVersion != dataKey.ResourceVersion { // Delete the data we just wrote since it's not the latest - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected") } if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) { // Another concurrent write happened between our read and write - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion) } @@ -403,12 +398,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened - if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { + if latestKey.ResourceVersion != dataKey.ResourceVersion { // Delete the data we just wrote since it's not the latest - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -416,10 +407,6 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in // Verify that the immediate predecessor is not a create if prevKey.Action == DataActionCreated { // Another concurrent create happened - delete our write and return error - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -431,7 +418,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Group: event.Key.Group, Resource: event.Key.Resource, Name: event.Key.Name, - ResourceVersion: rv, + ResourceVersion: dataKey.ResourceVersion, Action: action, Folder: obj.GetFolder(), PreviousRV: event.PreviousRV, diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index d6ff00a81c0..254c1080653 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -45,7 +45,7 @@ import ( const ( indexStorageMemory = "memory" indexStorageFile = "file" - boltTimeout = "500ms" + boltTimeout = "1s" ) // Keys used to store internal data in index. @@ -417,25 +417,18 @@ func (b *bleveBackend) BuildIndex( // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes) // If we do have an unexpired cached index already, we always build a new index from scratch. if cachedIndex == nil && !rebuild { - result := b.findPreviousFileBasedIndex(resourceDir) - if result != nil && result.IsOpen { - // Index file exists but is opened by another process, fallback to memory. - // Keep the name so we can skip cleanup of that directory. - newIndexType = indexStorageMemory - fileIndexName = result.Name - } else if result != nil && result.Index != nil { - // Found and opened existing index successfully - index = result.Index - fileIndexName = result.Name - indexRV = result.RV + var findErr error + index, fileIndexName, indexRV, findErr = b.findPreviousFileBasedIndex(resourceDir) + if findErr != nil { + return nil, findErr } } - if newIndexType == indexStorageFile && index != nil { + if index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName)) defer closeIndexOnExit(index, "") // Close index, but don't delete directory. - } else if newIndexType == indexStorageFile { + } else { // Building index from scratch. Index name has a time component in it to be unique, but if // we happen to create non-unique name, we bump the time and try again. @@ -462,9 +455,7 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Building index using filesystem", "directory", indexDir) defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } - } - - if newIndexType == indexStorageMemory { + } else { index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion) if err != nil { return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) @@ -567,7 +558,7 @@ func cleanFileSegment(input string) string { return input } -// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName". +// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName". // "skipName" can be empty. func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { entries, err := os.ReadDir(resourceDir) @@ -578,19 +569,19 @@ func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err) return } - for _, ent := range entries { - if ent.IsDir() && ent.Name() != skipName { - indexDir := filepath.Join(resourceDir, ent.Name()) - if !isPathWithinRoot(indexDir, b.opts.Root) { - b.log.Warn("Skipping cleanup of directory", "directory", indexDir) + for _, entry := range entries { + if entry.IsDir() && entry.Name() != skipName { + entryDir := filepath.Join(resourceDir, entry.Name()) + if !isPathWithinRoot(entryDir, b.opts.Root) { + b.log.Warn("Skipping cleanup of directory", "directory", entryDir) continue } - err = os.RemoveAll(indexDir) + err = os.RemoveAll(entryDir) if err != nil { - b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err) + b.log.Error("Unable to remove old index folder", "directory", entryDir, "error", err) } else { - b.log.Info("Removed old index folder", "directory", indexDir) + b.log.Info("Removed old index folder", "directory", entryDir) } } } @@ -637,17 +628,10 @@ func formatIndexName(now time.Time) string { return now.Format("20060102-150405") } -type fileIndex struct { - Index bleve.Index - Name string - RV int64 - IsOpen bool -} - -func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex { +func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64, error) { entries, err := os.ReadDir(resourceDir) if err != nil { - return nil + return nil, "", 0, nil } for _, ent := range entries { @@ -657,14 +641,15 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex indexName := ent.Name() indexDir := filepath.Join(resourceDir, indexName) - idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout}) if err != nil { + // On timeout, the file probably is locked by another process. + // This indicates a setup issue that should be fixed rather than worked around by creating a new index file. if errors.Is(err, bolterrors.ErrTimeout) { - b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir) - return &fileIndex{Name: indexName, IsOpen: true} + b.log.Error("index is locked by another process", "indexDir", indexDir, "err", err) + return nil, "", 0, fmt.Errorf("index is locked by another process: indexDir=%s, err=%w", indexDir, err) } - b.log.Debug("error opening index", "indexDir", indexDir, "err", err) + b.log.Error("error opening index", "indexDir", indexDir, "err", err) continue } @@ -675,14 +660,10 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex continue } - return &fileIndex{ - Index: idx, - Name: indexName, - RV: indexRV, - } + return idx, indexName, indexRV, nil } - return nil + return nil, "", 0, nil } // Stop closes all indexes and stops background tasks. diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index c879440e7b6..d80e35f90e5 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -18,6 +18,7 @@ import ( "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + bolterrors "go.etcd.io/bbolt/errors" "go.uber.org/atomic" "go.uber.org/goleak" @@ -1584,7 +1585,7 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int { return int(cnt) } -func TestBleveBackendFallsBackToMemory(t *testing.T) { +func TestBuildIndexReturnsErrorWhenIndexLocked(t *testing.T) { ns := resource.NamespacedResource{ Namespace: "test", Group: "group", @@ -1605,53 +1606,19 @@ func TestBleveBackendFallsBackToMemory(t *testing.T) { require.Equal(t, indexStorageFile, bleveIdx1.indexStorage) checkOpenIndexes(t, reg1, 0, 1) - // Now create a second backend using the same directory - // This simulates another instance trying to open the same index - backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir)) - - // BuildIndex should detect the file is locked and fallback to memory - index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) - require.NoError(t, err) - require.NotNil(t, index2) - - // Verify second index fell back to in-memory despite size being above file threshold - bleveIdx2, ok := index2.(*bleveIndex) - require.True(t, ok) - require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage) - - // Verify metrics show 1 memory index and 0 file indexes for backend2 - checkOpenIndexes(t, reg2, 1, 0) - - // Verify the in-memory index works correctly - require.Equal(t, 10, docCount(t, index2)) - - // Clean up: close first backend to release the file lock - backend1.Stop() -} - -func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) { - ns := resource.NamespacedResource{ - Namespace: "test", - Group: "group", - Resource: "resource", - } - - tmpDir := t.TempDir() - - backend1, _ := setupBleveBackend(t, withRootDir(tmpDir)) - _, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) - require.NoError(t, err) - // Now create a second backend using the same directory // This simulates another instance trying to open the same index backend2, _ := setupBleveBackend(t, withRootDir(tmpDir)) - // BuildIndex should detect the file is locked and fallback to memory - _, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + // BuildIndex should detect the file is locked and return an error after timeout + now := time.Now() + timeout, err := time.ParseDuration(boltTimeout) require.NoError(t, err) - - // Verify that the index directory still exists (i.e., cleanOldIndexes was skipped) - verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1) + index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.Error(t, err) + require.ErrorIs(t, err, bolterrors.ErrTimeout) + require.Nil(t, index2) + require.GreaterOrEqual(t, time.Since(now).Milliseconds(), timeout.Milliseconds()-500, "BuildIndex should have waited for approximately boltTimeout duration") // Clean up: close first backend to release the file lock backend1.Stop() diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index 0f19ae01d93..8f9be689718 100644 --- a/pkg/storage/unified/sql/db/migrations/resource_mig.go +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -217,5 +217,8 @@ func initResourceTables(mg *migrator.Migrator) string { migrator.ConvertUniqueKeyToPrimaryKey(mg, oldResourceVersionUniqueKey, updatedResourceVersionTable) + mg.AddMigration("Change key_path collation of resource_history in postgres", migrator.NewRawSQLMigration("").Postgres(`ALTER TABLE resource_history ALTER COLUMN key_path TYPE VARCHAR(2048) COLLATE "C";`)) + mg.AddMigration("Change key_path collation of resource_events in postgres", migrator.NewRawSQLMigration("").Postgres(`ALTER TABLE resource_events ALTER COLUMN key_path TYPE VARCHAR(2048) COLLATE "C";`)) + return marker } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index eaf78de0779..166e2dac372 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -107,16 +107,20 @@ func TestIntegrationSQLStorageAndSQLKVCompatibilityTests(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) t.Cleanup(db.CleanupTestDB) + newKvBackend := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { + return unitest.NewTestSqlKvBackend(t, ctx, true) + } + t.Run("IsHA (polling notifier)", func(t *testing.T) { unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, true, 0) - }, nil) + }, newKvBackend, nil) }) t.Run("NotHA (in process notifier)", func(t *testing.T) { unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, false, 0) - }, nil) + }, newKvBackend, nil) }) } diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index 730efe418f6..faf6b70c600 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/bwmarrin/snowflake" "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -44,7 +43,6 @@ const ( TestCreateNewResource = "create new resource" TestGetResourceLastImportTime = "get resource last import time" TestOptimisticLocking = "optimistic locking on concurrent writes" - TestKeyPathGeneration = "key_path generation" ) type NewBackendFunc func(ctx context.Context) resource.StorageBackend @@ -106,37 +104,6 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp } } -func RunSQLStorageBackendCompatibilityTest(t *testing.T, newBackend NewBackendWithDBFunc, opts *TestOptions) { - if opts == nil { - opts = &TestOptions{} - } - - if opts.NSPrefix == "" { - opts.NSPrefix = GenerateRandomNSPrefix() - } - - t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) - - cases := []struct { - name string - fn func(*testing.T, resource.StorageBackend, string, sqldb.DB) - }{ - {TestKeyPathGeneration, runTestIntegrationBackendKeyPathGeneration}, - } - - for _, tc := range cases { - if shouldSkip := opts.SkipTests[tc.name]; shouldSkip { - t.Logf("Skipping test: %s", tc.name) - continue - } - - t.Run(tc.name, func(t *testing.T) { - backend, db := newBackend(context.Background()) - tc.fn(t, backend, opts.NSPrefix, db) - }) - } -} - func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend, nsPrefix string) { ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ Claims: jwt.Claims{ @@ -1759,222 +1726,3 @@ func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.S require.LessOrEqual(t, successes, 1, "at most one create should succeed (errors: %v)", errorMessages) }) } - -func runTestIntegrationBackendKeyPathGeneration(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB) { - ctx := testutil.NewDefaultTestContext(t) - - t.Run("Create resource", func(t *testing.T) { - // Create a test resource - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: nsPrefix + "-default", - Name: "test-playlist-crud", - } - - // Create the K8s unstructured object - testObj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": map[string]interface{}{ - "name": "test-playlist-crud", - "namespace": nsPrefix + "-default", - "uid": "test-uid-crud-123", - }, - "spec": map[string]interface{}{ - "title": "My Test Playlist", - }, - }, - } - - // Get metadata accessor - metaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) - - // Serialize to JSON - jsonBytes, err := testObj.MarshalJSON() - require.NoError(t, err) - - // Create WriteEvent - writeEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: key, - Value: jsonBytes, - Object: metaAccessor, - PreviousRV: 0, // Always 0 for new resources - GUID: "create-guid-crud-123", - } - - // Create the resource using WriteEvent - createRV, err := backend.WriteEvent(ctx, writeEvent) - require.NoError(t, err) - require.Greater(t, createRV, int64(0)) - - // Verify created resource key_path - verifyKeyPath(t, db, ctx, key, "created", createRV, "") - - t.Run("Update resource", func(t *testing.T) { - // Update the resource - testObj.Object["spec"] = map[string]interface{}{ - "title": "My Updated Playlist", - } - - updatedMetaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) - - updatedJsonBytes, err := testObj.MarshalJSON() - require.NoError(t, err) - - updateEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_MODIFIED, - Key: key, - Value: updatedJsonBytes, - Object: updatedMetaAccessor, - PreviousRV: createRV, - GUID: fmt.Sprintf("update-guid-%d", createRV), - } - - // Update the resource - updateRV, err := backend.WriteEvent(ctx, updateEvent) - require.NoError(t, err) - require.Greater(t, updateRV, createRV) - - // Verify updated resource key_path - verifyKeyPath(t, db, ctx, key, "updated", updateRV, "") - - t.Run("Delete resource", func(t *testing.T) { - deleteEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_DELETED, - Key: key, - Value: updatedJsonBytes, // Keep the last known value - Object: updatedMetaAccessor, - PreviousRV: updateRV, - GUID: fmt.Sprintf("delete-guid-%d", updateRV), - } - - // Delete the resource - deleteRV, err := backend.WriteEvent(ctx, deleteEvent) - require.NoError(t, err) - require.Greater(t, deleteRV, updateRV) - - // Verify deleted resource key_path - verifyKeyPath(t, db, ctx, key, "deleted", deleteRV, "") - }) - }) - }) - - t.Run("Resource with folder", func(t *testing.T) { - // Create a resource in a folder - folderKey := &resourcepb.ResourceKey{ - Group: "dashboard.grafana.app", - Resource: "dashboards", - Namespace: nsPrefix + "-default", - Name: "my-dashboard", - } - - // Create dashboard object with folder - dashboardObj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "dashboard.grafana.app/v0alpha1", - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "my-dashboard", - "namespace": nsPrefix + "-default", - "uid": "dash-uid-456", - "annotations": map[string]interface{}{ - "grafana.app/folder": "test-folder", - }, - }, - "spec": map[string]interface{}{ - "title": "My Dashboard", - }, - }, - } - - folderMetaAccessor, err := utils.MetaAccessor(dashboardObj) - require.NoError(t, err) - - folderJsonBytes, err := dashboardObj.MarshalJSON() - require.NoError(t, err) - - folderWriteEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: folderKey, - Value: folderJsonBytes, - Object: folderMetaAccessor, - PreviousRV: 0, - GUID: "folder-guid-456", - } - - // Create the dashboard in folder - folderRV, err := backend.WriteEvent(ctx, folderWriteEvent) - require.NoError(t, err) - require.Greater(t, folderRV, int64(0)) - - // Verify folder resource key_path includes folder - verifyKeyPath(t, db, ctx, folderKey, "created", folderRV, "test-folder") - }) -} - -// verifyKeyPath is a helper function to verify key_path generation -func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { - var query string - if db.DriverName() == "postgres" { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3" - } else { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?" - } - rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion) - require.NoError(t, err) - - require.True(t, rows.Next()) - - var keyPath string - var actualRV int64 - var actualAction int - var actualFolder string - - err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) - require.NoError(t, err) - err = rows.Close() - require.NoError(t, err) - - // Verify basic key_path format - require.Contains(t, keyPath, "unified/data/") - require.Contains(t, keyPath, key.Group) - require.Contains(t, keyPath, key.Resource) - require.Contains(t, keyPath, key.Namespace) - require.Contains(t, keyPath, key.Name) - - // Verify action suffix - require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) - - // Verify snowflake calculation - expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000) - require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), fmt.Sprintf("actual RV: %d", actualRV)) - - // Verify folder if specified - if expectedFolder != "" { - require.Equal(t, expectedFolder, actualFolder) - require.Contains(t, keyPath, expectedFolder) - } - - // Verify action code matches - var expectedActionCode int - switch action { - case "created": - expectedActionCode = 1 - case "updated": - expectedActionCode = 2 - case "deleted": - expectedActionCode = 3 - } - require.Equal(t, expectedActionCode, actualAction) - - t.Logf("Action: %s, RV: %d, Snowflake: %d", action, resourceVersion, expectedSnowflake) - t.Logf("Key_path: %s", keyPath) - if expectedFolder != "" { - t.Logf("Folder: %s", actualFolder) - } -} diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go new file mode 100644 index 00000000000..6584992f3cd --- /dev/null +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -0,0 +1,1255 @@ +package test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/bwmarrin/snowflake" + "github.com/stretchr/testify/require" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func NewTestSqlKvBackend(t *testing.T, ctx context.Context, withRvManager bool) (resource.KVBackend, sqldb.DB) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + db, err := eDB.Init(ctx) + require.NoError(t, err) + + kvOpts := resource.KVBackendOptions{ + KvStore: kv, + } + + if withRvManager { + dialect := sqltemplate.DialectForDriver(db.DriverName()) + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ + Dialect: dialect, + DB: db, + }) + require.NoError(t, err) + + kvOpts.RvManager = rvManager + } + + backend, err := resource.NewKVStorageBackend(kvOpts) + require.NoError(t, err) + return backend, db +} + +func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBackend NewBackendWithDBFunc, opts *TestOptions) { + if opts == nil { + opts = &TestOptions{} + } + + if opts.NSPrefix == "" { + opts.NSPrefix = GenerateRandomNSPrefix() + } + + t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) + + cases := []struct { + name string + fn func(*testing.T, resource.StorageBackend, resource.StorageBackend, string, sqldb.DB) + }{ + {"key_path generation", runTestIntegrationBackendKeyPathGeneration}, + {"sql backend fields compatibility", runTestSQLBackendFieldsCompatibility}, + {"cross backend consistency", runTestCrossBackendConsistency}, + {"concurrent operations stress", runTestConcurrentOperationsStress}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if opts.SkipTests[tc.name] { + t.Skip() + } + + kvbackend, db := newKvBackend(t.Context()) + sqlbackend, _ := newSqlBackend(t.Context()) + tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db) + }) + } +} + +func runTestIntegrationBackendKeyPathGeneration(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Test SQL backend with 3 writes, 3 updates, 3 deletes + t.Run("SQL Backend Operations", func(t *testing.T) { + runKeyPathTest(t, sqlBackend, nsPrefix+"-sql", db, ctx) + }) + + // Test SQL KV backend with 3 writes, 3 updates, 3 deletes + t.Run("SQL KV Backend Operations", func(t *testing.T) { + runKeyPathTest(t, kvBackend, nsPrefix+"-kv", db, ctx) + }) +} + +// runKeyPathTest performs 3 writes, 3 updates, and 3 deletes on a backend then verifies that key_path is properly +// generated across both backends +func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB, ctx context.Context) { + // Create storage server from backend + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: backend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Track the current resource version for each resource (index 0, 1, 2 for resources 1, 2, 3) + currentRVs := make([]int64, 3) + + // Create 3 resources + for i := 1; i <= 3; i++ { + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation + } + + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 1, + Title: fmt.Sprintf("My Test Playlist %d", i), + Folder: folder, + } + + created := createPlaylistResource(t, server, ctx, opts) + currentRVs[i-1] = created.ResourceVersion + + // Verify created resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "") + } + } + + // Update the 3 resources + for i := 1; i <= 3; i++ { + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation + } + + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 2, + Title: fmt.Sprintf("My Updated Playlist %d", i), + Folder: folder, + } + + updated := updatePlaylistResource(t, server, ctx, opts, currentRVs[i-1]) + currentRVs[i-1] = updated.ResourceVersion // Update to the latest resource version + + // Verify updated resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "") + } + } + + // Delete the 3 resources + for i := 1; i <= 3; i++ { + name := fmt.Sprintf("test-playlist-%d", i) + deleted := deletePlaylistResource(t, server, ctx, nsPrefix, name, currentRVs[i-1]) + + // Verify deleted resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, name) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "") + } + } +} + +// verifyKeyPath is a helper function to verify key_path generation +func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { + var query string + if db.DriverName() == "postgres" { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3" + } else { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?" + } + rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion) + require.NoError(t, err) + + require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table") + + var keyPath string + var actualRV int64 + var actualAction int + var actualFolder string + + err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) + require.NoError(t, err) + + // Ensure there's exactly one row and no errors + require.False(t, rows.Next()) + require.NoError(t, rows.Err()) + + // Verify basic key_path format + require.Contains(t, keyPath, "unified/data/") + require.Contains(t, keyPath, key.Group) + require.Contains(t, keyPath, key.Resource) + require.Contains(t, keyPath, key.Namespace) + require.Contains(t, keyPath, key.Name) + + // Verify action suffix + require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) + + // Verify snowflake calculation + expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000) + require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), "actual RV: %d", actualRV) + + // Verify folder if specified + if expectedFolder != "" { + require.Equal(t, expectedFolder, actualFolder) + require.Contains(t, keyPath, expectedFolder) + } + + // Verify action code matches + var expectedActionCode int + switch action { + case "created": + expectedActionCode = 1 + case "updated": + expectedActionCode = 2 + case "deleted": + expectedActionCode = 3 + } + require.Equal(t, expectedActionCode, actualAction) +} + +// runTestSQLBackendFieldsCompatibility tests that KV backend with RvManager populates all SQL backend legacy fields +func runTestSQLBackendFieldsCompatibility(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create unique namespace for isolation + namespace := nsPrefix + "-fields-test" + + // Test SQL backend with 3 resources through complete lifecycle + t.Run("SQL Backend Operations", func(t *testing.T) { + runSQLBackendFieldsTest(t, sqlBackend, namespace+"-sql", db, ctx) + }) + + // Test KV backend with 3 resources through complete lifecycle + t.Run("KV Backend Operations", func(t *testing.T) { + runSQLBackendFieldsTest(t, kvBackend, namespace+"-kv", db, ctx) + }) +} + +// buildCrossDatabaseQuery converts query placeholders for different database drivers +func buildCrossDatabaseQuery(driverName, baseQuery string) string { + if driverName == "postgres" { + // Convert ? placeholders to $1, $2, etc. for PostgreSQL + placeholderCount := 1 + result := baseQuery + for { + oldResult := result + result = strings.Replace(result, "?", fmt.Sprintf("$%d", placeholderCount), 1) + if result == oldResult { + break + } + placeholderCount++ + } + return result + } + // MySQL and SQLite use ? placeholders + return baseQuery +} + +// runSQLBackendFieldsTest performs complete resource lifecycle testing and verifies all legacy SQL fields +func runSQLBackendFieldsTest(t *testing.T, backend resource.StorageBackend, namespace string, db sqldb.DB, ctx context.Context) { + // Create storage server from backend + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: backend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Resource definitions with different folder configurations + resources := []struct { + name string + folder string + }{ + {"test-resource-1", ""}, // No folder + {"test-resource-2", "test-folder"}, // With folder + {"test-resource-3", ""}, // No folder + } + + // Track resource versions for each resource + resourceVersions := make([][]int64, len(resources)) // [resourceIndex][versionIndex] + + // Create 3 resources + for i, res := range resources { + // Create the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 1, + Title: fmt.Sprintf("Test Playlist %d", i+1), + Folder: res.folder, + } + + created := createPlaylistResource(t, server, ctx, opts) + // Store the resource version + resourceVersions[i] = append(resourceVersions[i], created.ResourceVersion) + } + + // Update 3 resources + for i, res := range resources { + // Update the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 2, + Title: fmt.Sprintf("Updated Test Playlist %d", i+1), + Folder: res.folder, + } + + currentRV := resourceVersions[i][len(resourceVersions[i])-1] + updated := updatePlaylistResource(t, server, ctx, opts, currentRV) + // Store the new resource version + resourceVersions[i] = append(resourceVersions[i], updated.ResourceVersion) + } + + // Delete first 2 resources (leave the last one to validate resource table) + for i, res := range resources[:2] { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: res.name, + } + + // Delete the resource using the current resource version + currentRV := resourceVersions[i][len(resourceVersions[i])-1] + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: currentRV, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, currentRV) + + // Store the delete resource version + resourceVersions[i] = append(resourceVersions[i], deleted.ResourceVersion) + } + + // Verify all legacy SQL fields are populated correctly + verifyResourceHistoryTable(t, db, namespace, resources, resourceVersions) + verifyResourceTable(t, db, namespace, resources, resourceVersions) + verifyResourceVersionTable(t, db, namespace, resources, resourceVersions) +} + +// ResourceHistoryRecord represents a row from the resource_history table +type ResourceHistoryRecord struct { + GUID string + Group string + Resource string + Namespace string + Name string + Value string + Action int + Folder string + PreviousResourceVersion int64 + Generation int + ResourceVersion int64 +} + +// ResourceRecord represents a row from the resource table +type ResourceRecord struct { + GUID string + Group string + Resource string + Namespace string + Name string + Value string + Action int + Folder string + PreviousResourceVersion int64 + ResourceVersion int64 +} + +// ResourceVersionRecord represents a row from the resource_version table +type ResourceVersionRecord struct { + Group string + Resource string + ResourceVersion int64 +} + +// verifyResourceHistoryTable validates all resource_history entries +func verifyResourceHistoryTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT guid, "group", resource, namespace, name, value, action, folder, + previous_resource_version, generation, resource_version + FROM resource_history + WHERE namespace = ? + ORDER BY resource_version ASC + `) + + rows, err := db.QueryContext(ctx, query, namespace) + require.NoError(t, err) + defer func() { + _ = rows.Close() + }() + + var records []ResourceHistoryRecord + for rows.Next() { + var record ResourceHistoryRecord + err := rows.Scan( + &record.GUID, &record.Group, &record.Resource, &record.Namespace, &record.Name, + &record.Value, &record.Action, &record.Folder, &record.PreviousResourceVersion, + &record.Generation, &record.ResourceVersion, + ) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect 8 records total: 3 creates + 3 updates + 2 deletes + require.Len(t, records, 8, "Expected 8 resource_history records (3 creates + 3 updates + 2 deletes)") + + // Verify each record - we'll validate in the order they were created (by resource_version) + // The records are already sorted by resource_version ASC, so we just need to verify each one + recordIndex := 0 + for resourceIdx, res := range resources { + // Check create record (action=1, generation=1) + createRecord := records[recordIndex] + verifyResourceHistoryRecord(t, createRecord, res, resourceIdx, 1, 0, 1, resourceVersions[resourceIdx][0]) + recordIndex++ + } + + for resourceIdx, res := range resources { + // Check update record (action=2, generation=2) + updateRecord := records[recordIndex] + verifyResourceHistoryRecord(t, updateRecord, res, resourceIdx, 2, resourceVersions[resourceIdx][0], 2, resourceVersions[resourceIdx][1]) + recordIndex++ + } + + for resourceIdx, res := range resources[:2] { + // Check delete record (action=3, generation=0) - only first 2 resources were deleted + deleteRecord := records[recordIndex] + verifyResourceHistoryRecord(t, deleteRecord, res, resourceIdx, 3, resourceVersions[resourceIdx][1], 0, resourceVersions[resourceIdx][2]) + recordIndex++ + } +} + +// verifyResourceHistoryRecord validates a single resource_history record +func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, expectedRes struct{ name, folder string }, resourceIdx, expectedAction int, expectedPrevRV int64, expectedGeneration int, expectedRV int64) { + // Validate GUID (should be non-empty) + require.NotEmpty(t, record.GUID, "GUID should not be empty") + + // Validate group/resource/namespace/name + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + require.Equal(t, expectedRes.name, record.Name) + + // Validate value contains expected JSON - server modifies/formats the JSON differently for different operations + // Check for both formats (with and without space after colon) + nameFound := strings.Contains(record.Value, fmt.Sprintf(`"name": "%s"`, expectedRes.name)) || + strings.Contains(record.Value, fmt.Sprintf(`"name":"%s"`, expectedRes.name)) + require.True(t, nameFound, "JSON should contain the expected name field") + + kindFound := strings.Contains(record.Value, `"kind": "Playlist"`) || + strings.Contains(record.Value, `"kind":"Playlist"`) + require.True(t, kindFound, "JSON should contain the expected kind field") + + // Validate action + require.Equal(t, expectedAction, record.Action) + + // Validate folder + if expectedRes.folder == "" { + require.Equal(t, "", record.Folder, "Folder should be empty when no folder annotation") + } else { + require.Equal(t, expectedRes.folder, record.Folder, "Folder should match annotation") + } + + // Validate previous_resource_version + // For KV backend operations, resource versions are stored as snowflake format + // but expectedPrevRV is in microsecond format, so we need to use IsRvEqual for comparison + if strings.Contains(record.Namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(record.PreviousResourceVersion, expectedPrevRV), + "Previous resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedPrevRV, record.PreviousResourceVersion) + } + + // Validate generation: 1 for create, 2 for update, 0 for delete + require.Equal(t, expectedGeneration, record.Generation) + + // Validate resource_version + // For KV backend operations, resource versions are stored as snowflake format + if strings.Contains(record.Namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV), + "Resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedRV, record.ResourceVersion) + } +} + +// verifyResourceTable validates the resource table (latest state only) +func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT guid, "group", resource, namespace, name, value, action, folder, + previous_resource_version, resource_version + FROM resource + WHERE namespace = ? + ORDER BY name ASC + `) + + rows, err := db.QueryContext(ctx, query, namespace) + require.NoError(t, err) + defer func() { + _ = rows.Close() + }() + + var records []ResourceRecord + for rows.Next() { + var record ResourceRecord + err := rows.Scan( + &record.GUID, &record.Group, &record.Resource, &record.Namespace, &record.Name, + &record.Value, &record.Action, &record.Folder, &record.PreviousResourceVersion, + &record.ResourceVersion, + ) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect 1 record since only 2 resources were deleted (the 3rd remains) + require.Len(t, records, 1, "Expected 1 resource record since only 2 resources were deleted") + + // Validate the remaining record (should be the 3rd resource after update) + record := records[0] + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + require.Equal(t, "test-resource-3", record.Name) + + // Should be an update action (2) - resource table stores latest action + require.Equal(t, 2, record.Action) + + // Validate value contains expected JSON + nameFound := strings.Contains(record.Value, fmt.Sprintf(`"name": "%s"`, "test-resource-3")) || + strings.Contains(record.Value, fmt.Sprintf(`"name":"%s"`, "test-resource-3")) + require.True(t, nameFound, "JSON should contain the expected name field") + + kindFound := strings.Contains(record.Value, `"kind": "Playlist"`) || + strings.Contains(record.Value, `"kind":"Playlist"`) + require.True(t, kindFound, "JSON should contain the expected kind field") + + // Folder should be empty (3rd resource has no folder annotation) + require.Equal(t, "", record.Folder, "3rd resource should have no folder") + + // GUID should be non-empty + require.NotEmpty(t, record.GUID, "GUID should not be empty") + + // Resource version should match the expected version for test-resource-3 (updated version) + expectedRV := resourceVersions[2][1] // test-resource-3's update version + if strings.Contains(namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV), + "Resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedRV, record.ResourceVersion) + } +} + +// verifyResourceVersionTable validates the resource_version table +func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT "group", resource, resource_version + FROM resource_version + WHERE "group" = ? AND resource = ? + `) + + // Check that we have exactly one entry for playlist.grafana.app/playlists + rows, err := db.QueryContext(ctx, query, "playlist.grafana.app", "playlists") + require.NoError(t, err) + defer func() { + _ = rows.Close() + }() + + var records []ResourceVersionRecord + for rows.Next() { + var record ResourceVersionRecord + err := rows.Scan(&record.Group, &record.Resource, &record.ResourceVersion) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect exactly 1 record for the group+resource combination + require.Len(t, records, 1, "Expected 1 resource_version record for playlist.grafana.app/playlists") + + record := records[0] + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + + // Find the highest resource version across all resources + var maxRV int64 + for _, rvs := range resourceVersions { + for _, rv := range rvs { + if rv > maxRV { + maxRV = rv + } + } + } + + // The resource_version table should contain the latest RV for the group+resource + // It might be slightly higher due to RV manager operations, so check it's at least our max + require.GreaterOrEqual(t, record.ResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked") + // But it shouldn't be too much higher (within a reasonable range) + require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected") +} + +// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight) +func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespaces for each test phase + sqlNamespace := nsPrefix + "-concurrent-sql" + kvNamespace := nsPrefix + "-concurrent-kv" + + t.Run("Write to SQL, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, sqlServer, kvServer, sqlNamespace+"-writeSQL", ctx, "sql") + }) + + t.Run("Write to KV, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, kvServer, sqlServer, kvNamespace+"-writeKV", ctx, "kv") + }) + + t.Run("Resource Version Consistency", func(t *testing.T) { + runResourceVersionConsistencyTest(t, sqlServer, kvServer, nsPrefix+"-rv-consistency", ctx) + }) +} + +// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends +func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + // Skip on SQLite due to concurrency limitations + if db.DriverName() == "sqlite3" { + t.Skip("Skipping concurrent operations stress test on SQLite") + } + + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespace for mixed operations + mixedNamespace := nsPrefix + "-concurrent-mixed" + + // do a single create using the sql backend to initialize the resource_version table + // without this, both backend may try to insert the same group+resource to the resource_version which breaks the + // tests + initNamespace := mixedNamespace + "-init" + initOpts := PlaylistResourceOptions{ + Name: "init-resource", + Namespace: initNamespace, + UID: "init-uid", + Generation: 1, + Title: "Init Resource", + Folder: "", + } + createPlaylistResource(t, sqlServer, ctx, initOpts) + + // Heavy Mixed Concurrent Operations + t.Run("Mixed Concurrent Operations", func(t *testing.T) { + runMixedConcurrentOperations(t, sqlServer, kvServer, mixedNamespace, ctx) + }) +} + +// runWriteToOneReadFromBoth writes resources to one backend then reads from both to verify consistency +func runWriteToOneReadFromBoth(t *testing.T, writeServer, readServer resource.ResourceServer, namespace string, ctx context.Context, writerBackend string) { + // Create 5 test resources + resourceNames := []string{ + fmt.Sprintf("resource-%s-1", writerBackend), + fmt.Sprintf("resource-%s-2", writerBackend), + fmt.Sprintf("resource-%s-3", writerBackend), + fmt.Sprintf("resource-%s-4", writerBackend), + fmt.Sprintf("resource-%s-5", writerBackend), + } + + createdResourceVersions := make([]int64, len(resourceNames)) + + // Write all resources to the write backend + for i, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "test-uid-%d", + "generation": 1 + }, + "spec": { + "title": "Concurrent Test Playlist %d" + } + }`, resourceName, namespace, i+1, i+1) + + created, err := writeServer.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + createdResourceVersions[i] = created.ResourceVersion + } + + // Add a small delay to ensure data propagates + time.Sleep(10 * time.Millisecond) + + // Read from both backends and compare payloads + for _, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + // Read from write backend + writeResp, err := writeServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from write backend", resourceName) + require.Nil(t, writeResp.Error, "Read error from write backend %s: %s", resourceName, writeResp.Error) + require.Greater(t, writeResp.ResourceVersion, int64(0), "Invalid resource version for %s on write backend", resourceName) + + // Read from read backend + readResp, err := readServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from read backend", resourceName) + require.Nil(t, readResp.Error, "Read error from read backend %s: %s", resourceName, readResp.Error) + require.Greater(t, readResp.ResourceVersion, int64(0), "Invalid resource version for %s on read backend", resourceName) + + // Validate that both backends return identical payload content + require.JSONEq(t, string(writeResp.Value), string(readResp.Value), + "Payload mismatch for resource %s between write and read backends.\nWrite backend: %s\nRead backend: %s", + resourceName, string(writeResp.Value), string(readResp.Value)) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + // Note: rvmanager.IsRvEqual expects snowflake format as first parameter, so we check both orderings + require.True(t, rvmanager.IsRvEqual(writeResp.ResourceVersion, readResp.ResourceVersion) || rvmanager.IsRvEqual(readResp.ResourceVersion, writeResp.ResourceVersion), + "Resource version mismatch for resource %s between backends.\nWrite backend (%s): %d\nRead backend (%s): %d", + resourceName, writerBackend, writeResp.ResourceVersion, getOtherBackendName(writerBackend), readResp.ResourceVersion) + + t.Logf("✓ Resource %s: payload and resource version (%d) consistency verified between %s (write) and %s (read) backends", + resourceName, writeResp.ResourceVersion, writerBackend, getOtherBackendName(writerBackend)) + } + + // Verify List consistency between backends + verifyListConsistencyBetweenServers(t, writeServer, readServer, namespace, len(resourceNames)) +} + +// getOtherBackendName returns the complementary backend name +func getOtherBackendName(backend string) string { + if backend == "sql" { + return "kv" + } + return "sql" +} + +// runMixedConcurrentOperations runs different operations simultaneously on both backends +func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + var wg sync.WaitGroup + errors := make(chan error, 20) + startBarrier := make(chan struct{}) + + // Use higher operation counts to ensure concurrency + opCounts := BackendOperationCounts{ + Creates: 25, + Updates: 15, + Deletes: 10, + } + + // SQL backend operations + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil { + errors <- fmt.Errorf("SQL backend operations failed: %w", err) + } + }() + + // KV backend operations + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil { + errors <- fmt.Errorf("KV backend operations failed: %w", err) + } + }() + + // Start both goroutines simultaneously + close(startBarrier) + + // Wait for operations to complete with timeout + done := make(chan bool) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Operations completed + case <-time.After(10 * time.Second): + t.Fatal("Timeout waiting for mixed concurrent operations") + } + + // Check for errors + close(errors) + for err := range errors { + t.Error(err) + } + + // Allow some time for data propagation + time.Sleep(50 * time.Millisecond) + + // Calculate expected remaining resources based on operation counts + expectedRemaining := opCounts.Creates - opCounts.Deletes // Creates - Deletes = Remaining + + // Verify consistency of resources created by SQL backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-sql", expectedRemaining, false) + + // Verify consistency of resources created by KV backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-kv", expectedRemaining, false) +} + +// BackendOperationCounts defines how many operations of each type to perform +type BackendOperationCounts struct { + Creates int + Updates int + Deletes int +} + +// runBackendOperationsWithCounts performs configurable create, update, delete operations on a backend +func runBackendOperationsWithCounts(ctx context.Context, server resource.ResourceServer, namespace, backendType string, counts BackendOperationCounts) error { + // Create resources + resourceVersions := make([]int64, counts.Creates) + for i := 1; i <= counts.Creates; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 1 + }, + "spec": { + "title": "Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + if err != nil { + return fmt.Errorf("failed to create resource %d: %w", i, err) + } + if created.Error != nil { + return fmt.Errorf("create error for resource %d: %s", i, created.Error.Message) + } + resourceVersions[i-1] = created.ResourceVersion + } + + // Update resources (only update as many as we have, limited by creates and updates count) + updateCount := counts.Updates + if updateCount > counts.Creates { + updateCount = counts.Creates // Can't update more resources than we created + } + for i := 1; i <= updateCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + updatedJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 2 + }, + "spec": { + "title": "Updated Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: []byte(updatedJSON), + ResourceVersion: resourceVersions[i-1], + }) + if err != nil { + return fmt.Errorf("failed to update resource %d: %w", i, err) + } + if updated.Error != nil { + return fmt.Errorf("update error for resource %d: %s", i, updated.Error.Message) + } + resourceVersions[i-1] = updated.ResourceVersion + } + + // Delete resources (only delete as many as we have, limited by creates and deletes count) + deleteCount := counts.Deletes + if deleteCount > updateCount { + deleteCount = updateCount // Can only delete resources that were updated (have latest RV) + } + for i := 1; i <= deleteCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersions[i-1], // Use the resource version from updates + }) + if err != nil { + return fmt.Errorf("failed to delete resource %d: %w", i, err) + } + if deleted.Error != nil { + return fmt.Errorf("delete error for resource %d: %s", i, deleted.Error.Message) + } + } + + return nil +} + +// runResourceVersionConsistencyTest verifies resource version handling across backends +func runResourceVersionConsistencyTest(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + // Create a resource on SQL backend + opts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 1, + Title: "RV Test Playlist", + Folder: "", // No folder + } + + createPlaylistResource(t, sqlServer, ctx, opts) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from KV backend to get the same resource + key := createPlaylistKey(namespace, "rv-test-resource") + kvRead, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvRead.Error) + // Note: Resource versions may differ between backends, but content should be the same + require.Greater(t, kvRead.ResourceVersion, int64(0), "KV backend should return a valid resource version") + + // Read from SQL backend to compare content + sqlReadInitial, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlReadInitial.Error) + require.JSONEq(t, string(sqlReadInitial.Value), string(kvRead.Value), "Both backends should return the same initial content") + + // Update via KV backend + updateOpts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 2, + Title: "Updated RV Test Playlist", + Folder: "", // No folder + } + + updatePlaylistResource(t, kvServer, ctx, updateOpts, kvRead.ResourceVersion) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from SQL backend to verify consistency + sqlRead, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlRead.Error) + // Note: Resource versions may differ, but content should be consistent + require.Greater(t, sqlRead.ResourceVersion, int64(0), "SQL backend should return a valid resource version") + + // Verify both backends return the same content - we need to read from KV again to get the Value + kvReadAfterUpdate, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvReadAfterUpdate.Error) + require.JSONEq(t, string(kvReadAfterUpdate.Value), string(sqlRead.Value), "Both backends should return the same updated content") +} + +// verifyListConsistencyBetweenServers verifies that both servers return consistent list results +func verifyListConsistencyBetweenServers(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int) { + verifyListConsistencyBetweenServersWithRVCheck(t, server1, server2, namespace, expectedCount, true) +} + +// verifyListConsistencyBetweenServersWithRVCheck verifies list consistency with optional resource version checking +func verifyListConsistencyBetweenServersWithRVCheck(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int, checkResourceVersions bool) { + ctx := testutil.NewDefaultTestContext(t) + + // Get lists from both servers + list1, err := server1.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list1.Error) + + list2, err := server2.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list2.Error) + + // Create maps for easier comparison by extracting names from JSON + items1 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list1.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items1[name] = item + } + } + + items2 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list2.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items2[name] = item + } + } + + // Verify counts match after filtering by namespace + require.Equal(t, expectedCount, len(items1), "Server 1 should return expected count after filtering") + require.Equal(t, expectedCount, len(items2), "Server 2 should return expected count after filtering") + require.Equal(t, len(items1), len(items2), "Both servers should return same count after filtering") + + // Verify all items exist in both lists with same content and resource version + for name, item1 := range items1 { + item2, exists := items2[name] + require.True(t, exists, "Item %s should exist in both lists", name) + require.Greater(t, item1.ResourceVersion, int64(0), "Item1 should have valid resource version for %s", name) + require.Greater(t, item2.ResourceVersion, int64(0), "Item2 should have valid resource version for %s", name) + require.JSONEq(t, string(item1.Value), string(item2.Value), "Content should match for %s", name) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + if checkResourceVersions { + require.True(t, rvmanager.IsRvEqual(item1.ResourceVersion, item2.ResourceVersion) || rvmanager.IsRvEqual(item2.ResourceVersion, item1.ResourceVersion), + "Resource version mismatch for item %s between backends. Item1: %d, Item2: %d", name, item1.ResourceVersion, item2.ResourceVersion) + } + } +} + +// extractResourceNameFromJSON extracts the resource name from JSON metadata +func extractResourceNameFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + name, ok := metadata["name"].(string) + require.True(t, ok, "name field not found or not a string") + + return name +} + +// extractResourceNamespaceFromJSON extracts the resource namespace from JSON metadata +func extractResourceNamespaceFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + namespace, ok := metadata["namespace"].(string) + require.True(t, ok, "namespace field not found or not a string") + + return namespace +} + +// PlaylistResourceOptions defines options for creating test playlist resources +type PlaylistResourceOptions struct { + Name string + Namespace string + UID string + Generation int + Title string + Folder string // optional - empty string means no folder +} + +// createPlaylistJSON creates standardized JSON for playlist resources +func createPlaylistJSON(opts PlaylistResourceOptions) []byte { + folderAnnotation := "" + if opts.Folder != "" { + folderAnnotation = fmt.Sprintf(`, + "annotations": { + "grafana.app/folder": "%s" + }`, opts.Folder) + } + + jsonStr := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "%s", + "generation": %d%s + }, + "spec": { + "title": "%s" + } + }`, opts.Name, opts.Namespace, opts.UID, opts.Generation, folderAnnotation, opts.Title) + + return []byte(jsonStr) +} + +// createPlaylistKey creates standardized ResourceKey for playlist resources +func createPlaylistKey(namespace, name string) *resourcepb.ResourceKey { + return &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: name, + } +} + +// createPlaylistResource creates a playlist resource using the server with consistent error handling +func createPlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions) *resourcepb.CreateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: value, + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + + return created +} + +// updatePlaylistResource updates a playlist resource using the server with consistent error handling +func updatePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions, resourceVersion int64) *resourcepb.UpdateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: value, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, updated.Error) + require.Greater(t, updated.ResourceVersion, int64(0)) // Just check it's positive, not necessarily greater than input + + return updated +} + +// deletePlaylistResource deletes a playlist resource using the server with consistent error handling +func deletePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, namespace, name string, resourceVersion int64) *resourcepb.DeleteResponse { + t.Helper() + key := createPlaylistKey(namespace, name) + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, int64(0)) + + return deleted +} diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 70e3b15aa7b..092cd476b52 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -7,11 +7,7 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" - sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" - "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" ) func TestBadgerKVStorageBackend(t *testing.T) { @@ -41,48 +37,35 @@ func TestBadgerKVStorageBackend(t *testing.T) { } func TestSQLKVStorageBackend(t *testing.T) { - newBackendFunc := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { - dbstore := db.InitTestDB(t) - eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) - require.NoError(t, err) - kv, err := resource.NewSQLKV(eDB) - require.NoError(t, err) - kvOpts := resource.KVBackendOptions{ - KvStore: kv, - } - backend, err := resource.NewKVStorageBackend(kvOpts) - require.NoError(t, err) - db, err := eDB.Init(ctx) - require.NoError(t, err) - return backend, db + skipTests := map[string]bool{ + TestHappyPath: true, + TestWatchWriteEvents: true, + TestList: true, + TestBlobSupport: true, + TestGetResourceStats: true, + TestListHistory: true, + TestListHistoryErrorReporting: true, + TestListModifiedSince: true, + TestListTrash: true, + TestCreateNewResource: true, + TestGetResourceLastImportTime: true, + TestOptimisticLocking: true, } - + // without RvManager RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { - backend, _ := newBackendFunc(ctx) + backend, _ := NewTestSqlKvBackend(t, ctx, false) return backend }, &TestOptions{ - NSPrefix: "sqlkvstorage-test", - SkipTests: map[string]bool{ - TestHappyPath: true, - TestWatchWriteEvents: true, - TestList: true, - TestBlobSupport: true, - TestGetResourceStats: true, - TestListHistory: true, - TestListHistoryErrorReporting: true, - TestListModifiedSince: true, - TestListTrash: true, - TestCreateNewResource: true, - TestGetResourceLastImportTime: true, - TestOptimisticLocking: true, - TestKeyPathGeneration: true, - }, + NSPrefix: "sqlkvstorage-test", + SkipTests: skipTests, }) - RunSQLStorageBackendCompatibilityTest(t, newBackendFunc, &TestOptions{ - NSPrefix: "sqlkvstorage-compatibility-test", - SkipTests: map[string]bool{ - TestKeyPathGeneration: true, - }, + // with RvManager + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, true) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-withrvmanager-test", + SkipTests: skipTests, }) } diff --git a/pkg/tests/apis/config_test.go b/pkg/tests/apis/config_test.go deleted file mode 100644 index 665d79a5e50..00000000000 --- a/pkg/tests/apis/config_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package apis - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/grafana/grafana/pkg/util/testutil" -) - -const pluginsDiscoveryJSON = `[ -{ - "version": "v0alpha1", - "freshness": "Current", - "resources": [ - { - "resource": "metas", - "responseKind": { - "group": "", - "kind": "Meta", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "meta", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "Meta", - "version": "" - }, - "subresource": "status", - "verbs": [ - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "get", - "list" - ] - }, - { - "resource": "plugins", - "responseKind": { - "group": "", - "kind": "Plugin", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "plugin", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "Plugin", - "version": "" - }, - "subresource": "status", - "verbs": [ - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "create", - "delete", - "deletecollection", - "get", - "list", - "patch", - "update", - "watch" - ] - } - ] -} -]` - -func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper { - t.Helper() - helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true", - OpenFeatureAPIEnabled: openFeatureAPIEnabled, - }) - t.Cleanup(func() { helper.Shutdown() }) - return helper -} - -func TestIntegrationAPIServerRuntimeConfig(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - t.Run("discovery with openfeature api enabled", func(t *testing.T) { - helper := setupHelper(t, true) - disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app") - require.NoError(t, err) - require.JSONEq(t, `[ - { - "freshness": "Current", - "resources": [ - { - "resource": "noop", - "responseKind": { - "group": "", - "kind": "Status", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "noop", - "verbs": [ - "get" - ] - } - ], - "version": "v0alpha1" - } - ]`, disco) - - // plugins should still be discoverable - disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app") - require.NoError(t, err) - require.JSONEq(t, pluginsDiscoveryJSON, disco) - require.NoError(t, err) - }) - - t.Run("discovery with openfeature api false", func(t *testing.T) { - helper := setupHelper(t, false) - _, err := helper.GetGroupVersionInfoJSON("features.grafana.app") - require.Error(t, err, "expected error when openfeature api is disabled") - - // plugins should still be discoverable - disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app") - require.NoError(t, err) - require.JSONEq(t, pluginsDiscoveryJSON, disco) - require.NoError(t, err) - }) -} diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index ee40bf00b47..96a364cb8bc 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/require" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -132,6 +133,94 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { } } +func TestIntegrationDashboardAPIZanzana(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableAuthZClientCache: true, + DisableZanzanaCache: true, + DisableZanzanaServerCheckQueryCache: true, + ZanzanaReconciliationInterval: 1 * time.Second, + APIServerStorageType: "unified", + DBMaxConns: 10, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + org1Ctx := createTestContext(t, helper, helper.Org1, rest.Mode5) + org2Ctx := createTestContext(t, helper, helper.OrgB, rest.Mode5) + + t.Run("Dashboard permission tests", func(t *testing.T) { + runDashboardPermissionTests(t, org1Ctx, true) + }) + + t.Run("Authorization tests for all identity types", func(t *testing.T) { + runAuthorizationTests(t, org1Ctx) + }) + t.Run("Dashboard HTTP API test", func(t *testing.T) { + runDashboardHttpTest(t, org1Ctx, org2Ctx) + }) + + t.Run("Cross-organization tests", func(t *testing.T) { + runCrossOrgTests(t, org1Ctx, org2Ctx) + }) +} + +// list tests will go very slowly if the cache is disabled - allow the cache solely for Lists +func TestIntegrationDashboardAPIZanzanaList(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + DBMaxConns: 4, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + ZanzanaReconciliationInterval: 100 * time.Millisecond, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + org1Ctx := createTestContext(t, helper, helper.Org1, rest.Mode5) + + runDashboardListTests(t, org1Ctx) +} + // TestIntegrationDashboardAPI tests the dashboard K8s API func TestIntegrationDashboardAPI(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -211,11 +300,11 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with existing UID", func(t *testing.T) { // Create a dashboard with a specific UID specificUID := "existing-uid-dash" - createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID) + createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID, ctx.Helper) require.NoError(t, err) // Try to create another dashboard with the same UID - _, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID) + _, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID, ctx.Helper) require.Error(t, err) // Clean up @@ -227,14 +316,14 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with too long UID", func(t *testing.T) { // Create a dashboard with a long UID (over 40 chars) longUID := "this-uid-is-way-too-long-for-a-dashboard-uid-12345678901234567890" - _, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID) + _, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID, ctx.Helper) require.Error(t, err) }) // Test creating dashboard with invalid UID characters t.Run("reject dashboard with invalid UID characters", func(t *testing.T) { invalidUID := "invalid/uid/with/slashes" - _, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID) + _, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID, ctx.Helper) require.Error(t, err) }) }) @@ -243,21 +332,21 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("Dashboard title validations", func(t *testing.T) { // Test empty title t.Run("reject dashboard with empty title", func(t *testing.T) { - _, err := createDashboard(t, adminClient, "", nil, nil) + _, err := createDashboard(t, adminClient, "", nil, nil, ctx.Helper) require.Error(t, err) }) // Test long title t.Run("reject dashboard with excessively long title", func(t *testing.T) { veryLongTitle := strings.Repeat("a", 10000) - _, err := createDashboard(t, adminClient, veryLongTitle, nil, nil) + _, err := createDashboard(t, adminClient, veryLongTitle, nil, nil, ctx.Helper) require.Error(t, err) }) // Test updating dashboard with empty title t.Run("reject dashboard update with empty title", func(t *testing.T) { // First create a valid dashboard - dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil) + dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -273,7 +362,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test updating dashboard with excessively long title t.Run("reject dashboard update with excessively long title", func(t *testing.T) { // First create a valid dashboard - dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil) + dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -291,7 +380,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("Dashboard message validations", func(t *testing.T) { // Test long message t.Run("reject dashboard with excessively long update message", func(t *testing.T) { - dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil) + dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil, ctx.Helper) require.NoError(t, err) veryLongMessage := strings.Repeat("a", 600) @@ -304,18 +393,78 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { }) }) + t.Run("Dashboard tag validations", func(t *testing.T) { + t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"} + _ = meta.SetSpec(specMap) + _, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + }) + + t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) { + dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper) + require.NoError(t, err) + require.NotNil(t, dash) + meta, _ := utils.MetaAccessor(dash) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"} + _ = meta.SetSpec(specMap) + _, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"} + _ = meta.SetSpec(specMap) + createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDash) + err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{ + "valid-tag", + "another-valid-tag", + "this-is-a-very-long-tag-that-exceeds-fifty-characters-limit", + } + _ = meta.SetSpec(specMap) + _, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + }) + }) + t.Run("Dashboard folder validations", func(t *testing.T) { // Test non-existent folder UID t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) { nonExistentFolderUID := "non-existent-folder-uid" - _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil) + _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil, ctx.Helper) ctx.Helper.EnsureStatusError(err, http.StatusNotFound, "folders.folder.grafana.app \"non-existent-folder-uid\" not found") }) t.Run("allow moving folder to general folder", func(t *testing.T) { folder1 := createFolderObject(t, "folder1", "default", "") folder1UID := folder1.GetName() - dash, err := createDashboard(t, adminClient, "Dashboard in a Folder", &folder1UID, nil) + dash, err := createDashboard(t, adminClient, "Dashboard in a Folder", &folder1UID, nil, ctx.Helper) require.NoError(t, err) generalFolderUID := "" @@ -437,7 +586,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test version increment on update t.Run("version increments on dashboard update", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for version test") dashUID := dash.GetName() @@ -464,7 +613,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test generation conflict when updating concurrently t.Run("reject update with version conflict", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for version conflict test") dashUID := dash.GetName() @@ -517,7 +666,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("dashboard version history available, even for UIDs ending in hyphen", func(t *testing.T) { dashboardUID := "test-dashboard-" - dash, err := createDashboard(t, adminClient, "Dashboard with uid ending in hyphen", nil, &dashboardUID) + dash, err := createDashboard(t, adminClient, "Dashboard with uid ending in hyphen", nil, &dashboardUID, ctx.Helper) require.NoError(t, err) updatedDash, err := updateDashboard(t, adminClient, dash, "Updated dashboard with uid ending in hyphen", nil) @@ -564,7 +713,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for provisioning test") dashUID := dash.GetName() @@ -689,7 +838,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Create a dashboard with a specific UID to make it easier to manage specificUID := "size-limit-test-dash" - dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID) + dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID, ctx.Helper) require.NoError(t, err) meta, _ := utils.MetaAccessor(dash) @@ -877,11 +1026,11 @@ func runQuotaTests(t *testing.T, ctx TestContext) { require.NoError(t, err, "Failed to update quota") // Create first dashboard - should succeed - dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil) + dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create first dashboard") // Create second dashboard - should fail due to quota - _, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil) + _, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil, ctx.Helper) require.Error(t, err, "Creating second dashboard should fail due to quota") require.Contains(t, err.Error(), "quota", "Error should mention quota") @@ -911,6 +1060,8 @@ func runQuotaTests(t *testing.T, ctx TestContext) { // Helper function to create test context for an organization func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.OrgUsers, dualWriterMode rest.DualWriterMode) TestContext { + apis.AwaitZanzanaReconcileNext(t, helper) + // Create test folder folderTitle := "Test Folder Org " + strconv.FormatInt(orgUsers.Admin.Identity.GetOrgID(), 10) testFolder, err := createFolder(t, helper, orgUsers.Admin, folderTitle) @@ -1013,6 +1164,8 @@ func createFolder(t *testing.T, helper *apis.K8sTestHelper, user apis.User, titl return nil, err } + apis.AwaitZanzanaReconcileNext(t, helper) + meta, _ := utils.MetaAccessor(createdFolder) // Create a folder struct to return (for compatibility with existing code) @@ -1087,7 +1240,7 @@ func markDashboardObjectAsProvisioned(t *testing.T, dashboard *unstructured.Unst } // Create a dashboard -func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string) (*unstructured.Unstructured, error) { +func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string, helper *apis.K8sTestHelper) (*unstructured.Unstructured, error) { t.Helper() var folderUIDStr string @@ -1111,6 +1264,8 @@ func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, return nil, err } + apis.AwaitZanzanaReconcileNext(t, helper) + // Fetch the generated object to ensure we're not running into any caching or UID mismatch issues databaseDash, err := client.Resource.Get(context.Background(), createdDash.GetName(), v1.GetOptions{}) if err != nil { @@ -1254,11 +1409,13 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { {name: "in folder", folderUID: ctx.TestFolder.UID}, } + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) + for _, loc := range locations { t.Run(loc.name, func(t *testing.T) { if roleCapabilities.canCreate { // Test can create dashboard - dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil) + dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1274,7 +1431,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { require.NoError(t, err) } else { // Test cannot create dashboard - _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil) + _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil, ctx.Helper) require.Error(t, err) } }) @@ -1284,7 +1441,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard updates t.Run("dashboard update", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1311,7 +1468,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard deletion permissions t.Run("dashboard deletion", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1331,7 +1488,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard viewing for all roles t.Run("dashboard viewing", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1363,7 +1520,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test custom dashboard permissions t.Run("Dashboard with custom permissions", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1394,12 +1551,12 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test dashboard-specific permission overrides (new test case) t.Run("Dashboard-specific permission overrides", func(t *testing.T) { // Create multiple dashboards with admin - dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil) + dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash1) dash1UID := dash1.GetName() - dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil) + dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash2) dash2UID := dash2.GetName() @@ -1443,7 +1600,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo setResourceUserPermission(t, ctx, ctx.AdminUser, false, folderUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) // Create a dashboard in the folder with admin - dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil) + dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1462,7 +1619,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo require.Equal(t, "Updated by Viewer with Folder Permission", meta.FindTitle("")) // User should be able to create a dashboard in the folder - dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil) + dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dashViewer) @@ -1509,7 +1666,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo setResourceUserPermission(t, ctx, ctx.AdminUser, false, folder2UID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) // Have the viewer create a dashboard in folder2 - viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil) + viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil, ctx.Helper) require.NoError(t, err, "Viewer should be able to create dashboard in folder with edit permissions") require.NotNil(t, viewerDash) dashUID := viewerDash.GetName() @@ -1544,7 +1701,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test creator permissions (new test case) t.Run("Creator of dashboard gets admin permission", func(t *testing.T) { // Create a dashboard as an editor user (not admin) - editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, editorCreatedDash) dashUID := editorCreatedDash.GetName() @@ -1575,7 +1732,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo t.Run("Admin can override creator permissions", func(t *testing.T) { t.Skip("Have to double check if that's actually the case") // Create a dashboard as an editor user (not admin) - editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, editorCreatedDash) dashUID := editorCreatedDash.GetName() @@ -1614,7 +1771,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo otherOrgClient := getResourceClient(t, ctx.Helper, ctx.Helper.OrgB.Viewer, getDashboardGVR()) // Create a dashboard with admin in the current org - dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) org1DashUID := dash.GetName() @@ -1703,11 +1860,11 @@ func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { dashTitle := "Cross-Org Dashboard" // Create in org1 - dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid) + dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid, org1Ctx.Helper) require.NoError(t, err, "Failed to create dashboard in org1") // Create in org2 with same UID - should succeed (UIDs only need to be unique within an org) - dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid) + dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid, org2Ctx.Helper) require.NoError(t, err, "Failed to create dashboard with same UID in org2") // Verify both dashboards were created @@ -1793,12 +1950,12 @@ func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { // Test cross-organization access t.Run("Cross-organization access", func(t *testing.T) { // Create dashboards in both orgs - org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil) + org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil, org1Ctx.Helper) require.NoError(t, err) require.NotNil(t, org1Dashboard) org1DashUID := org1Dashboard.GetName() - org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil) + org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil, org2Ctx.Helper) require.NoError(t, err) require.NotNil(t, org2Dashboard) org2DashUID := org2Dashboard.GetName() @@ -1957,6 +2114,8 @@ func setResourceUserPermission(t *testing.T, ctx TestContext, actingUser apis.Us // Check response status code require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for %s", resourceUID) + + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) } // Test creating a dashboard via HTTP and deleting it @@ -2033,6 +2192,7 @@ func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestConte for _, userTC := range userTestCases { testName := fmt.Sprintf("%s by %s", locTC.name, userTC.name) t.Run(testName, func(t *testing.T) { + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) // Create a unique dashboard UID - ensure it's 40 chars max dashboardUID := fmt.Sprintf("test-%s-%s-%s", "POST", @@ -2078,6 +2238,8 @@ func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestConte ContentType: "application/json", }, &struct{}{}) + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) + // Check if the creation was successful or failed as expected adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) @@ -2421,7 +2583,7 @@ func runDashboardListTests(t *testing.T, ctx TestContext) { // Create all test resources (folders, dashboards) in one loop for i, fc := range folderConfigs { // Create root dashboard - rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil) + rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil, ctx.Helper) require.NoError(t, err) rootDashboards[i] = rootDash fc.permissions(t, ctx, rootDash.GetName(), true) @@ -2433,7 +2595,7 @@ func runDashboardListTests(t *testing.T, ctx TestContext) { fc.permissions(t, ctx, folder.UID, false) // Create dashboard in folder - folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil) + folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil, ctx.Helper) require.NoError(t, err) folderDashboards[i] = folderDash } @@ -2594,10 +2756,10 @@ func runDashboardTrashTests(t *testing.T, ctx TestContext) { t.Run("regular dashboards appear in trash but provisioned ones do not", func(t *testing.T) { // create two dashboards, one that is provisioned and one that is not - regularDash, err := createDashboard(t, adminClient, "Regular Dashboard for Trash Comparison", nil, nil) + regularDash, err := createDashboard(t, adminClient, "Regular Dashboard for Trash Comparison", nil, nil, ctx.Helper) require.NoError(t, err) regularDashUID := regularDash.GetName() - provisionedDash, err := createDashboard(t, adminClient, "Provisioned Dashboard for Trash Comparison", nil, nil) + provisionedDash, err := createDashboard(t, adminClient, "Provisioned Dashboard for Trash Comparison", nil, nil, ctx.Helper) require.NoError(t, err) provisionedDashUID := provisionedDash.GetName() meta, err := utils.MetaAccessor(provisionedDash) @@ -2626,7 +2788,7 @@ func runDashboardTrashTests(t *testing.T, ctx TestContext) { }) t.Run("permission checks - admin can see everything, users can see their own deleted items", func(t *testing.T) { - dash, err := createDashboard(t, editorClient, "Dashboard for Trash Test", nil, nil) + dash, err := createDashboard(t, editorClient, "Dashboard for Trash Test", nil, nil, ctx.Helper) require.NoError(t, err) dashUID := dash.GetName() err = editorClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 613d021b236..227dec47d73 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -36,10 +36,12 @@ func TestIntegrationFolderTreeZanzana(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) runIntegrationFolderTree(t, testinfra.GrafanaOpts{ - DisableDataMigrations: true, - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableAuthZClientCache: true, + DisableZanzanaServerCheckQueryCache: true, + APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: grafanarest.Mode5, diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index dab9f3cd8b1..f03b3c3369b 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -87,7 +87,7 @@ "tags": [ "ExternalGroupMapping" ], - "description": "list or watch objects of kind ExternalGroupMapping", + "description": "list objects of kind ExternalGroupMapping", "operationId": "listExternalGroupMapping", "parameters": [ { @@ -8690,32 +8690,6 @@ "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", "type": "string", "format": "date-time" - }, - "io.k8s.apimachinery.pkg.apis.meta.v1.WatchEvent": { - "description": "Event represents a single event to a watched resource.", - "type": "object", - "required": [ - "type", - "object" - ], - "properties": { - "object": { - "description": "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.runtime.RawExtension" - } - ] - }, - "type": { - "type": "string", - "default": "" - } - } - }, - "io.k8s.apimachinery.pkg.runtime.RawExtension": { - "description": "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)", - "type": "object" } } } diff --git a/pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json similarity index 61% rename from pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json rename to pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json index 017323b2a1e..de166b1984d 100644 --- a/pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json @@ -1,10 +1,10 @@ { "openapi": "3.0.0", "info": { - "title": "investigations.grafana.app/v0alpha1" + "title": "logsdrilldown.grafana.app/v1beta1" }, "paths": { - "/apis/investigations.grafana.app/v0alpha1/": { + "/apis/logsdrilldown.grafana.app/v1beta1/": { "get": { "tags": [ "API Discovery" @@ -35,13 +35,13 @@ } } }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigationindexes": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns": { "get": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "list or watch objects of kind InvestigationIndex", - "operationId": "listInvestigationIndex", + "description": "list or watch objects of kind LogsDrilldownDefaultColumns", + "operationId": "listLogsDrilldownDefaultColumns", "parameters": [ { "name": "allowWatchBookmarks", @@ -140,27 +140,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/json;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/vnd.kubernetes.protobuf;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } } } @@ -168,17 +168,17 @@ }, "x-kubernetes-action": "list", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "post": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "create an InvestigationIndex", - "operationId": "createInvestigationIndex", + "description": "create LogsDrilldownDefaultColumns", + "operationId": "createLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -212,17 +212,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -234,17 +234,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -254,17 +254,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -274,17 +274,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -292,17 +292,17 @@ }, "x-kubernetes-action": "post", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "delete": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "delete collection of InvestigationIndex", - "operationId": "deletecollectionInvestigationIndex", + "description": "delete collection of LogsDrilldownDefaultColumns", + "operationId": "deletecollectionLogsDrilldownDefaultColumns", "parameters": [ { "name": "continue", @@ -446,9 +446,9 @@ }, "x-kubernetes-action": "deletecollection", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ @@ -473,30 +473,30 @@ } ] }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigationindexes/{name}": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}": { "get": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "read the specified InvestigationIndex", - "operationId": "getInvestigationIndex", + "description": "read the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumns", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -504,17 +504,17 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "put": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "replace the specified InvestigationIndex", - "operationId": "replaceInvestigationIndex", + "description": "replace the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -548,17 +548,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -570,17 +570,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -590,17 +590,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -608,17 +608,17 @@ }, "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "delete": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "delete an InvestigationIndex", - "operationId": "deleteInvestigationIndex", + "description": "delete LogsDrilldownDefaultColumns", + "operationId": "deleteLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -710,17 +710,17 @@ }, "x-kubernetes-action": "delete", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "patch": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "partially update the specified InvestigationIndex", - "operationId": "updateInvestigationIndex", + "description": "partially update the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -790,17 +790,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -810,17 +810,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -828,16 +828,16 @@ }, "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ { "name": "name", "in": "path", - "description": "name of the InvestigationIndex", + "description": "name of the LogsDrilldownDefaultColumns", "required": true, "schema": { "type": "string", @@ -865,468 +865,30 @@ } ] }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigations": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}/status": { "get": { "tags": [ - "Investigation" - ], - "description": "list or watch objects of kind Investigation", - "operationId": "listInvestigation", - "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "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.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "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\".\n\nThis 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.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "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.\n\nThe 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.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "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.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "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.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`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.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - } + "LogsDrilldownDefaultColumns" ], + "description": "read status of the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumnsStatus", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - }, - "application/json;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - }, - "application/vnd.kubernetes.protobuf;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - } - } - } - }, - "x-kubernetes-action": "list", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "post": { - "tags": [ - "Investigation" - ], - "description": "create an Investigation", - "operationId": "createInvestigation", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "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", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldManager", - "in": "query", - "description": "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.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldValidation", - "in": "query", - "description": "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.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - }, - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - } - }, - "x-kubernetes-action": "post", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "delete": { - "tags": [ - "Investigation" - ], - "description": "delete collection of Investigation", - "operationId": "deletecollectionInvestigation", - "parameters": [ - { - "name": "continue", - "in": "query", - "description": "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\".\n\nThis 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.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "dryRun", - "in": "query", - "description": "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", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "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.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "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", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "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.\n\nThe 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.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "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.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "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.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "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.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "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.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`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.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "deletecollection", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "parameters": [ - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "pretty", - "in": "query", - "description": "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).", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ] - }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigations/{name}": { - "get": { - "tags": [ - "Investigation" - ], - "description": "read the specified Investigation", - "operationId": "getInvestigation", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1334,17 +896,17 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "put": { "tags": [ - "Investigation" + "LogsDrilldownDefaultColumns" ], - "description": "replace the specified Investigation", - "operationId": "replaceInvestigation", + "description": "replace status of the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumnsStatus", "parameters": [ { "name": "dryRun", @@ -1378,17 +940,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -1400,17 +962,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1420,17 +982,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1438,119 +1000,17 @@ }, "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "delete": { - "tags": [ - "Investigation" - ], - "description": "delete an Investigation", - "operationId": "deleteInvestigation", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "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", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "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.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "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", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "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.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "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.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "delete", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "patch": { "tags": [ - "Investigation" + "LogsDrilldownDefaultColumns" ], - "description": "partially update the specified Investigation", - "operationId": "updateInvestigation", + "description": "partially update status of the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumnsStatus", "parameters": [ { "name": "dryRun", @@ -1620,17 +1080,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1640,17 +1100,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1658,16 +1118,16 @@ }, "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ { "name": "name", "in": "path", - "description": "name of the Investigation", + "description": "name of the LogsDrilldownDefaultColumns", "required": true, "schema": { "type": "string", @@ -1698,12 +1158,13 @@ }, "components": { "schemas": { - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns": { "type": "object", "required": [ + "kind", + "apiVersion", "metadata", - "spec", - "status" + "spec" ], "properties": { "apiVersion": { @@ -1723,269 +1184,21 @@ ] }, "spec": { - "description": "Spec is the spec of the Investigation", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationSpec" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec" }, "status": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationStatus" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus" } }, "x-kubernetes-group-version-kind": [ { - "group": "investigations.grafana.app", - "kind": "Investigation", - "version": "v0alpha1" + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumns", + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationCollectable": { - "description": "Collectable represents an item collected during investigation", - "type": "object", - "required": [ - "id", - "createdAt", - "title", - "origin", - "type", - "queries", - "timeRange", - "datasource", - "url", - "note", - "noteUpdatedAt", - "fieldConfig" - ], - "properties": { - "createdAt": { - "type": "string", - "default": "" - }, - "datasource": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationDatasourceRef" - } - ] - }, - "fieldConfig": { - "type": "string", - "default": "" - }, - "id": { - "type": "string", - "default": "" - }, - "logoPath": { - "type": "string" - }, - "note": { - "type": "string", - "default": "" - }, - "noteUpdatedAt": { - "type": "string", - "default": "" - }, - "origin": { - "type": "string", - "default": "" - }, - "queries": { - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "atomic" - }, - "timeRange": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationTimeRange" - } - ] - }, - "title": { - "type": "string", - "default": "" - }, - "type": { - "type": "string", - "default": "" - }, - "url": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationDatasourceRef": { - "description": "DatasourceRef is a reference to a datasource", - "type": "object", - "required": [ - "uid" - ], - "properties": { - "uid": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex": { - "type": "object", - "required": [ - "metadata", - "spec", - "status" - ], - "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" - }, - "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.ObjectMeta" - } - ] - }, - "spec": { - "description": "Spec is the spec of the InvestigationIndex", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexSpec" - } - ] - }, - "status": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexStatus" - } - ] - } - }, - "x-kubernetes-group-version-kind": [ - { - "group": "investigations.grafana.app", - "kind": "InvestigationIndex", - "version": "v0alpha1" - } - ] - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexCollectableSummary": { - "type": "object", - "required": [ - "id", - "title", - "logoPath", - "origin" - ], - "properties": { - "id": { - "type": "string", - "default": "" - }, - "logoPath": { - "type": "string", - "default": "" - }, - "origin": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexInvestigationSummary": { - "description": "Type definition for investigation summaries", - "type": "object", - "required": [ - "title", - "createdByProfile", - "hasCustomName", - "isFavorite", - "overviewNote", - "overviewNoteUpdatedAt", - "viewMode", - "collectableSummaries" - ], - "properties": { - "collectableSummaries": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexCollectableSummary" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "createdByProfile": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson" - } - ] - }, - "hasCustomName": { - "type": "boolean", - "default": false - }, - "isFavorite": { - "type": "boolean", - "default": false - }, - "overviewNote": { - "type": "string", - "default": "" - }, - "overviewNoteUpdatedAt": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - }, - "viewMode": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexViewMode" - } - ] - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList": { "type": "object", "required": [ "metadata", @@ -2002,7 +1215,7 @@ "default": {}, "allOf": [ { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } ] } @@ -2022,122 +1235,60 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "investigations.grafana.app", - "kind": "InvestigationIndexList", - "version": "v0alpha1" + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumnsList", + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson": { - "description": "Person represents a user profile with basic information", + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel": { "type": "object", "required": [ - "uid", - "name", - "gravatarUrl" + "key", + "value" ], "properties": { - "gravatarUrl": { - "description": "URL to user's Gravatar image", - "type": "string", - "default": "" + "key": { + "type": "string" }, - "name": { - "description": "Display name of the user", - "type": "string", - "default": "" - }, - "uid": { - "description": "Unique identifier for the user", - "type": "string", - "default": "" + "value": { + "type": "string" } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel" } }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexSpec": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord": { "type": "object", "required": [ - "title", - "owner", - "investigationSummaries" + "columns", + "labels" ], "properties": { - "investigationSummaries": { - "description": "Array of investigation summaries", + "columns": { "type": "array", "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexInvestigationSummary" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "owner": { - "description": "The Person who owns this investigation index", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson" - } - ] - }, - "title": { - "description": "Title of the index, e.g. 'Favorites' or 'My Investigations'", - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexStatus": { - "type": "object", - "properties": { - "additionalFields": { - "description": "additionalFields is reserved for future use", - "type": "object", - "additionalProperties": { - "type": "object" + "type": "string" } }, - "operatorStates": { - "description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - "type": "object", - "additionalProperties": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexstatusOperatorState" - } - ] - } + "labels": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels" } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord" } }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexViewMode": { - "type": "object", - "required": [ - "mode", - "showComments", - "showTooltips" - ], - "properties": { - "mode": { - "type": "string", - "default": "" - }, - "showComments": { - "type": "boolean", - "default": false - }, - "showTooltips": { - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexstatusOperatorState": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState": { "type": "object", "required": [ "lastEvaluation", @@ -2152,273 +1303,58 @@ "description": "details contains any extra information that is operator-specific", "type": "object", "additionalProperties": { - "type": "object" + "type": "object", + "additionalProperties": {} } }, "lastEvaluation": { "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" + "type": "string" }, "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", + "description": "state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.", "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList": { - "type": "object", - "required": [ - "metadata", - "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.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - ] - } - }, - "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" - } + "enum": [ + "success", + "in_progress", + "failed" ] } }, - "x-kubernetes-group-version-kind": [ - { - "group": "investigations.grafana.app", - "kind": "InvestigationList", - "version": "v0alpha1" - } - ] + "additionalProperties": false }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationPerson": { - "description": "Person represents a user profile with basic information", + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec": { "type": "object", "required": [ - "uid", - "name", - "gravatarUrl" + "records" ], "properties": { - "gravatarUrl": { - "description": "URL to user's Gravatar image", - "type": "string", - "default": "" - }, - "name": { - "description": "Display name of the user", - "type": "string", - "default": "" - }, - "uid": { - "description": "Unique identifier for the user", - "type": "string", - "default": "" + "records": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords" } - } + }, + "additionalProperties": false }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationSpec": { - "description": "spec is the schema of our resource", - "type": "object", - "required": [ - "title", - "createdByProfile", - "hasCustomName", - "isFavorite", - "overviewNote", - "overviewNoteUpdatedAt", - "collectables", - "viewMode" - ], - "properties": { - "collectables": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationCollectable" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "createdByProfile": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationPerson" - } - ] - }, - "hasCustomName": { - "type": "boolean", - "default": false - }, - "isFavorite": { - "type": "boolean", - "default": false - }, - "overviewNote": { - "type": "string", - "default": "" - }, - "overviewNoteUpdatedAt": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - }, - "viewMode": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationViewMode" - } - ] - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationStatus": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus": { "type": "object", "properties": { "additionalFields": { "description": "additionalFields is reserved for future use", "type": "object", "additionalProperties": { - "type": "object" + "type": "object", + "additionalProperties": {} } }, "operatorStates": { - "description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", + "description": "operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.", "type": "object", "additionalProperties": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationstatusOperatorState" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState" } } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationTimeRange": { - "description": "TimeRange represents a time range with both absolute and relative values", - "type": "object", - "required": [ - "from", - "to", - "raw" - ], - "properties": { - "from": { - "type": "string", - "default": "" - }, - "raw": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationV0alpha1TimeRangeRaw" - } - ] - }, - "to": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationV0alpha1TimeRangeRaw": { - "type": "object", - "required": [ - "from", - "to" - ], - "properties": { - "from": { - "type": "string", - "default": "" - }, - "to": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationViewMode": { - "type": "object", - "required": [ - "mode", - "showComments", - "showTooltips" - ], - "properties": { - "mode": { - "type": "string", - "default": "" - }, - "showComments": { - "type": "boolean", - "default": false - }, - "showTooltips": { - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationstatusOperatorState": { - "type": "object", - "required": [ - "lastEvaluation", - "state" - ], - "properties": { - "descriptiveState": { - "description": "descriptiveState is an optional more descriptive state field which has no requirements on format", - "type": "string" - }, - "details": { - "description": "details contains any extra information that is operator-specific", - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "lastEvaluation": { - "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" - }, - "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - "type": "string", - "default": "" - } - } + }, + "additionalProperties": false }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index d4764b742f1..0e2f06946ac 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -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": [ diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index f9d5cdb70e5..aeb629a5939 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -30,7 +30,6 @@ func TestIntegrationOpenAPIs(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagQueryService, // Query Library featuremgmt.FlagProvisioning, - featuremgmt.FlagInvestigationsBackend, featuremgmt.FlagGrafanaAdvisor, featuremgmt.FlagKubernetesAlertingRules, featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // all datasources @@ -97,9 +96,6 @@ func TestIntegrationOpenAPIs(t *testing.T) { }, { Group: "iam.grafana.app", Version: "v0alpha1", - }, { - Group: "investigations.grafana.app", - Version: "v0alpha1", }, { Group: "advisor.grafana.app", Version: "v0alpha1", @@ -132,7 +128,7 @@ func TestIntegrationOpenAPIs(t *testing.T) { Version: "v0alpha1", }, { Group: "logsdrilldown.grafana.app", - Version: "v1alpha1", + Version: "v1beta1", }} for _, gv := range groups { VerifyOpenAPISnapshots(t, dir, gv, h) diff --git a/pkg/tests/apis/plugins/plugins_test.go b/pkg/tests/apis/plugins/plugins_test.go index d8c45713337..0844bda9757 100644 --- a/pkg/tests/apis/plugins/plugins_test.go +++ b/pkg/tests/apis/plugins/plugins_test.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -177,6 +178,9 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper { AppModeProduction: true, DisableAnonymous: true, APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true", + EnableFeatureToggles: []string{ + featuremgmt.FlagPluginStoreServiceLoading, + }, }) t.Cleanup(func() { helper.Shutdown() }) return helper diff --git a/pkg/tests/apis/preferences/preferences_test.go b/pkg/tests/apis/preferences/preferences_test.go index 5de09e16fa8..63ab74e0eb8 100644 --- a/pkg/tests/apis/preferences/preferences_test.go +++ b/pkg/tests/apis/preferences/preferences_test.go @@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) { Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID), Body: []byte(`{ "weekStart": "sunday", - "timezone": "africa" + "timezone": "Africa/Johannesburg" }`), }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user") @@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/api/org/preferences", Body: []byte(`{ "weekStart": "sunday", - "timezone": "africa", + "timezone": "Africa/Accra", "theme": "dark" }`), }, &raw) @@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) { jj, _ = json.Marshal(bootdata.Result.User) require.JSONEq(t, `{ - "timezone":"africa", + "timezone":"Africa/Johannesburg", "weekStart":"saturday", "theme":"dark", "language":"en-US", `+ // FROM global default! @@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged", }, &preferences.Preferences{}) require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences") - require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user - require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team - require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org - require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini - require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update + require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user + require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team + require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org + require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini + require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update }) } diff --git a/pkg/tests/apis/provisioning/connection_repositories_test.go b/pkg/tests/apis/provisioning/connection_repositories_test.go new file mode 100644 index 00000000000..e6ef823b801 --- /dev/null +++ b/pkg/tests/apis/provisioning/connection_repositories_test.go @@ -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) + }) +} diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index ea28ac88359..02c5436badb 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/grafana/grafana/pkg/util/testutil" "github.com/stretchr/testify/assert" @@ -11,6 +12,9 @@ import ( k8serrors "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" + clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" ) func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { @@ -411,3 +415,147 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "privateKey is forbidden in Gitlab connection") }) } + +func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + namespace := "default" + + // Create typed client from REST config + restConfig := helper.Org1.Admin.NewRestConfig() + provisioningClient, err := clientset.NewForConfig(restConfig) + require.NoError(t, err) + connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace) + + t.Run("health check gets updated after initial creation", func(t *testing.T) { + // Create a connection using unstructured (like other connection tests) + connUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-connection-health", + "namespace": namespace, + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "12345", + "installationID": "67890", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "test-private-key", + }, + }, + }} + + createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdUnstructured) + + connName := createdUnstructured.GetName() + + t.Cleanup(func() { + _ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{}) + }) + + // Wait for initial reconciliation - controller should update status + require.Eventually(t, func() bool { + updated, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + return updated.Status.ObservedGeneration == updated.Generation && + updated.Status.Health.Checked > 0 && + updated.Status.State == provisioning.ConnectionStateConnected && + updated.Status.Health.Healthy + }, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled with health status") + + // Verify initial health check was set + initial, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + assert.True(t, initial.Status.Health.Healthy, "connection should be healthy") + assert.Equal(t, provisioning.ConnectionStateConnected, initial.Status.State, "connection should be connected") + assert.Greater(t, initial.Status.Health.Checked, int64(0), "health check timestamp should be set") + assert.Equal(t, initial.Generation, initial.Status.ObservedGeneration, "observed generation should match") + }) + + t.Run("health check updates when spec changes", func(t *testing.T) { + // Create a connection using unstructured + connUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-connection-spec-change", + "namespace": namespace, + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "11111", + "installationID": "22222", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "test-private-key-2", + }, + }, + }} + + createdUnstructured, err := helper.Connections.Resource.Create(ctx, connUnstructured, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdUnstructured) + + connName := createdUnstructured.GetName() + + t.Cleanup(func() { + _ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{}) + }) + + // Wait for initial reconciliation + var initialHealthChecked int64 + require.Eventually(t, func() bool { + updated, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + if updated.Status.ObservedGeneration == updated.Generation { + initialHealthChecked = updated.Status.Health.Checked + return true + } + return false + }, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled") + + // Get the latest version before updating to avoid conflicts with controller updates + latestUnstructured, err := helper.Connections.Resource.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + + // Update the connection spec using the latest version + updatedUnstructured := latestUnstructured.DeepCopy() + githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any) + githubSpec["appID"] = "99999" + _, err = helper.Connections.Resource.Update(ctx, updatedUnstructured, metav1.UpdateOptions{}) + require.NoError(t, err) + + // Wait for reconciliation after spec change + require.Eventually(t, func() bool { + reconciled, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + return reconciled.Status.ObservedGeneration == reconciled.Generation && + reconciled.Status.Health.Checked > initialHealthChecked + }, 10*time.Second, 500*time.Millisecond, "connection should be reconciled after spec change") + + // Verify health check was updated + final, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, final.Generation, final.Status.ObservedGeneration, "observed generation should match generation") + assert.Greater(t, final.Status.Health.Checked, initialHealthChecked, "health check should be updated after spec change") + assert.True(t, final.Status.Health.Healthy, "connection should remain healthy") + }) +} diff --git a/pkg/tests/apis/zanzana_reconcile.go b/pkg/tests/apis/zanzana_reconcile.go index f8a5673fed7..d63d46491fe 100644 --- a/pkg/tests/apis/zanzana_reconcile.go +++ b/pkg/tests/apis/zanzana_reconcile.go @@ -18,7 +18,10 @@ import ( const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds" -// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete. +// AwaitZanzanaReconcileNext waits for a Zanzana reconciliation cycle whose last-success timestamp +// has been incremented from its current value. This ensures a reconciliation has occurred after +// this function is called. +// // It is a no-op unless the `zanzana` feature toggle is enabled for the running test env. func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { t.Helper() @@ -31,18 +34,14 @@ func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { return } - prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) - if !ok { - prev = 0 - } - + baselineTimestamp, _ := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) require.EventuallyWithT(t, func(c *assert.CollectT) { ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric) if !ok { return } - assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev) + assert.Greater(c, ts, baselineTimestamp, "expected %s (%v) > baseline (%v)", zanzanaReconcileLastSuccessMetric, ts, baselineTimestamp) }, 30*time.Second, 50*time.Millisecond) } diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index ebf82f17864..88f65223675 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -320,8 +320,9 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled)) require.NoError(t, err) - if !opts.OpenFeatureAPIEnabled { - _, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work + + if opts.OpenFeatureAPIEnabled { + _, err = openFeatureSect.NewKey("provider", "static") require.NoError(t, err) _, err = openFeatureSect.NewKey("targetingKey", "grafana") require.NoError(t, err) @@ -369,6 +370,39 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } + if opts.DisableZanzanaServerCheckQueryCache { + zanzanaServerSect, err := cfg.NewSection("zanzana.server") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_cache_limit", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("cache_controller_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("cache_controller_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_query_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_query_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_max_results", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_max_results", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_limit", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_ttl", "0") + require.NoError(t, err) + } + analyticsSect, err := cfg.NewSection("analytics") require.NoError(t, err) _, err = analyticsSect.NewKey("intercom_secret", "intercom_secret_at_config") @@ -556,6 +590,8 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = section.NewKey("enableMigration", fmt.Sprintf("%t", v.EnableMigration)) require.NoError(t, err) + _, err = section.NewKey("autoMigrationThreshold", fmt.Sprintf("%d", v.AutoMigrationThreshold)) + require.NoError(t, err) } } if opts.UnifiedStorageEnableSearch { @@ -638,9 +674,14 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = dbSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries)) require.NoError(t, err) - _, err = dbSection.NewKey("max_open_conn", "2") + maxConns := opts.DBMaxConns + if maxConns <= 0 { + maxConns = 2 + } + + _, err = dbSection.NewKey("max_open_conn", fmt.Sprintf("%d", maxConns)) require.NoError(t, err) - _, err = dbSection.NewKey("max_idle_conn", "2") + _, err = dbSection.NewKey("max_idle_conn", fmt.Sprintf("%d", maxConns)) require.NoError(t, err) cfgPath := filepath.Join(cfgDir, "test.ini") @@ -703,6 +744,10 @@ type GrafanaOpts struct { DisableAuthZClientCache bool ZanzanaReconciliationInterval time.Duration DisableZanzanaCache bool + DisableZanzanaServerCheckQueryCache bool + + // If set to 0, the default (2) is used. + DBMaxConns int // Allow creating grafana dir beforehand Dir string diff --git a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go new file mode 100644 index 00000000000..a0612e70be7 --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go @@ -0,0 +1,43 @@ +package exemplar + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +type Exemplar struct { + Id string + Value float64 + Timestamp int64 +} + +func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar) *data.Frame { + frame := data.NewFrame("exemplar") + frame.Meta = &data.FrameMeta{ + DataTopic: data.DataTopicAnnotations, + } + fields := []*data.Field{ + data.NewField("Time", nil, []time.Time{}), + data.NewField("Value", labels, []float64{}), // add labels here? + data.NewField("Id", nil, []string{}), + } + fields[2].Config = &data.FieldConfig{ + DisplayName: "Profile ID", + } + for name := range labels { + fields = append(fields, data.NewField(name, nil, []string{})) + } + frame.Fields = fields + + for _, e := range exemplars { + frame.AppendRow(time.UnixMilli(e.Timestamp), e.Value, e.Id) + for name, value := range labels { + field, _ := frame.FieldByName(name) + if field != nil { + field.Append(value) + } + } + } + return frame +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go new file mode 100644 index 00000000000..c026e5a4cd7 --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go @@ -0,0 +1,34 @@ +package exemplar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateExemplarFrame(t *testing.T) { + exemplars := []*Exemplar{ + {Id: "1", Value: 1.0, Timestamp: 100}, + {Id: "2", Value: 2.0, Timestamp: 200}, + } + labels := map[string]string{ + "foo": "bar", + } + frame := CreateExemplarFrame(labels, exemplars) + + require.Equal(t, "exemplar", frame.Name) + require.Equal(t, 4, len(frame.Fields)) + require.Equal(t, "Time", frame.Fields[0].Name) + require.Equal(t, "Value", frame.Fields[1].Name) + require.Equal(t, "Id", frame.Fields[2].Name) + require.Equal(t, "foo", frame.Fields[3].Name) + + rows, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, 2, rows) + row := frame.RowCopy(0) + require.Equal(t, 4, len(row)) + require.Equal(t, 1.0, row[1]) + require.Equal(t, "1", row[2]) + require.Equal(t, "bar", row[3]) +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index 244481aacbd..0c2e7f29baa 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -18,6 +18,8 @@ import ( "github.com/prometheus/prometheus/promql/parser" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" ) var ( @@ -31,7 +33,7 @@ type ProfilingClient interface { ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error) LabelNames(ctx context.Context, labelSelector string, start int64, end int64) ([]string, error) LabelValues(ctx context.Context, label string, labelSelector string, start int64, end int64) ([]string, error) - GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) + GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error) GetSpanProfile(ctx context.Context, profileTypeID string, labelSelector string, spanSelector []string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error) } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go index 29f27facdd8..7dabd28dc94 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go @@ -32,6 +32,8 @@ type GrafanaPyroscopeDataQuery struct { Limit *int64 `json:"limit,omitempty"` // Sets the maximum number of nodes in the flamegraph. MaxNodes *int64 `json:"maxNodes,omitempty"` + // If set to true, the response will contain annotations + Annotations *bool `json:"annotations,omitempty"` // A unique identifier for the query within the list of targets. // In server side expressions, the refId is used as a variable name to identify results. // By default, the UI will assign A->Z; however setting meaningful names may be useful. @@ -41,8 +43,8 @@ type GrafanaPyroscopeDataQuery struct { // Specify the query flavor // TODO make this required and give it a default QueryType *string `json:"queryType,omitempty"` - // If set to true, the response will contain annotations - Annotations *bool `json:"annotations,omitempty"` + // If set to true, exemplars will be requested + IncludeExemplars bool `json:"includeExemplars"` // For mixed data sources the selected datasource is on the query level. // For non mixed scenarios this is undefined. // TODO find a better way to do this ^ that's friendly to schema @@ -53,7 +55,8 @@ type GrafanaPyroscopeDataQuery struct { // NewGrafanaPyroscopeDataQuery creates a new GrafanaPyroscopeDataQuery object. func NewGrafanaPyroscopeDataQuery() *GrafanaPyroscopeDataQuery { return &GrafanaPyroscopeDataQuery{ - LabelSelector: "{}", - GroupBy: []string{}, + LabelSelector: "{}", + GroupBy: []string{}, + IncludeExemplars: false, } } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go index aa6af21d6c3..1029b178f42 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go @@ -8,14 +8,16 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" "connectrpc.com/connect" - querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" - "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + + querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" + "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect" ) type ProfileType struct { @@ -49,6 +51,13 @@ type Point struct { // Milliseconds unix timestamp Timestamp int64 Annotations []*typesv1.ProfileAnnotation + Exemplars []*Exemplar +} + +type Exemplar struct { + Id string + Value uint64 + Timestamp int64 } type ProfileResponse struct { @@ -99,7 +108,7 @@ func (c *PyroscopeClient) ProfileTypes(ctx context.Context, start int64, end int } } -func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) { +func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetSeries", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector))) defer span.End() req := connect.NewRequest(&querierv1.SelectSeriesRequest{ @@ -110,6 +119,7 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l Step: step, GroupBy: groupBy, Limit: limit, + ExemplarType: exemplarType, }) resp, err := c.connectClient.SelectSeries(ctx, req) @@ -137,6 +147,16 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l Timestamp: p.Timestamp, Annotations: p.Annotations, } + if len(p.Exemplars) > 0 { + points[i].Exemplars = make([]*Exemplar, len(p.Exemplars)) + for j, e := range p.Exemplars { + points[i].Exemplars[j] = &Exemplar{ + Id: e.ProfileId, + Value: e.Value, + Timestamp: e.Timestamp, + } + } + } } series[i] = &Series{ diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go index dbcf94aee94..c6bb80dcc2f 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go @@ -5,10 +5,11 @@ import ( "testing" "connectrpc.com/connect" + "github.com/stretchr/testify/require" + googlev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1" querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" - "github.com/stretchr/testify/require" ) func Test_PyroscopeClient(t *testing.T) { @@ -19,7 +20,7 @@ func Test_PyroscopeClient(t *testing.T) { t.Run("GetSeries", func(t *testing.T) { limit := int64(42) - resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15) + resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_NONE) require.Nil(t, err) series := &SeriesResponse{ @@ -32,6 +33,21 @@ func Test_PyroscopeClient(t *testing.T) { require.Equal(t, series, resp) }) + t.Run("GetSeriesWithExemplars", func(t *testing.T) { + limit := int64(42) + resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL) + require.Nil(t, err) + + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{Id: "id1", Value: 3, Timestamp: 1000}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{Id: "id2", Value: 1, Timestamp: 2000}}}}}, + }, + Units: "short", + Label: "alloc_objects", + } + require.Equal(t, series, resp) + }) + t.Run("GetProfile", func(t *testing.T) { maxNodes := int64(-1) resp, err := client.GetProfile(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, &maxNodes) @@ -115,6 +131,21 @@ func (f *FakePyroscopeConnectClient) SelectMergeStacktraces(ctx context.Context, func (f *FakePyroscopeConnectClient) SelectSeries(ctx context.Context, req *connect.Request[querierv1.SelectSeriesRequest]) (*connect.Response[querierv1.SelectSeriesResponse], error) { f.Req = req + if req.Msg.ExemplarType == typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL { + return &connect.Response[querierv1.SelectSeriesResponse]{ + Msg: &querierv1.SelectSeriesResponse{ + Series: []*typesv1.Series{ + { + Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}}, + Points: []*typesv1.Point{ + {Timestamp: int64(1000), Value: 30, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(1000), Value: 3, ProfileId: "id1"}}}, + {Timestamp: int64(2000), Value: 10, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(2000), Value: 1, ProfileId: "id2"}}}, + }, + }, + }, + }, + }, nil + } return &connect.Response[querierv1.SelectSeriesResponse]{ Msg: &querierv1.SelectSeriesResponse{ Series: []*typesv1.Series{ diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query.go b/pkg/tsdb/grafana-pyroscope-datasource/query.go index 1acf3bcee37..be662cef4fd 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/live" + "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/exemplar" "github.com/xlab/treeprint" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -21,6 +22,8 @@ import ( "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation" "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery" + + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" ) type queryModel struct { @@ -36,8 +39,12 @@ const ( queryTypeProfile = string(dataquery.PyroscopeQueryTypeProfile) queryTypeMetrics = string(dataquery.PyroscopeQueryTypeMetrics) queryTypeBoth = string(dataquery.PyroscopeQueryTypeBoth) + + exemplarsFeatureToggle = "profilesExemplars" ) +var identityTransformation = func(value float64) float64 { return value } + // query processes single Pyroscope query transforming the response to data.Frame packaged in DataResponse func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.query", trace.WithAttributes(attribute.String("query_type", query.QueryType))) @@ -77,6 +84,10 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint()) } } + exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE + if qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) { + exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL + } seriesResp, err := d.client.GetSeries( gCtx, profileTypeId, @@ -86,6 +97,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont qm.GroupBy, qm.Limit, math.Max(query.Interval.Seconds(), parsedInterval.Seconds()), + exemplarType, ) if err != nil { span.RecordError(err) @@ -475,6 +487,7 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration annotations := make([]*annotation.TimedAnnotation, 0) for _, series := range resp.Series { + exemplars := make([]*exemplar.Exemplar, 0) // We create separate data frames as the series may not have the same length frame := data.NewFrame("series") frameMeta := &data.FrameMeta{PreferredVisualization: "graph"} @@ -516,14 +529,20 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration // Apply rate calculation for cumulative profiles value := point.Value + transformation := identityTransformation if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { - value = value / stepDurationSec + transformation = func(value float64) float64 { + return value / stepDurationSec + } // Convert CPU nanoseconds to cores if isCPUTimeProfile(profileTypeID) { - value = value / 1e9 + transformation = func(value float64) float64 { + return value / stepDurationSec / 1e9 + } } } + value = transformation(value) valueField.Append(value) if withAnnotations { for _, a := range point.Annotations { @@ -533,10 +552,22 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration }) } } + for _, e := range point.Exemplars { + exemplars = append(exemplars, &exemplar.Exemplar{ + Id: e.Id, + Value: transformation(float64(e.Value)), + Timestamp: e.Timestamp, + }) + } } frame.Fields = fields frames = append(frames, frame) + + if len(exemplars) > 0 { + frame := exemplar.CreateExemplarFrame(labels, exemplars) + frames = append(frames, frame) + } } if len(annotations) > 0 { diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go index 2f6d7185d7a..a9a7cd41351 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation" @@ -487,10 +488,21 @@ func Test_seriesToDataFrame(t *testing.T) { require.Nil(t, frames[0].Meta.Custom) }) - t.Run("CPU time conversion to cores", func(t *testing.T) { + t.Run("CPU time conversion to cores with exemplars", func(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 3000000000}, {Timestamp: int64(2000), Value: 1500000000}}}, // 3s and 1.5s in nanoseconds + { + Labels: []*LabelPair{}, Points: []*Point{ + { + Timestamp: int64(1000), Value: 3000000000, // 3s in nanoseconds + Exemplars: []*Exemplar{{Value: 300000000, Timestamp: 1000}}, // 0.3s in nanoseconds + }, + { + Timestamp: int64(2000), Value: 1500000000, // 1.5s in nanoseconds + Exemplars: []*Exemplar{{Value: 150000000, Timestamp: 1000}}, // 0.15s in nanoseconds + }, + }, + }, }, Units: "ns", Label: "cpu", @@ -498,19 +510,32 @@ func Test_seriesToDataFrame(t *testing.T) { // should convert nanoseconds to cores and set unit to "cores" frames, err := seriesToDataFrames(series, false, 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds") require.NoError(t, err) - require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames)) require.Equal(t, "cores", frames[0].Fields[1].Config.Unit) // Check values were converted: 3000000000/15/1e9 = 0.2 cores/sec, 1500000000/15/1e9 = 0.1 cores/sec values := fieldValues[float64](frames[0].Fields[1]) require.Equal(t, []float64{0.2, 0.1}, values) + // Check exemplar values were converted: 300000000/15/1e9 = 0.02 cores/sec, 150000000/15/1e9 = 0.01 cores/sec + exemplarValues := fieldValues[float64](frames[1].Fields[1]) + require.Equal(t, []float64{0.02, 0.01}, exemplarValues) }) t.Run("Memory allocation unit conversion to bytes/sec", func(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 150000000}, {Timestamp: int64(2000), Value: 300000000}}}, // 150 MB, 300 MB + { + Labels: []*LabelPair{}, Points: []*Point{ + { + Timestamp: int64(1000), Value: 150000000, // 150 MB + Exemplars: []*Exemplar{{Value: 15000000, Timestamp: 1000}}, // 15 MB + }, { + Timestamp: int64(2000), Value: 300000000, // 300 MB + Exemplars: []*Exemplar{{Value: 30000000, Timestamp: 1000}}, // 30 MB + }, + }, + }, }, Units: "bytes", Label: "memory_alloc", @@ -518,19 +543,33 @@ func Test_seriesToDataFrame(t *testing.T) { // should convert bytes to binBps and apply rate calculation frames, err := seriesToDataFrames(series, false, 15.0, "memory:alloc_space:bytes:space:bytes") require.NoError(t, err) - require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames)) require.Equal(t, "binBps", frames[0].Fields[1].Config.Unit) // Check values were rate calculated: 150000000/15 = 10000000, 300000000/15 = 20000000 values := fieldValues[float64](frames[0].Fields[1]) require.Equal(t, []float64{10000000, 20000000}, values) + // Check exemplar values were rate calculated: 15000000/15 = 1000000, 30000000/15 = 2000000 + exemplarValues := fieldValues[float64](frames[1].Fields[1]) + require.Equal(t, []float64{1000000, 2000000}, exemplarValues) }) t.Run("Count-based profile unit conversion to ops/sec", func(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 1500}, {Timestamp: int64(2000), Value: 3000}}}, // 1500, 3000 contentions + { + Labels: []*LabelPair{}, Points: []*Point{ + { + Timestamp: int64(1000), Value: 1500, // 1500 contentions + Exemplars: []*Exemplar{{Value: 150, Timestamp: 1000}}, // 150 contentions + + }, { + Timestamp: int64(2000), Value: 3000, // 3000 contentions + Exemplars: []*Exemplar{{Value: 300, Timestamp: 1000}}, // 300 contentions + }, + }, + }, }, Units: "short", Label: "contentions", @@ -538,13 +577,16 @@ func Test_seriesToDataFrame(t *testing.T) { // should convert short to ops and apply rate calculation frames, err := seriesToDataFrames(series, false, 15.0, "mutex:contentions:count:contentions:count") require.NoError(t, err) - require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames)) require.Equal(t, "ops", frames[0].Fields[1].Config.Unit) // Check values were rate calculated: 1500/15 = 100, 3000/15 = 200 values := fieldValues[float64](frames[0].Fields[1]) require.Equal(t, []float64{100, 200}, values) + // Check exemplar values were rate calculated: 150/15 = 10, 300/15 = 20 + exemplarValues := fieldValues[float64](frames[1].Fields[1]) + require.Equal(t, []float64{10, 20}, exemplarValues) }) } @@ -605,7 +647,7 @@ func (f *FakeClient) GetSpanProfile(ctx context.Context, profileTypeID, labelSel }, nil } -func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) { +func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) { f.Args = []any{profileTypeID, labelSelector, start, end, groupBy, step} return &SeriesResponse{ Series: []*Series{ diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 9f09c0135d7..4cb15addb0a 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -6002,6 +6002,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -6152,11 +6156,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -6990,6 +6991,9 @@ "ReportOptions": { "type": "object", "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -8654,11 +8658,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -8906,6 +8907,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/api-merged.json b/public/api-merged.json index 642583d2600..d511139c7bd 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -6167,10 +6167,16 @@ }, { "type": "string", - "description": "A comma separated list of folder ID(s) to filter the elements by.", + "description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.", "name": "folderFilter", "in": "query" }, + { + "type": "string", + "description": "A comma separated list of folder UID(s) to filter the elements by.", + "name": "folderFilterUIDs", + "in": "query" + }, { "type": "integer", "format": "int64", @@ -18483,6 +18489,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -18729,11 +18739,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -20504,6 +20511,9 @@ "ReportOptions": { "type": "object", "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -23117,11 +23127,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -23407,6 +23414,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index e340e8276b8..131b2f48ea2 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -10,11 +10,8 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual'; import { KioskMode } from 'app/types/dashboard'; import { RouteDescriptor } from '../../navigation/types'; -import { buildBreadcrumbs } from '../Breadcrumbs/utils'; -import { logDuplicateUnifiedHistoryEntryEvent } from './History/eventsTracking'; import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious'; -import { HistoryEntry } from './types'; export interface AppChromeState { chromeless?: boolean; @@ -34,7 +31,6 @@ export interface AppChromeState { export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open'; -export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history'; export class AppChromeService { searchBarStorageKey = 'SearchBar_Hidden'; @@ -88,8 +84,6 @@ export class AppChromeService { newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless; if (!this.ignoreStateUpdate(newState, current)) { - config.featureToggles.unifiedHistory && - store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState)); this.state.next(newState); } } @@ -118,40 +112,6 @@ export class AppChromeService { window.sessionStorage.removeItem('returnToPrevious'); }; - private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] { - const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }); - const newPageNav = newState.pageNav || newState.sectionNav.node; - - let entries = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); - const clickedHistory = store.getObject('CLICKING_HISTORY'); - if (clickedHistory) { - store.setObject('CLICKING_HISTORY', false); - return entries; - } - if (!newPageNav) { - return entries; - } - - const lastEntry = entries[0]; - const newEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href }; - const isSamePath = lastEntry && newEntry.url.split('?')[0] === lastEntry.url.split('?')[0]; - - // To avoid adding an entry with the same path twice, we always use the latest one - if (isSamePath) { - entries[0] = newEntry; - } else { - if (lastEntry && lastEntry.name === newEntry.name) { - logDuplicateUnifiedHistoryEntryEvent({ - entryName: newEntry.name, - lastEntryURL: lastEntry.url, - newEntryURL: newEntry.url, - }); - } - entries = [newEntry, ...entries]; - } - - return entries; - } private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) { if (isShallowEqual(newState, current)) { return true; diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index c6c8ee37239..3efee947d5b 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -26,7 +26,7 @@ const mockDifferentComponent = { } as ExtensionInfo; const mockPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent, mockDifferentComponent], addedLinks: [], }; @@ -187,7 +187,7 @@ describe('ExtensionSidebarProvider', () => { it('should only include permitted plugins in available components', () => { const permittedPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent], addedLinks: [], }; @@ -256,7 +256,7 @@ describe('ExtensionSidebarProvider', () => { // Call it directly with the test event subscriberFn( new OpenExtensionSidebarEvent({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', props: { testProp: 'test value' }, }) @@ -266,7 +266,7 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('is-open')).toHaveTextContent('true'); expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}'); const expectedComponentId = JSON.stringify({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', }); expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId); @@ -381,7 +381,7 @@ describe('ExtensionSidebarProvider', () => { subscriberFn( new ToggleExtensionSidebarEvent({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', props: { testProp: 'test value' }, }) @@ -392,7 +392,7 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('is-open')).toHaveTextContent('true'); expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}'); const expectedComponentId = JSON.stringify({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', }); expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 51d73d2e8e5..07f716d1a7d 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -11,7 +11,6 @@ import { DEFAULT_EXTENSION_SIDEBAR_WIDTH, MAX_EXTENSION_SIDEBAR_WIDTH } from './ export const EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarDocked'; export const EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarWidth'; const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = [ - 'grafana-investigations-app', 'grafana-assistant-app', 'grafana-dash-app', // The docs plugin ID is transitioning from grafana-grafanadocsplugin-app to grafana-pathfinder-app. diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx index 8fdb8b5eccb..cb717322af7 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx @@ -44,7 +44,7 @@ const mockComponent = { }; const mockPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent], }; @@ -109,7 +109,7 @@ describe('ExtensionToolbarItem', () => { it('should render a dropdown menu when multiple components are available', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -141,7 +141,7 @@ describe('ExtensionToolbarItem', () => { it('should show menu items when clicking the dropdown button', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -165,7 +165,7 @@ describe('ExtensionToolbarItem', () => { it('should toggle the sidebar when clicking a menu item', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -192,7 +192,7 @@ describe('ExtensionToolbarItem', () => { it('should close the sidebar when clicking an active menu item', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -218,13 +218,13 @@ describe('ExtensionToolbarItem', () => { it('should render individual buttons when multiple plugins are available', async () => { const plugin1Meta = { - pluginId: 'grafana-investigations-app', - addedComponents: [{ ...mockComponent, title: 'Investigations' }], + pluginId: 'grafana-assistant-app', + addedComponents: [{ ...mockComponent, title: 'Assistant' }], }; const plugin2Meta = { - pluginId: 'grafana-assistant-app', - addedComponents: [{ ...mockComponent, title: 'Assistant' }], + pluginId: 'grafana-dash-app', + addedComponents: [{ ...mockComponent, title: 'Dash' }], }; (usePluginLinks as jest.Mock).mockReturnValue({ @@ -249,7 +249,7 @@ describe('ExtensionToolbarItem', () => { expect(buttons).toHaveLength(2); // Each button should have the correct title - expect(buttons[0]).toHaveAttribute('aria-label', 'Open Investigations'); - expect(buttons[1]).toHaveAttribute('aria-label', 'Open Assistant'); + expect(buttons[0]).toHaveAttribute('aria-label', 'Open Assistant'); + expect(buttons[1]).toHaveAttribute('aria-label', 'Open Dash'); }); }); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx index 3145909e7f8..d819b0fa499 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx @@ -17,8 +17,6 @@ function getPluginIcon(pluginId?: string): string { case 'grafana-grafanadocsplugin-app': case 'grafana-pathfinder-app': return 'book'; - case 'grafana-investigations-app': - return 'eye'; default: return 'ai-sparkle'; } diff --git a/public/app/core/components/AppChrome/History/HistoryContainer.tsx b/public/app/core/components/AppChrome/History/HistoryContainer.tsx deleted file mode 100644 index 88cbde02856..00000000000 --- a/public/app/core/components/AppChrome/History/HistoryContainer.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { css } from '@emotion/css'; -import { useEffect } from 'react'; -import { useToggle } from 'react-use'; - -import { GrafanaTheme2, store } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { appEvents } from 'app/core/app_events'; -import { RecordHistoryEntryEvent } from 'app/types/events'; - -import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; -import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; -import { HistoryEntry } from '../types'; - -import { HistoryWrapper } from './HistoryWrapper'; -import { logUnifiedHistoryDrawerInteractionEvent } from './eventsTracking'; - -export function HistoryContainer() { - const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false); - const styles = useStyles2(getStyles); - - useEffect(() => { - const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => { - const clickedHistory = store.getObject('CLICKING_HISTORY'); - if (clickedHistory) { - store.setObject('CLICKING_HISTORY', false); - return; - } - const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); - let lastEntry = history[0]; - const newUrl = ev.payload.url; - const lastUrl = lastEntry.views[0]?.url; - if (lastUrl !== newUrl) { - lastEntry.views = [ - { - name: ev.payload.name, - description: ev.payload.description, - url: newUrl, - time: Date.now(), - }, - ...lastEntry.views, - ]; - store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]); - } - return () => { - sub.unsubscribe(); - }; - }); - }, []); - - return ( - <> - { - onToggleShowHistoryDrawer(); - logUnifiedHistoryDrawerInteractionEvent({ type: 'open' }); - }} - iconOnly - icon="history" - aria-label={t('nav.history-container.drawer-tittle', 'History')} - /> - - {showHistoryDrawer && ( - { - onToggleShowHistoryDrawer(); - logUnifiedHistoryDrawerInteractionEvent({ type: 'close' }); - }} - size="sm" - > - onToggleShowHistoryDrawer(false)} /> - - )} - - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - separator: css({ - [theme.breakpoints.down('sm')]: { - display: 'none', - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx deleted file mode 100644 index d25c86d6f3d..00000000000 --- a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { css, cx } from '@emotion/css'; -import moment from 'moment'; -import { useState } from 'react'; - -import { FieldType, GrafanaTheme2, store } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { Box, Button, Card, Icon, IconButton, Space, Sparkline, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui'; -import { formatDate } from 'app/core/internationalization/dates'; - -import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; -import { HistoryEntry } from '../types'; - -import { logClickUnifiedHistoryEntryEvent, logUnifiedHistoryShowMoreEvent } from './eventsTracking'; - -export function HistoryWrapper({ onClose }: { onClose: () => void }) { - const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => { - return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day')); - }); - const [numItemsToShow, setNumItemsToShow] = useState(5); - - const selectedTime = history.find((entry) => { - return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href); - })?.time; - - const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => { - const date = moment(entry.time); - let key = ''; - if (date.isSame(moment(), 'day')) { - key = t('nav.history-wrapper.today', 'Today'); - } else if (date.isSame(moment().subtract(1, 'day'), 'day')) { - key = t('nav.history-wrapper.yesterday', 'Yesterday'); - } else { - key = date.format('YYYY-MM-DD'); - } - acc[key] = [...(acc[key] || []), entry]; - return acc; - }, {}); - const styles = useStyles2(getStyles); - return ( - - - {Object.keys(hist).map((entries, date) => { - return ( - - - {entries} - -
- {hist[entries].map((entry, index) => { - return ( - onClose()} - /> - ); - })} -
-
- ); - })} -
- {history.length > numItemsToShow && ( - - - - )} -
- ); -} -interface ItemProps { - entry: HistoryEntry; - isSelected: boolean; - onClick: () => void; -} - -function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) { - const styles = useStyles2(getStyles); - const theme = useTheme2(); - const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0); - - const { breadcrumbs, views, time, url, sparklineData } = entry; - const expandedLabel = isExpanded - ? t('nav.history-wrapper.collapse', 'Collapse') - : t('nav.history-wrapper.expand', 'Expand'); - const entryIconLabel = isExpanded - ? t('nav.history-wrapper.icon-selected', 'Selected Entry') - : t('nav.history-wrapper.icon-unselected', 'Normal Entry'); - const selectedViewTime = - isSelected && - entry.views.find((entry) => { - return entry.url === window.location.href; - })?.time; - - return ( - - - - {views.length > 0 ? ( - setIsExpanded(!isExpanded)} - aria-label={expandedLabel} - className={styles.iconButton} - /> - ) : ( - - )} - - { - store.setObject('CLICKING_HISTORY', true); - onClick(); - logClickUnifiedHistoryEntryEvent({ entryURL: url }); - }} - href={url} - isCompact={true} - className={isSelected ? styles.card : cx(styles.card, styles.cardSelected)} - > - -
- {breadcrumbs.map((breadcrumb, index) => ( - - {breadcrumb.text}{' '} - {index !== breadcrumbs.length - 1 - ? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings - '> ' - : ''} - - ))} -
- - {formatDate(time, { timeStyle: 'short' })} - - {sparklineData && ( - - )} -
-
-
- {isExpanded && ( -
- {views.map((view, index) => { - return ( - { - store.setObject('CLICKING_HISTORY', true); - onClick(); - logClickUnifiedHistoryEntryEvent({ entryURL: view.url, subEntry: 'timeRange' }); - }} - isCompact={true} - className={view.time === selectedViewTime ? undefined : styles.subCard} - > - - {view.name} - {view.description && ( - - {view.description} - - )} - - - ); - })} -
- )} -
-
- ); -} -const getStyles = (theme: GrafanaTheme2) => { - return { - card: css({ - label: 'card', - background: 'none', - margin: theme.spacing(0.5, 0), - }), - cardSelected: css({ - label: 'card-selected', - background: 'none', - }), - subCard: css({ - label: 'subcard', - background: 'none', - margin: 0, - }), - iconButton: css({ - label: 'expand-button', - margin: 0, - }), - iconButtonCircle: css({ - label: 'blue-circle-icon', - margin: 0, - background: theme.colors.background.primary, - fill: theme.colors.primary.main, - cursor: 'default', - '&:hover:before': { - background: 'none', - }, - //Need this to place the icon on the line, otherwise the line will appear on top of the icon - zIndex: 0, - }), - iconButtonDot: css({ - label: 'blue-dot-icon', - margin: 0, - color: theme.colors.primary.main, - border: theme.shape.radius.circle, - cursor: 'default', - '&:hover:before': { - background: 'none', - }, - //Need this to place the icon on the line, otherwise the line will appear on top of the icon - zIndex: 0, - }), - expanded: css({ - label: 'expanded', - display: 'flex', - flexDirection: 'column', - marginLeft: theme.spacing(6), - gap: theme.spacing(1), - position: 'relative', - '&:before': { - content: '""', - position: 'absolute', - left: 0, - top: 0, - height: '100%', - width: '1px', - background: theme.colors.border.weak, - }, - }), - timeline: css({ - label: 'timeline', - position: 'relative', - height: '100%', - width: '100%', - paddingLeft: theme.spacing(2), - '&:before': { - content: '""', - position: 'absolute', - left: theme.spacing(5.75), - top: 0, - height: '100%', - width: '1px', - borderLeft: `1px dashed ${theme.colors.border.strong}`, - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/History/eventsTracking.ts b/public/app/core/components/AppChrome/History/eventsTracking.ts deleted file mode 100644 index 5d49bd1c978..00000000000 --- a/public/app/core/components/AppChrome/History/eventsTracking.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { reportInteraction } from '@grafana/runtime'; - -const UNIFIED_HISTORY_ENTRY_CLICKED = 'grafana_unified_history_entry_clicked'; -const UNIFIED_HISTORY_ENTRY_DUPLICATED = 'grafana_unified_history_duplicated_entry_rendered'; -const UNIFIED_HISTORY_DRAWER_INTERACTION = 'grafana_unified_history_drawer_interaction'; -const UNIFIED_HISTORY_DRAWER_SHOW_MORE = 'grafana_unified_history_show_more'; - -//Currently just 'timeRange' is supported -//in short term, we could add 'templateVariables' for example -type subEntryTypes = 'timeRange'; - -//Whether the user opens or closes the `HistoryDrawer` -type UnifiedHistoryDrawerInteraction = 'open' | 'close'; - -interface UnifiedHistoryEntryClicked { - //We will also work with the current URL but we will get this from Rudderstack data - //URL to return to - entryURL: string; - //In the case we want to go back to a specific query param, currently just a specific time range - subEntry?: subEntryTypes; -} - -interface UnifiedHistoryEntryDuplicated { - // Common name of the history entries - entryName: string; - // URL of the last entry - lastEntryURL: string; - // URL of the new entry - newEntryURL: string; -} - -//Event triggered when a user clicks on an entry of the `HistoryDrawer` -export const logClickUnifiedHistoryEntryEvent = ({ entryURL, subEntry }: UnifiedHistoryEntryClicked) => { - reportInteraction(UNIFIED_HISTORY_ENTRY_CLICKED, { - entryURL, - subEntry, - }); -}; - -//Event triggered when history entry name matches the previous one -//so we keep track of duplicated entries and be able to analyze them -export const logDuplicateUnifiedHistoryEntryEvent = ({ - entryName, - lastEntryURL, - newEntryURL, -}: UnifiedHistoryEntryDuplicated) => { - reportInteraction(UNIFIED_HISTORY_ENTRY_DUPLICATED, { - entryName, - lastEntryURL, - newEntryURL, - }); -}; - -//We keep track of users open and closing the drawer -export const logUnifiedHistoryDrawerInteractionEvent = ({ type }: { type: UnifiedHistoryDrawerInteraction }) => { - reportInteraction(UNIFIED_HISTORY_DRAWER_INTERACTION, { - type, - }); -}; - -//We keep track of users clicking on the `Show more` button -export const logUnifiedHistoryShowMoreEvent = () => { - reportInteraction(UNIFIED_HISTORY_DRAWER_SHOW_MORE); -}; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index d5179570ef3..23c1624dc05 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -6,7 +6,6 @@ import { Components } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { ScopesContextValue } from '@grafana/runtime'; import { Icon, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MEGA_MENU_TOGGLE_ID } from 'app/core/constants'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; @@ -19,7 +18,6 @@ import { HomeLink } from '../../Branding/Branding'; import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem'; -import { HistoryContainer } from '../History/HistoryContainer'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { QuickAdd } from '../QuickAdd/QuickAdd'; @@ -60,7 +58,6 @@ export const SingleTopBar = memo(function SingleTopBar({ const profileNode = useSelector((state) => state.navIndex['profile']); const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); - const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; const isSmallScreen = !useMediaQueryMinWidth('sm'); const isLargeScreen = useMediaQueryMinWidth('lg'); const topLevelScopes = !showToolbarLevel && isLargeScreen && scopes?.state.enabled; @@ -96,7 +93,6 @@ export const SingleTopBar = memo(function SingleTopBar({ > - {unifiedHistoryEnabled && !isSmallScreen && } {!isSmallScreen && } diff --git a/public/app/core/components/AppChrome/types.ts b/public/app/core/components/AppChrome/types.ts index 6cf72c936f5..de9183423c3 100644 --- a/public/app/core/components/AppChrome/types.ts +++ b/public/app/core/components/AppChrome/types.ts @@ -4,28 +4,3 @@ export interface ToolbarUpdateProps { pageNav?: NavModelItem; actions?: React.ReactNode; } - -export interface HistoryEntryView { - name: string; - description: string; - url: string; - time: number; -} - -export interface HistoryEntrySparkline { - values: number[]; - range: { - min: number; - max: number; - delta: number; - }; -} - -export interface HistoryEntry { - name: string; - time: number; - breadcrumbs: NavModelItem[]; - url: string; - views: HistoryEntryView[]; - sparklineData?: HistoryEntrySparkline; -} diff --git a/public/app/core/components/BouncingLoader/BouncingLoader.tsx b/public/app/core/components/BouncingLoader/BouncingLoader.tsx index 025f0469cdf..bb4f12306bb 100644 --- a/public/app/core/components/BouncingLoader/BouncingLoader.tsx +++ b/public/app/core/components/BouncingLoader/BouncingLoader.tsx @@ -3,7 +3,8 @@ import { css, keyframes } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; -import grafanaIconSvg from 'img/grafana_icon.svg'; + +import { Branding } from '../Branding/Branding'; export function BouncingLoader() { const styles = useStyles2(getStyles); @@ -16,7 +17,7 @@ export function BouncingLoader() { aria-label={t('bouncing-loader.label', 'Loading')} >
- +
); diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 3debbd886a8..a3d93b12bad 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -115,12 +115,15 @@ export const OrgUsersTable = ({ { id: 'lastSeenAtAge', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ cell: { value }, row: { original } }: Cell<'lastSeenAtAge'>) => { + // If lastSeenAt is before created, user has never logged in + const neverLoggedIn = + original.lastSeenAt && original.created && new Date(original.lastSeenAt) < new Date(original.created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index a3a1f6be297..b4d34f0d438 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -135,12 +135,19 @@ export const UsersTable = ({ content: 'Time since user was seen using Grafana', iconName: 'question-circle', }, - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ + cell: { value }, + row: { + original: { lastSeenAt, created }, + }, + }: Cell<'lastSeenAtAge'>) => { + // The user has never logged in if lastSeenAt is before its creation date. + const neverLoggedIn = lastSeenAt && created && new Date(lastSeenAt) < new Date(created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx index ad6ee94d73e..b57242f0e49 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx @@ -79,6 +79,9 @@ describe('new receiver', () => { // click test await user.click(ui.testContactPoint.get()); + // close the modal + await user.click(screen.getByRole('button', { name: 'Close' })); + // we shouldn't be testing implementation details but when the request is successful // it can't seem to assert on the success toast await user.click(ui.saveContactButton.get()); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index d19ac4bb619..fd6cbf40fae 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -36,7 +36,11 @@ import { isExpressionQueryInAlert, } from '../../../rule-editor/formProcessing'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; -import { GRAFANA_RULES_SOURCE_NAME, getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; +import { + GRAFANA_RULES_SOURCE_NAME, + getDefaultOrFirstCompatibleDataSource, + getRulesDataSources, +} from '../../../utils/datasource'; import { PromOrLokiQuery, isPromOrLokiQuery } from '../../../utils/rule-form'; import { isCloudAlertingRuleByType, @@ -417,7 +421,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod ]); const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; - + // Only show the data source managed option if there are data sources with manageAlerts enabled + const hasAlertEnabledDataSources = useMemo(() => getRulesDataSources().length > 0, []); + const canSelectDataSourceManaged = onlyOneDSInQueries(queries) && hasAlertEnabledDataSources; if (!type) { return null; } @@ -437,8 +443,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod } : undefined; - const canSelectDataSourceManaged = onlyOneDSInQueries(queries); - return ( <>
- {mode === 'edit' && ( + {mode === 'edit' && hasAlertEnabledDataSources && ( <> { + const api = mockAlertRuleApi(server); + beforeEach(() => { setupDataSources(...Object.values(dataSources)); }); @@ -249,19 +253,22 @@ describe('RuleViewer', () => { expect(screen.getAllByRole('row')).toHaveLength(7); expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i); - expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest'); + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service'); + expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest'); expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i); - expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5'); + expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6'); expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i); - expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5'); + expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds'); + expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5'); expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i); - expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9'); + expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10'); expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i); - expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7'); + expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing'); + expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7'); expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i); @@ -275,9 +282,10 @@ describe('RuleViewer', () => { await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); - expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument(); + // Check for special updated_by values - use getAllByRole since some text appears in multiple columns + expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0); + expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument(); }); @@ -321,6 +329,47 @@ describe('RuleViewer', () => { await renderRuleViewer(rule, ruleIdentifier); expect(screen.queryByText('Labels')).not.toBeInTheDocument(); }); + + it('shows Notes column when versions have messages', async () => { + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + + expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows + expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument(); + }); + + it('does not show Notes column when no versions have messages', async () => { + const versionsWithoutMessages = [ + mockRulerGrafanaRule( + {}, + { + uid: grafanaRulerRule.grafana_alert.uid, + version: 2, + updated: '2025-01-14T09:35:17.000Z', + updated_by: { uid: 'foo', name: '' }, + } + ), + mockRulerGrafanaRule( + {}, + { + uid: grafanaRulerRule.grafana_alert.uid, + version: 1, + updated: '2025-01-13T09:35:17.000Z', + updated_by: null, + } + ), + ]; + api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages); + + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + + await screen.findByRole('button', { name: /Compare versions/i }); + + expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows + expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx index 74dabe18449..ac6c75b93ef 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx @@ -1,8 +1,9 @@ +import { css } from '@emotion/css'; import { useMemo, useState } from 'react'; import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui'; +import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff'; import { RuleIdentifier } from 'app/types/unified-alerting'; @@ -33,6 +34,7 @@ export function VersionHistoryTable({ onRestoreError, canRestore, }: VersionHistoryTableProps) { + const styles = useStyles2(getStyles); const [showConfirmModal, setShowConfirmModal] = useState(false); const [ruleToRestore, setRuleToRestore] = useState>(); const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? ''; @@ -41,6 +43,8 @@ export function VersionHistoryTable({ [ruleToRestoreUid] ); + const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]); + const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO) => { setShowConfirmModal(true); setRuleToRestore(ruleToRestore); @@ -52,6 +56,15 @@ export function VersionHistoryTable({ const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown'); + const notesColumn: Column> = { + id: 'notes', + header: t('core.versionHistory.table.notes', 'Notes'), + cell: ({ row }) => { + const message = row.original.grafana_alert.message; + return message || null; + }, + }; + const columns: Array>> = [ { disableGrow: true, @@ -91,9 +104,12 @@ export function VersionHistoryTable({ if (!value) { return unknown; } - return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'; + return ( + {dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'} + ); }, }, + ...(hasAnyNotes ? [notesColumn] : []), { id: 'diff', disableGrow: true, @@ -179,3 +195,9 @@ export function VersionHistoryTable({ ); } + +const getStyles = () => ({ + nowrap: css({ + whiteSpace: 'nowrap', + }), +}); diff --git a/public/app/features/alerting/unified/components/silences/utils.ts b/public/app/features/alerting/unified/components/silences/utils.ts index a0909f7e647..af3ba2cbfcb 100644 --- a/public/app/features/alerting/unified/components/silences/utils.ts +++ b/public/app/features/alerting/unified/components/silences/utils.ts @@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields => startsAt: interval.start.toISOString(), endsAt: interval.end.toISOString(), comment: silence.comment, - createdBy: silence.createdBy, + createdBy: isExpired ? contextSrv.user.name : silence.createdBy, duration: intervalToAbbreviatedDurationString(interval), isRegex: false, matchers: silence.matchers?.map(matcherToMatcherField) || [], diff --git a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts index b0f58408306..85905d480b3 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts @@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'service', name: '', }; + draft.grafana_alert.message = 'Updated by provisioning service'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { draft.grafana_alert.version = 5; @@ -171,6 +172,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'different', name: 'different user', }; + draft.grafana_alert.message = 'Changed alert title and thresholds'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { draft.grafana_alert.version = 3; @@ -193,6 +195,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'foo', name: '', }; + draft.grafana_alert.message = 'Updated evaluation interval and routing'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { draft.grafana_alert.version = 1; diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index d5ed0d39b6b..857cdc28769 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -194,4 +194,53 @@ describe('RuleEditor grafana managed rules', () => { ]), }); }); + + it('should not show rule type switch when no data sources have manageAlerts enabled', async () => { + // Setup data source with manageAlerts explicitly disabled + setupDataSources( + mockDataSource( + { + type: 'prometheus', + name: 'Prom-disabled', + uid: 'prometheus-disabled', + isDefault: true, + jsonData: { manageAlerts: false }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ) + ); + + renderRuleEditor(); + + // Wait for the form to load + await screen.findByRole('textbox', { name: 'name' }); + + // The rule type switch should NOT be visible + expect(screen.queryByText('Rule type')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rule-type-radio-group')).not.toBeInTheDocument(); + }); + + it('should show rule type switch when data sources have manageAlerts enabled', async () => { + // Setup data source with manageAlerts enabled + setupDataSources( + mockDataSource( + { + type: 'prometheus', + name: 'Prom-enabled', + uid: 'prometheus-enabled', + isDefault: true, + jsonData: { manageAlerts: true }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ) + ); + + renderRuleEditor(); + + // Wait for the form to load + await screen.findByRole('textbox', { name: 'name' }); + + // The rule type section should be visible + expect(await screen.findByText('Rule type')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index 7985791cfa2..1f33e3476ff 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -7,10 +7,11 @@ import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../mockApi'; -import { grantUserPermissions, grantUserRole } from '../mocks'; +import { grantUserPermissions, grantUserRole, mockDataSource } from '../mocks'; import { setGrafanaRuleGroupExportResolver } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { RulesFilter } from '../search/rulesSearchParser'; +import { setupDataSources } from '../testSetup/datasources'; import RuleListPage, { RuleListActions } from './RuleList.v2'; import { loadDefaultSavedSearch } from './filter/useSavedSearches'; @@ -365,6 +366,51 @@ describe('RuleListActions', () => { expect(ui.exportDrawer.query()).toBeInTheDocument(); }); }); + + describe('Data source options visibility', () => { + it('should not show "New Data source recording rule" option when no data sources have manageAlerts enabled', async () => { + // Set up only data sources with manageAlerts explicitly set to false + // This replaces the default data sources that have manageAlerts defaulting to true + setupDataSources( + mockDataSource({ + name: 'Prometheus-disabled', + uid: 'prometheus-disabled', + type: 'prometheus', + jsonData: { manageAlerts: false }, + }) + ); + + grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument(); + }); + + it('should show "New Data source recording rule" option when data sources have manageAlerts enabled', async () => { + // Set up data source with manageAlerts enabled + setupDataSources( + mockDataSource({ + name: 'Prometheus-enabled', + uid: 'prometheus-enabled', + type: 'prometheus', + jsonData: { manageAlerts: true }, + }) + ); + + grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument(); + }); + }); }); describe('RuleListPage v2 - View switching', () => { diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 284bd6ad757..b90663200ce 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; +import { getRulesDataSources } from '../utils/datasource'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; @@ -41,8 +42,11 @@ export function RuleListActions() { const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); + // Check if there are any data sources with manageAlerts enabled + const hasAlertEnabledDataSources = useMemo(() => getRulesDataSources().length > 0, []); + const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed; - const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed && hasAlertEnabledDataSources; const canExportRules = exportRulesSupported && exportRulesAllowed; const canCreateRules = canCreateGrafanaRules || canCreateCloudRules; diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index 8109de32145..effa0c8009b 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() { retry(); }; - if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { + if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) { return null; } @@ -76,10 +76,6 @@ export function RecentlyViewedDashboards() { )} {loading && } - {/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */} - {!loading && recentDashboards.length === 0 && ( - {t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')} - )} {!loading && recentDashboards.length > 0 && (
    diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 097c0d8d26c..515087010fb 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -40,7 +40,11 @@ import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel'; import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { + createV2RowsLayout, + SceneCreationOptions, + transformSaveModelToScene, +} from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { processQueryParamsForDashboardLoad, updateNavModel } from './utils'; @@ -106,6 +110,34 @@ interface DashboardScenePageStateManagerLike { useState: () => DashboardScenePageState; } +/** + * Creates scene creation options with appropriate layout creator + * based on feature flags and dashboard type. + */ +export function getSceneCreationOptions( + loadOptions?: LoadDashboardOptions, + meta?: { isSnapshot?: boolean } +): SceneCreationOptions | undefined { + const isReport = loadOptions?.route === DashboardRoutes.Report; + const isTemplate = loadOptions?.route === DashboardRoutes.Template; + const isSnapshot = meta?.isSnapshot ?? false; + + // Don't use v2 layout for reports or snapshots + if (isReport || isSnapshot || isTemplate) { + return undefined; + } + + // Use v2 layout creator when v2 API is enabled + if (shouldForceV2API()) { + return { + createLayout: createV2RowsLayout, + targetVersion: 'v2', + }; + } + + return undefined; +} + abstract class DashboardScenePageStateManagerBase extends StateManagerBase implements DashboardScenePageStateManagerLike @@ -155,7 +187,7 @@ abstract class DashboardScenePageStateManagerBase private async loadHomeDashboard(): Promise { const rsp = await this.fetchHomeDashboard(); if (rsp) { - return transformSaveModelToScene(rsp); + return transformSaveModelToScene(rsp, undefined, getSceneCreationOptions()); } return null; @@ -441,7 +473,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } if (rsp?.dashboard) { - const scene = transformSaveModelToScene(rsp, options); + const sceneCreationOptions = getSceneCreationOptions(options, rsp.meta); + const scene = transformSaveModelToScene(rsp, options, sceneCreationOptions); // Special handling for Template route - set up edit mode and dirty state if ( @@ -474,7 +507,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag throw new DashboardVersionError('v2beta1', 'Using legacy snapshot API to get a V2 dashboard'); } - const scene = transformSaveModelToScene(rsp); + // Snapshots should use default v1 layout + const scene = transformSaveModelToScene(rsp, undefined, getSceneCreationOptions(undefined, { isSnapshot: true })); return scene; } @@ -755,7 +789,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag return; } - const scene = transformSaveModelToScene(rsp); + const sceneCreationOptions = getSceneCreationOptions(undefined, rsp.meta); + const scene = transformSaveModelToScene(rsp, undefined, sceneCreationOptions); // we need to call and restore dashboard state on every reload that pulls a new dashboard version if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index b863bf2fc78..290e298c88c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -71,6 +71,7 @@ export class PanelOptionsPane extends SceneObjectBase { reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_PANEL_PLUGIN, plugin_id: pluginId, + from_suggestions: options.fromSuggestions ?? false, }); // clear custom options @@ -236,6 +237,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps )} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index acc4ff208e6..71c82bcf6ab 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -26,6 +26,7 @@ export interface Props { editPreview: VizPanel; onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; onClose: () => void; + isNewPanel?: boolean; } const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> => { @@ -42,7 +43,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> : [allVisualizationsTab, suggestionsTab]; }; -export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) { +export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton, isNewPanel }: Props) { const styles = useStyles2(getStyles); const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); const filterId = useId(); @@ -83,6 +84,16 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose [setListMode] ); + const handleBackButtonClick = useCallback(() => { + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.BACK_BUTTON, + tab: VisualizationSelectPaneTab[listMode], + creator_team: 'grafana_plugins_catalog', + schema_version: '1.0.0', + }); + onClose(); + }, [listMode, onClose]); + return (
    @@ -114,7 +125,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose variant="secondary" icon="arrow-left" data-testid={selectors.components.PanelEditor.toggleVizPicker} - onClick={onClose} + onClick={handleBackButtonClick} > Back @@ -136,6 +147,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose editPreview={editPreview} data={data} searchQuery={searchQuery} + isNewPanel={isNewPanel} /> )} {listMode === VisualizationSelectPaneTab.Visualizations && ( diff --git a/public/app/features/dashboard-scene/panel-edit/interaction.ts b/public/app/features/dashboard-scene/panel-edit/interaction.ts index 4ba515b5223..da147a60807 100644 --- a/public/app/features/dashboard-scene/panel-edit/interaction.ts +++ b/public/app/features/dashboard-scene/panel-edit/interaction.ts @@ -4,4 +4,5 @@ export const INTERACTION_ITEM = { SELECT_PANEL_PLUGIN: 'select_panel_plugin', CHANGE_TAB: 'change_tab', // for ref - PanelVizTypePicker SEARCH: 'search', // for ref - PanelVizTypePicker + BACK_BUTTON: 'back_button', // for ref - PanelVizTypePicker }; diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx index d363a95ddf0..a8c2cbb6ec0 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx @@ -7,10 +7,13 @@ import { SceneGridLayout, VizPanel, SceneVariableSet } from '@grafana/scenes'; import { activateFullSceneTree } from '../../utils/test-utils'; import { DashboardScene } from '../DashboardScene'; +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowItem } from '../layout-rows/RowItem'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabItem } from '../layout-tabs/TabItem'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { LayoutParent } from '../types/LayoutParent'; import { DashboardLayoutSelector } from './DashboardLayoutSelector'; @@ -40,6 +43,27 @@ describe('DashboardLayoutSelector', () => { await user.click(confirmButton); expect(switchLayoutMock).toHaveBeenCalled(); }); + + it('should disable tabs option when a row contains tabs layout and show correct message', async () => { + const scene = buildTestSceneWithNestedTabs(); + const layoutManager = scene.state.body; + + render(); + + const tabsOption = screen.getByLabelText('layout-selection-option-Tabs'); + expect(tabsOption).toBeDisabled(); + expect(screen.getByTitle('Cannot change to tabs because a row already contains tabs')).toBeInTheDocument(); + }); + + it('should not disable tabs option when rows do not contain tabs', async () => { + const scene = buildTestScene(); + const layoutManager = scene.state.body; + + render(); + + const tabsOption = screen.getByLabelText('layout-selection-option-Tabs'); + expect(tabsOption).not.toBeDisabled(); + }); }); const buildTestScene = () => { @@ -70,3 +94,43 @@ const buildTestScene = () => { activateFullSceneTree(scene); return scene; }; + +const buildTestSceneWithNestedTabs = () => { + const scene = new DashboardScene({ + title: 'testScene', + editable: true, + $variables: new SceneVariableSet({ + variables: [], + }), + body: new RowsLayoutManager({ + rows: [ + new RowItem({ + title: 'Row 1', + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + body: new VizPanel({ key: 'panel-1', pluginId: 'text' }), + }), + ], + }), + }), + }), + new RowItem({ + title: 'Row with Tabs', + layout: new TabsLayoutManager({ + tabs: [ + new TabItem({ + title: 'Tab 1', + layout: AutoGridLayoutManager.createEmpty(), + }), + ], + }), + }), + ], + }), + }); + + activateFullSceneTree(scene); + return scene; +}; diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx index b32555b32f8..ee902d195ad 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx @@ -11,6 +11,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { isLayoutParent } from '../types/LayoutParent'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; +import { containsTabsLayout } from './findAllGridTypes'; import { layoutRegistry } from './layoutRegistry'; export interface Props { @@ -22,19 +23,26 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout); const [newLayout, setNewLayout] = useState(); - const disableTabs = useMemo(() => { + const disableTabsReason = useMemo(() => { if (config.featureToggles.unlimitedLayoutsNesting) { - return false; + return undefined; } + + // Check parent hierarchy let parent = layoutManager.parent; while (parent) { if (parent instanceof TabsLayoutManager) { - return true; + return 'parent'; } parent = parent.parent; } - return false; + // Check child hierarchy + if (containsTabsLayout(layoutManager)) { + return 'child'; + } + + return undefined; }, [layoutManager]); const onChangeLayout = useCallback((newLayout: LayoutRegistryItem) => setNewLayout(newLayout), []); @@ -59,8 +67,15 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { const radioOptions = options.map((opt) => { let description = opt.description; - if (disableTabs && opt.id === TabsLayoutManager.descriptor.id) { - description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs'); + if (disableTabsReason && opt.id === TabsLayoutManager.descriptor.id) { + if (disableTabsReason === 'parent') { + description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs'); + } else { + description = t( + 'dashboard.canvas-actions.disabled-child-contains-tabs', + 'Cannot change to tabs because a row already contains tabs' + ); + } disabledOptions.push(opt); } diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts new file mode 100644 index 00000000000..b2c925fb26e --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts @@ -0,0 +1,93 @@ +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; +import { RowItem } from '../layout-rows/RowItem'; +import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabItem } from '../layout-tabs/TabItem'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; + +import { containsTabsLayout, findAllGridTypes } from './findAllGridTypes'; + +describe('findAllGridTypes', () => { + it('should return grid type for a grid layout', () => { + const layout = AutoGridLayoutManager.createEmpty(); + expect(findAllGridTypes(layout)).toEqual([AutoGridLayoutManager.descriptor.id]); + }); + + it('should return grid types from tabs', () => { + const layout = new TabsLayoutManager({ + tabs: [ + new TabItem({ layout: AutoGridLayoutManager.createEmpty() }), + new TabItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(findAllGridTypes(layout)).toEqual([ + AutoGridLayoutManager.descriptor.id, + AutoGridLayoutManager.descriptor.id, + ]); + }); + + it('should return grid types from rows', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(findAllGridTypes(layout)).toEqual([ + AutoGridLayoutManager.descriptor.id, + AutoGridLayoutManager.descriptor.id, + ]); + }); +}); + +describe('containsTabsLayout', () => { + it('should return true when layout is TabsLayoutManager', () => { + const layout = new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); + + it('should return false when layout is a grid layout', () => { + const layout = AutoGridLayoutManager.createEmpty(); + expect(containsTabsLayout(layout)).toBe(false); + }); + + it('should return false when layout is RowsLayoutManager with no tabs in rows', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(containsTabsLayout(layout)).toBe(false); + }); + + it('should return true when RowsLayoutManager contains a row with tabs layout', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ + layout: new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }), + }), + ], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); + + it('should return true when any row contains tabs layout', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ + layout: new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }), + }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts index 03dec1482fb..6050e25a94f 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts @@ -15,3 +15,15 @@ export function findAllGridTypes(layout: DashboardLayoutManager): string[] { return []; } + +export function containsTabsLayout(layout: DashboardLayoutManager): boolean { + if (layout instanceof TabsLayoutManager) { + return true; + } + + if (layout instanceof RowsLayoutManager) { + return layout.state.rows.some((row) => containsTabsLayout(row.getLayout())); + } + + return false; +} diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx index 8bce6364a34..a31927a3e92 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx @@ -128,7 +128,7 @@ describe('PanelTimeRange', () => { expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC }); - it('should handle invalid time reference in timeShift', () => { + it('should handle invalid time reference in timeShift with relative time range', () => { const panelTime = new PanelTimeRange({ timeShift: 'now-1d' }); buildAndActivateSceneFor(panelTime); @@ -139,6 +139,22 @@ describe('PanelTimeRange', () => { expect(panelTime.state.to).toBe('now'); }); + it('should handle invalid time reference in timeShift with absolute time range', () => { + const panelTime = new PanelTimeRange({ timeShift: 'now-1d' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const absoluteFrom = '2019-02-11T10:00:00.000Z'; + const absoluteTo = '2019-02-11T16:00:00.000Z'; + const scene = new SceneFlexLayout({ + $timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }), + children: [new SceneFlexItem({ body: panel })], + }); + activateFullSceneTree(scene); + + expect(panelTime.state.timeInfo).toBe('invalid timeshift'); + expect(panelTime.state.from).toBe(absoluteFrom); + expect(panelTime.state.to).toBe(absoluteTo); + }); + it('should handle invalid time reference in timeShift combined with timeFrom', () => { const panelTime = new PanelTimeRange({ timeFrom: 'now-2h', @@ -153,6 +169,66 @@ describe('PanelTimeRange', () => { expect(panelTime.state.to).toBe('now'); }); + describe('from/to state format for liveNow compatibility', () => { + it('should store relative strings in from/to when timeShift is applied to relative time range', () => { + const panelTime = new PanelTimeRange({ timeShift: '2h' }); + + buildAndActivateSceneFor(panelTime); + + expect(panelTime.state.from).toBe('now-6h-2h'); + expect(panelTime.state.to).toBe('now-2h'); + expect(panelTime.state.value.raw.from).toBe('now-6h-2h'); + expect(panelTime.state.value.raw.to).toBe('now-2h'); + }); + + it('should store relative strings when both timeFrom and timeShift are applied', () => { + const panelTime = new PanelTimeRange({ timeFrom: '2h', timeShift: '1h' }); + + buildAndActivateSceneFor(panelTime); + + expect(panelTime.state.from).toBe('now-2h-1h'); + expect(panelTime.state.to).toBe('now-1h'); + }); + + it('should store ISO strings when timeShift is applied to absolute time range', () => { + const panelTime = new PanelTimeRange({ timeShift: '1h' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const absoluteFrom = '2019-02-11T10:00:00.000Z'; + const absoluteTo = '2019-02-11T16:00:00.000Z'; + const scene = new SceneFlexLayout({ + $timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }), + children: [new SceneFlexItem({ body: panel })], + }); + activateFullSceneTree(scene); + + expect(panelTime.state.from).toBe('2019-02-11T09:00:00.000Z'); + expect(panelTime.state.to).toBe('2019-02-11T15:00:00.000Z'); + }); + + it('should update from/to when ancestor time range changes', () => { + const panelTime = new PanelTimeRange({ timeShift: '1h' }); + const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const scene = new SceneFlexLayout({ + $timeRange: sceneTimeRange, + children: [new SceneFlexItem({ body: panel })], + }); + activateFullSceneTree(scene); + + expect(panelTime.state.from).toBe('now-6h-1h'); + expect(panelTime.state.to).toBe('now-1h'); + + sceneTimeRange.onTimeRangeChange({ + from: dateTime('2019-02-11T12:00:00.000Z'), + to: dateTime('2019-02-11T18:00:00.000Z'), + raw: { from: 'now-12h', to: 'now' }, + }); + + expect(panelTime.state.from).toBe('now-12h-1h'); + expect(panelTime.state.to).toBe('now-1h'); + }); + }); + describe('onTimeRangeChange', () => { it('should reverse timeShift when updating time range', () => { const oneHourShift = '1h'; diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx index 04f3eac85e2..7fe4133a7fa 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx @@ -81,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase { }); it('Should convert legacy rows to new rows', () => { - const scene = transformSaveModelToScene({ - dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; @@ -857,10 +862,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows with free panels before first row', () => { - const scene = transformSaveModelToScene({ - dashboard: rowsAfterFreePanels as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: rowsAfterFreePanels as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 3da62d6cdf4..8f80dcc526e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -29,11 +29,11 @@ import { } from 'app/features/dashboard/services/DashboardProfiler'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { DashboardDTO, DashboardDataDTO, DashboardRoutes } from 'app/types/dashboard'; +import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior'; -import { LoadDashboardOptions, shouldForceV2API } from '../pages/DashboardScenePageStateManager'; +import { LoadDashboardOptions } from '../pages/DashboardScenePageStateManager'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -76,11 +76,53 @@ export interface SaveModelToSceneOptions { isEmbedded?: boolean; } -export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashboardOptions): DashboardScene { +type LayoutCreator = (panels: PanelModel[], preload?: boolean) => DashboardLayoutManager; + +export interface SceneCreationOptions { + /** + * When provided, this function is used to create the dashboard body/layout instead of the default v1 behavior. + * This allows callers to inject v2 layout strategy. + */ + createLayout?: LayoutCreator; + /** + * Determines how the dashboard scene is serialized. + * @default 'v1' + */ + targetVersion?: 'v1' | 'v2'; +} + +// Rows as SceneGridRow within the grid. +const createDefaultGridLayout: LayoutCreator = (panels, preload) => { + return new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: getIsLazy(preload), + children: createSceneObjectsForPanels(panels), + }), + }); +}; + +/** + * V2 layout creator - uses RowsLayoutManager when dashboard has rows. + * This creates a layout that can be properly serialized to v2 format. + */ +export const createV2RowsLayout: LayoutCreator = (panels, preload) => { + const hasRows = panels.some((p) => p.type === 'row'); + if (hasRows) { + return createRowsFromPanels(panels); + } + // Fall back to default grid layout when no rows + return createDefaultGridLayout(panels, preload); +}; + +export function transformSaveModelToScene( + rsp: DashboardDTO, + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions +): DashboardScene { // Just to have migrations run const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); - const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options); + const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options, sceneOptions); // TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model const apiVersion = config.featureToggles.kubernetesDashboards @@ -92,7 +134,7 @@ export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashb return scene; } -export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { +function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { const rowItems: RowItem[] = []; let currentLegacyRow: PanelModel | null = null; @@ -143,7 +185,7 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager }); } -export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { +function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { // collects all panels and rows const panels: SceneGridItemLike[] = []; @@ -259,14 +301,14 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[] export function createDashboardSceneFromDashboardModel( oldModel: DashboardModel, dto: DashboardDataDTO, - options?: LoadDashboardOptions + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions ) { let variables: SceneVariableSet | undefined; let annotationLayers: SceneDataLayerProvider[] = []; let alertStatesLayer: AlertStatesDataLayer | undefined; const uid = oldModel.uid; - const isReport = options?.route === DashboardRoutes.Report; - const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot && !isReport ? 'v2' : 'v1'; + const targetVersion = sceneOptions?.targetVersion ?? 'v1'; if (oldModel.meta.isSnapshot) { variables = createVariablesForSnapshot(oldModel); @@ -354,9 +396,11 @@ export function createDashboardSceneFromDashboardModel( let body: DashboardLayoutManager; - if (serializerVersion === 'v2' && oldModel.panels.some((p) => p.type === 'row')) { - body = createRowsFromPanels(oldModel.panels); + if (sceneOptions?.createLayout) { + // Use injected layout creator (allows callers to specify v2 or custom layout strategy) + body = sceneOptions.createLayout(oldModel.panels, dto.preload); } else { + // Default v1 layout: DefaultGridLayoutManager body = new DefaultGridLayoutManager({ grid: new SceneGridLayout({ isLazy: getIsLazy(dto.preload), @@ -404,7 +448,7 @@ export function createDashboardSceneFromDashboardModel( hideTimeControls: oldModel.timepicker.hidden, }), }, - serializerVersion + targetVersion ); // Enable panel profiling for this dashboard using the composed SceneRenderProfiler diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts index 9dbf8e36f0a..a870f4feef5 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts @@ -1,6 +1,8 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; @@ -207,22 +209,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); @@ -279,22 +285,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts index 74208ff8a8f..af6116b9adf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts @@ -6,6 +6,8 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModel } from './transformSceneToSaveModel'; @@ -228,22 +230,26 @@ function removeMetadata(spec: Dashboard): Partial { * identical processing. */ function loadAndSerializeV1SaveModel(dashboard: Dashboard): Dashboard { - const scene = transformSaveModelToScene({ - dashboard: dashboard as DashboardDataDTO, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboard as DashboardDataDTO, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); return transformSceneToSaveModel(scene, false); } diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index 0e9d6ad49c5..c255cd4af5b 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -23,6 +23,7 @@ import { DashboardDataDTO } from 'app/types/dashboard'; import { DashboardScene } from '../scene/DashboardScene'; import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters'; +import { createV2RowsLayout, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2'; import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; @@ -216,7 +217,27 @@ export class ShareExportTab extends SceneObjectBase impleme } if (exportMode === ExportMode.V2Resource) { - const spec = transformSceneToSaveModelSchemaV2(scene); + let sceneForV2Export = scene; + + // When exporting v1 dashboard as v2, we need to recreate the scene with v2 layout creator + // to ensure rows are properly serialized. The v1 scene uses DefaultGridLayoutManager which + // doesn't know about RowsLayoutManager structure needed for v2 serialization. + if (initialSaveModelVersion === 'v1' && initialSaveModel && isV1ClassicDashboard(initialSaveModel)) { + // Recreate scene with v2 layout creator to properly handle rows + sceneForV2Export = transformSaveModelToScene( + { + dashboard: { ...initialSaveModel, title: initialSaveModel.title ?? '', uid: initialSaveModel.uid ?? '' }, + meta: scene.state.meta, + }, + undefined, + { + createLayout: createV2RowsLayout, + targetVersion: 'v2', + } + ); + } + + const spec = transformSceneToSaveModelSchemaV2(sceneForV2Export); const specCopy = JSON.parse(JSON.stringify(spec)); const statelessSpec = await makeExportableV2(specCopy, isSharingExternally); const exportableV2 = isSharingExternally ? statelessSpec : spec; diff --git a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts index 2ca8d2a2411..63fb8016294 100644 --- a/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformersToBackend.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { getSceneCreationOptions } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; import { normalizeBackendOutputForFrontendComparison } from 'app/features/dashboard-scene/serialization/serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene'; @@ -193,22 +194,26 @@ describe('V1 to V2 Dashboard Transformation Comparison (ResponseTransformers)', delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx index 96165ca03be..9a5245cd7a0 100644 --- a/public/app/features/explore/Logs/LogsTable.tsx +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -33,7 +33,7 @@ import { useStyles2, } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal'; -import { LogsFrame } from 'app/features/logs/logsFrame'; +import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame'; import { getFieldLinksForExplore } from '../utils/links'; @@ -154,9 +154,9 @@ export function LogsTable(props: Props) { }, }); // `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx - for (const [index, field] of frameWithOverrides.fields.entries()) { + for (const [fieldIdx, field] of frameWithOverrides.fields.entries()) { // Hide ID field from visualization (it's only needed for row matching) - if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === 'id')) { + if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === DATAPLANE_ID_NAME)) { field.config = { ...field.config, custom: { @@ -180,7 +180,7 @@ export function LogsTable(props: Props) { }; // For the first field (time), wrap the cell to include action buttons - const isFirstField = index === 0; + const isFirstField = fieldIdx === 0; field.config = { ...field.config, @@ -202,7 +202,6 @@ export function LogsTable(props: Props) { panelState={props.panelState} absoluteRange={props.absoluteRange} logRows={props.logRows} - rowIndex={cellProps.rowIndex} /> {cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)} diff --git a/public/app/features/explore/Logs/LogsTableActionButtons.tsx b/public/app/features/explore/Logs/LogsTableActionButtons.tsx index 88e3c1db4ed..f60d5eaa072 100644 --- a/public/app/features/explore/Logs/LogsTableActionButtons.tsx +++ b/public/app/features/explore/Logs/LogsTableActionButtons.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useState } from 'react'; +import { useCallback, useState, memo } from 'react'; import { AbsoluteTimeRange, @@ -13,7 +13,7 @@ import { t } from '@grafana/i18n'; import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui'; import { getLogsPermalinkRange } from 'app/core/utils/shortLinks'; import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync'; -import { LogsFrame } from 'app/features/logs/logsFrame'; +import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame'; import { getState } from 'app/store/store'; import { getExploreBaseUrl } from './utils/url'; @@ -28,25 +28,20 @@ interface Props extends CustomCellRendererProps { index?: number; } -export function LogsTableActionButtons(props: Props) { +export const LogsTableActionButtons = memo((props: Props) => { const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props; - const theme = useTheme2(); const [isInspecting, setIsInspecting] = useState(false); // Get logId from the table frame (frame), not the original logsFrame, because // the table frame is sorted/transformed and rowIndex refers to the table frame - const idFieldName = logsFrame?.idField?.name ?? 'id'; - const idField = frame.fields.find((field) => field.name === idFieldName || field.name === 'id'); + const idFieldName = logsFrame?.idField?.name ?? DATAPLANE_ID_NAME; + const idField = frame.fields.find((field) => field.name === idFieldName || field.name === DATAPLANE_ID_NAME); const logId = idField?.values[rowIndex]; - const getLineValue = () => { - const bodyFieldName = logsFrame?.bodyField?.name; - const bodyField = bodyFieldName - ? frame.fields.find((field) => field.name === bodyFieldName) - : frame.fields.find((field) => field.type === 'string'); - return bodyField?.values[rowIndex]; - }; - const lineValue = getLineValue(); + const getLineValue = () => { + const logRowById = logRows?.find((row) => row.rowId === logId); + return logRowById?.raw ?? ''; + }; const styles = getStyles(theme); @@ -105,33 +100,29 @@ export function LogsTableActionButtons(props: Props) { return ( <>
    -
    - -
    -
    - -
    + +
    {isInspecting && ( -
    {lineValue}
    +
    {getLineValue()}
    - lineValue}> + getLineValue()}> {t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')} @@ -149,15 +140,11 @@ export function LogsTableActionButtons(props: Props) { )} ); -} +}); -export const getStyles = (theme: GrafanaTheme2) => ({ - clipboardButton: css({ - height: '100%', - lineHeight: '1', - padding: 0, - width: '20px', - }), +LogsTableActionButtons.displayName = 'LogsTableActionButtons'; + +const getStyles = (theme: GrafanaTheme2) => ({ iconWrapper: css({ background: theme.colors.background.secondary, boxShadow: theme.shadows.z2, @@ -166,25 +153,50 @@ export const getStyles = (theme: GrafanaTheme2) => ({ height: '35px', left: 0, top: 0, - padding: `0 ${theme.spacing(0.5)}`, + padding: 0, position: 'absolute', zIndex: 1, + alignItems: 'center', + // Fix switching icon direction when cell is numeric (rtl) + direction: 'ltr', }), - inspect: css({ - '& button svg': { - marginRight: 'auto', + icon: css({ + gap: 0, + margin: 0, + padding: 0, + borderRadius: theme.shape.radius.default, + width: '28px', + height: '32px', + display: 'inline-flex', + justifyContent: 'center', + + '&:before': { + content: '""', + position: 'absolute', + width: 24, + height: 24, + top: 0, + bottom: 0, + left: 0, + right: 0, + margin: 'auto', + borderRadius: theme.shape.radius.default, + backgroundColor: theme.colors.background.primary, + zIndex: -1, + opacity: 0, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transitionDuration: '0.2s', + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionProperty: 'opacity', + }, }, '&:hover': { color: theme.colors.text.link, cursor: 'pointer', + background: 'none', + '&:before': { + opacity: 1, + }, }, - padding: '5px 3px', - }), - inspectButton: css({ - borderRadius: theme.shape.radius.default, - display: 'inline-flex', - margin: 0, - overflow: 'hidden', - verticalAlign: 'middle', }), }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx index 734479ff4ce..fd525152a57 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx @@ -132,6 +132,7 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) { type: profilesDataSourceSettings.type, uid: profilesDataSourceSettings.uid, }, + includeExemplars: false, }, ], }; diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index fceb78b0b49..df2972c4483 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -200,7 +200,7 @@ describe('createSpanLinkFactory', () => { datasource: 'loki1_uid', queries: [ { - expr: '{cluster="cluster1", hostname="hostname1", service_namespace="namespace1"} | label_format log_line_contains_trace_id=`{{ contains "7946b05c2e2e4e5a" __line__ }}` | log_line_contains_trace_id="true" or trace_id="7946b05c2e2e4e5a" | label_format log_line_contains_span_id=`{{ contains "6605c7b08e715d6c" __line__ }}` | log_line_contains_span_id="true" or span_id="6605c7b08e715d6c"', + expr: '{cluster="cluster1", hostname="hostname1", service_namespace="namespace1"} | logfmt | json | drop __error__, __error_details__ | trace_id="7946b05c2e2e4e5a" | span_id="6605c7b08e715d6c"', refId: '', datasource: { uid: 'loki1_uid' }, }, diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 4feed49e07d..1b7cbc83054 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -446,12 +446,12 @@ function getQueryForLoki( let expr = '{${__tags}}'; if (filterByTraceID && span.traceID) { - expr += - ' | label_format log_line_contains_trace_id=`{{ contains "${__span.traceId}" __line__ }}` | log_line_contains_trace_id="true" or trace_id="${__span.traceId}"'; - } - if (filterBySpanID && span.spanID) { - expr += - ' | label_format log_line_contains_span_id=`{{ contains "${__span.spanId}" __line__ }}` | log_line_contains_span_id="true" or span_id="${__span.spanId}"'; + expr += ' | logfmt | json | drop __error__, __error_details__ | trace_id="${__span.traceId}"'; + if (filterBySpanID && span.spanID) { + expr += ' | span_id="${__span.spanId}"'; + } + } else if (filterBySpanID && span.spanID) { + expr += ' | logfmt | json | drop __error__, __error_details__ | span_id="${__span.spanId}"'; } return { diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index 9e2cfd50b75..433ce7f1d91 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils'; +import { render, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils'; import { ExpressionQuery, ExpressionQueryType } from '../../types'; @@ -72,12 +72,12 @@ describe('SqlExpr', () => { const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery; - await act(async () => { - render(); - }); + render(); // Verify onChange was called - expect(onChange).toHaveBeenCalled(); + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); // Verify essential SQL structure without exact string matching const updatedQuery = onChange.mock.calls[0][0]; @@ -90,19 +90,12 @@ describe('SqlExpr', () => { const existingExpression = 'SELECT 1 AS foo'; const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery; - await act(async () => { - render(); - }); - - // Check if onChange was called - if (onChange.mock.calls.length > 0) { - // If called, ensure it didn't change the expression value - const updatedQuery = onChange.mock.calls[0][0]; - expect(updatedQuery.expression).toBe(existingExpression); - } + render(); // The SQLEditor should receive the existing expression - expect(query.expression).toBe(existingExpression); + await waitFor(() => { + expect(query.expression).toBe(existingExpression); + }); }); it('adds alerting format when alerting prop is true', async () => { @@ -110,40 +103,12 @@ describe('SqlExpr', () => { const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery; - await act(async () => { - render(); + render(); + + await waitFor(() => { + const updatedQuery = onChange.mock.calls[0][0]; + expect(updatedQuery.format).toBe('alerting'); }); - - const updatedQuery = onChange.mock.calls[0][0]; - expect(updatedQuery.format).toBe('alerting'); - }); -}); - -describe('SqlExpr with GenAI features', () => { - const defaultProps: SqlExprProps = { - onChange: jest.fn(), - refIds: [{ value: 'A' }], - query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, - queries: [], - }; - - it('renders suggestions drawer when isDrawerOpen is true', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ - isDrawerOpen: true, - suggestions: ['suggestion1', 'suggestion2'], - })); - - const { findByTestId } = render(); - expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); - }); - - it('renders explanation drawer when isExplanationOpen is true', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); - - const { findByTestId } = render(); - expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); }); }); @@ -166,10 +131,10 @@ describe('Schema Inspector feature toggle', () => { }); }); - it('renders panel open by default', () => { - const { getByText } = render(); + it('renders panel open by default', async () => { + const { findByText } = render(); - expect(getByText('No schema information available')).toBeInTheDocument(); + expect(await findByText('No schema information available')).toBeInTheDocument(); }); it('closes panel and shows reopen button when close button clicked', async () => { @@ -178,7 +143,7 @@ describe('Schema Inspector feature toggle', () => { expect(queryByText('No schema information available')).toBeInTheDocument(); const closeButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(closeButton)); + await userEvent.click(closeButton); expect(queryByText('No schema information available')).not.toBeInTheDocument(); expect(await findByText('Schema inspector')).toBeInTheDocument(); @@ -188,12 +153,12 @@ describe('Schema Inspector feature toggle', () => { const { queryByText, getByText } = render(); const closeButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(closeButton)); + await userEvent.click(closeButton); expect(queryByText('No schema information available')).not.toBeInTheDocument(); const reopenButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(reopenButton)); + await userEvent.click(reopenButton); expect(queryByText('No schema information available')).toBeInTheDocument(); }); @@ -233,3 +198,33 @@ describe('Schema Inspector feature toggle', () => { }); }); }); + +describe('SqlExpr with GenAI features', () => { + const defaultProps: SqlExprProps = { + onChange: jest.fn(), + refIds: [{ value: 'A' }], + query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, + queries: [], + }; + + it('renders suggestions drawer when isDrawerOpen is true', async () => { + // TODO this inline require breaks future tests - do it differently! + const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); + useSQLSuggestions.mockImplementation(() => ({ + isDrawerOpen: true, + suggestions: ['suggestion1', 'suggestion2'], + })); + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); + }); + + it('renders explanation drawer when isExplanationOpen is true', async () => { + // TODO this inline require breaks future tests - do it differently! + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); + + const { findByTestId } = render(); + expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index 941dca5416d..8645dfb4c4e 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -340,14 +340,16 @@ describe('LibraryPanelsSearch', () => { await user.click(screen.getAllByRole('button', { name: 'Delete' })[1]); await waitFor(() => - expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ - searchString: '', - folderFilterUIDs: ['wfTJJL5Wz'], - page: 1, - typeFilter: [], - sortDirection: undefined, - perPage: 40, - }) + expect(getLibraryPanelsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: '', + folderFilterUIDs: ['wfTJJL5Wz'], + page: 1, + typeFilter: [], + sortDirection: undefined, + perPage: 40, + }) + ) ); }); }); diff --git a/public/app/features/logs/logsFrame.ts b/public/app/features/logs/logsFrame.ts index 5a9938b6e2b..90a3439d3c9 100644 --- a/public/app/features/logs/logsFrame.ts +++ b/public/app/features/logs/logsFrame.ts @@ -32,7 +32,7 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW const DATAPLANE_TIMESTAMP_NAME = 'timestamp'; const DATAPLANE_BODY_NAME = 'body'; const DATAPLANE_SEVERITY_NAME = 'severity'; -const DATAPLANE_ID_NAME = 'id'; +export const DATAPLANE_ID_NAME = 'id'; const DATAPLANE_LABELS_NAME = 'labels'; // NOTE: this is a hot fn, we need to avoid allocating new objects here diff --git a/public/app/features/manage-dashboards/utils/validation.ts b/public/app/features/manage-dashboards/utils/validation.ts index 3c591b6f6cb..20bcf0cc56f 100644 --- a/public/app/features/manage-dashboards/utils/validation.ts +++ b/public/app/features/manage-dashboards/utils/validation.ts @@ -18,6 +18,10 @@ export const validateDashboardJson = (json: string) => { if (hasInvalidTag) { return t('dashboard.validation.tags-expected-strings', 'tags expected array of strings'); } + const hasTooLongTag = dashboard.tags.some((tag: string) => tag.length > 50); + if (hasTooLongTag) { + return t('dashboard.validation.tag-too-long', 'Dashboard tag too long, max 50 characters'); + } } else { return t('dashboard.validation.tags-expected-array', 'tags expected array'); } diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 924b5f3b6bf..c29f1b60a21 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -22,6 +22,7 @@ import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; import { hasData } from '../../suggestions/utils'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; +import { VizSuggestionsInteractions, PANEL_STATES, type PanelState } from './interactions'; import { VizTypeChangeDetails } from './types'; export interface Props { @@ -30,6 +31,7 @@ export interface Props { data?: PanelData; panel?: PanelModel; searchQuery?: string; + isNewPanel?: boolean; } const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => { @@ -62,7 +64,7 @@ const useSuggestions = (data: PanelData | undefined, searchQuery: string | undef return { value: filteredValue, loading, error, retry }; }; -export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) { +export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery, isNewPanel }: Props) { const styles = useStyles2(getStyles); const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); @@ -75,6 +77,18 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions; const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID; + const panelState = useMemo((): PanelState => { + if (isUnconfiguredPanel) { + return PANEL_STATES.UNCONFIGURED_PANEL; + } + + if (isNewPanel) { + return PANEL_STATES.NEW_PANEL; + } + + return PANEL_STATES.EXISTING_PANEL; + }, [isUnconfiguredPanel, isNewPanel]); + const suggestionsByVizType = useMemo(() => { const meta = getAllPanelPluginMeta(); const record: Record = {}; @@ -96,22 +110,36 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s }, [suggestions]); const applySuggestion = useCallback( - (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { + (suggestion: PanelPluginVisualizationSuggestion, isPreview: boolean, isAutoSelected = false) => { + if (isPreview) { + VizSuggestionsInteractions.suggestionPreviewed({ + pluginId: suggestion.pluginId, + suggestionName: suggestion.name, + panelState, + isAutoSelected, + }); + + setSuggestionHash(suggestion.hash); + } else { + VizSuggestionsInteractions.suggestionAccepted({ + pluginId: suggestion.pluginId, + suggestionName: suggestion.name, + panelState, + }); + } + onChange( { pluginId: suggestion.pluginId, options: suggestion.options, fieldConfig: suggestion.fieldConfig, withModKey: isPreview, + fromSuggestions: true, }, isPreview ? editPreview : undefined ); - - if (isPreview) { - setSuggestionHash(suggestion.hash); - } }, - [onChange, editPreview] + [onChange, editPreview, panelState] ); useEffect(() => { @@ -124,7 +152,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s // the previously selected suggestion is no longer present in the list. const newFirstCardHash = suggestions?.[0]?.hash ?? null; if (firstCardHash !== newFirstCardHash || suggestions.every((s) => s.hash !== suggestionHash)) { - applySuggestion(suggestions[0], true); + applySuggestion(suggestions[0], true, true); setFirstCardHash(newFirstCardHash); return; } @@ -243,7 +271,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s suggestion={suggestion} width={width} tabIndex={index} - onClick={() => applySuggestion(suggestion)} + onClick={() => applySuggestion(suggestion, false)} />
    ))} diff --git a/public/app/features/panel/components/VizTypePicker/interactions.ts b/public/app/features/panel/components/VizTypePicker/interactions.ts new file mode 100644 index 00000000000..3062919c93a --- /dev/null +++ b/public/app/features/panel/components/VizTypePicker/interactions.ts @@ -0,0 +1,28 @@ +import { reportInteraction } from '@grafana/runtime'; + +export const PANEL_STATES = { + UNCONFIGURED_PANEL: 'unconfigured_panel', + NEW_PANEL: 'new_panel', + EXISTING_PANEL: 'existing_panel', +} as const; + +export type PanelState = (typeof PANEL_STATES)[keyof typeof PANEL_STATES]; + +export const VizSuggestionsInteractions = { + suggestionPreviewed: (properties: { + pluginId: string; + suggestionName: string; + panelState: PanelState; + isAutoSelected?: boolean; + }) => { + reportVizSuggestionsInteraction('suggestion_previewed', properties); + }, + + suggestionAccepted: (properties: { pluginId: string; suggestionName: string; panelState: PanelState }) => { + reportVizSuggestionsInteraction('suggestion_accepted', properties); + }, +}; + +const reportVizSuggestionsInteraction = (name: string, properties?: Record) => { + reportInteraction(`grafana_viz_suggestions_${name}`, properties); +}; diff --git a/public/app/features/panel/components/VizTypePicker/types.ts b/public/app/features/panel/components/VizTypePicker/types.ts index 4dbab692faf..6eac9011184 100644 --- a/public/app/features/panel/components/VizTypePicker/types.ts +++ b/public/app/features/panel/components/VizTypePicker/types.ts @@ -5,4 +5,5 @@ export interface VizTypeChangeDetails { options?: Record; fieldConfig?: FieldConfigSource; withModKey?: boolean; + fromSuggestions?: boolean; } diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts index 07820587be5..eaf56bd99c0 100644 --- a/public/app/features/panel/suggestions/utils.ts +++ b/public/app/features/panel/suggestions/utils.ts @@ -5,7 +5,7 @@ import { VisualizationSuggestion, VisualizationSuggestionScore, } from '@grafana/data'; -import { ReduceDataOptions } from '@grafana/schema'; +import { LegendDisplayMode, ReduceDataOptions, VizLegendOptions } from '@grafana/schema'; /** * @internal @@ -62,3 +62,15 @@ export function defaultNumericVizOptions( export function hasData(data?: PanelData): boolean { return Boolean(data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0)); } + +/** + * @internal + * Hidden legend config for previewing suggestion cards. + * This should only be used in previewModifier. + */ +export const SUGGESTIONS_LEGEND_OPTIONS: VizLegendOptions = { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'right', + showLegend: false, +}; diff --git a/public/app/features/scopes/ScopesApiClient.test.ts b/public/app/features/scopes/ScopesApiClient.test.ts new file mode 100644 index 00000000000..979da40b6ce --- /dev/null +++ b/public/app/features/scopes/ScopesApiClient.test.ts @@ -0,0 +1,362 @@ +import { getBackendSrv, config } from '@grafana/runtime'; + +import { ScopesApiClient } from './ScopesApiClient'; + +// Mock the runtime dependencies +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: jest.fn(), + config: { + featureToggles: { + useMultipleScopeNodesEndpoint: true, + useScopeSingleNodeEndpoint: true, + }, + }, +})); + +jest.mock('@grafana/api-clients', () => ({ + getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'), +})); + +describe('ScopesApiClient', () => { + let apiClient: ScopesApiClient; + let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>; + + beforeEach(() => { + mockBackendSrv = { + get: jest.fn(), + }; + (getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv); + apiClient = new ScopesApiClient(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchMultipleScopeNodes', () => { + it('should fetch multiple nodes by names', async () => { + const mockNodes = [ + { + metadata: { name: 'node-1' }, + spec: { nodeType: 'container', title: 'Node 1', parentName: '' }, + }, + { + metadata: { name: 'node-2' }, + spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names: ['node-1', 'node-2'], + }); + expect(result).toEqual(mockNodes); + }); + + it('should return empty array when names array is empty', async () => { + const result = await apiClient.fetchMultipleScopeNodes([]); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should return empty array when feature toggle is disabled', async () => { + config.featureToggles.useMultipleScopeNodesEndpoint = false; + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toEqual([]); + + // Restore feature toggle + config.featureToggles.useMultipleScopeNodesEndpoint = true; + }); + + it('should handle API errors gracefully', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('Network error')); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle response with no items field', async () => { + mockBackendSrv.get.mockResolvedValue({}); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle response with null items', async () => { + mockBackendSrv.get.mockResolvedValue({ items: null }); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle large arrays of node names', async () => { + const names = Array.from({ length: 100 }, (_, i) => `node-${i}`); + const mockNodes = names.map((name) => ({ + metadata: { name }, + spec: { nodeType: 'leaf', title: name, parentName: '' }, + })); + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchMultipleScopeNodes(names); + + expect(result).toEqual(mockNodes); + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names, + }); + }); + + it('should pass through node names exactly as provided', async () => { + const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes']; + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchMultipleScopeNodes(names); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names, + }); + }); + }); + + describe('fetchScopeNode', () => { + it('should fetch a single scope node by ID', async () => { + const mockNode = { + metadata: { name: 'test-node' }, + spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' }, + }; + + mockBackendSrv.get.mockResolvedValue(mockNode); + + const result = await apiClient.fetchScopeNode('test-node'); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node'); + expect(result).toEqual(mockNode); + }); + + it('should return undefined when feature toggle is disabled', async () => { + config.featureToggles.useScopeSingleNodeEndpoint = false; + + const result = await apiClient.fetchScopeNode('test-node'); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + + // Restore feature toggle + config.featureToggles.useScopeSingleNodeEndpoint = true; + }); + + it('should return undefined on API error', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + + const result = await apiClient.fetchScopeNode('non-existent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('fetchNodes', () => { + it('should fetch nodes with parent filter', async () => { + const mockNodes = [ + { + metadata: { name: 'child-1' }, + spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchNodes({ parent: 'parent' }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: 'parent', + query: undefined, + limit: 1000, + }); + expect(result).toEqual(mockNodes); + }); + + it('should fetch nodes with query filter', async () => { + const mockNodes = [ + { + metadata: { name: 'matching-node' }, + spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchNodes({ query: 'matching' }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: 'matching', + limit: 1000, + }); + expect(result).toEqual(mockNodes); + }); + + it('should respect custom limit', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchNodes({ limit: 50 }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: undefined, + limit: 50, + }); + }); + + it('should throw error for invalid limit (too small)', async () => { + await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should throw error for invalid limit (too large)', async () => { + await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should use default limit of 1000 when not specified', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchNodes({}); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: undefined, + limit: 1000, + }); + }); + + it('should return empty array on API error', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('API Error')); + + const result = await apiClient.fetchNodes({ parent: 'test' }); + + expect(result).toEqual([]); + }); + }); + + describe('fetchScope', () => { + it('should fetch a scope by name', async () => { + const mockScope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + mockBackendSrv.get.mockResolvedValue(mockScope); + + const result = await apiClient.fetchScope('test-scope'); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope'); + expect(result).toEqual(mockScope); + }); + + it('should return undefined on error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + + const result = await apiClient.fetchScope('non-existent'); + + expect(result).toBeUndefined(); + consoleErrorSpy.mockRestore(); + }); + + it('should log error to console', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Not found'); + mockBackendSrv.get.mockRejectedValue(error); + + await apiClient.fetchScope('non-existent'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(error); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchMultipleScopes', () => { + it('should fetch multiple scopes in parallel', async () => { + const mockScopes = [ + { + metadata: { name: 'scope-1' }, + spec: { title: 'Scope 1', filters: [] }, + }, + { + metadata: { name: 'scope-2' }, + spec: { title: 'Scope 2', filters: [] }, + }, + ]; + + mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]); + + const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']); + + expect(mockBackendSrv.get).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockScopes); + }); + + it('should filter out undefined scopes', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const mockScope = { + metadata: { name: 'scope-1' }, + spec: { title: 'Scope 1', filters: [] }, + }; + + mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found')); + + const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']); + + expect(result).toEqual([mockScope]); + consoleErrorSpy.mockRestore(); + }); + + it('should return empty array when no scopes provided', async () => { + const result = await apiClient.fetchMultipleScopes([]); + + expect(result).toEqual([]); + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + }); + }); + + describe('performance considerations', () => { + it('should make single batched request with fetchMultipleScopeNodes', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']); + + // Should make exactly 1 API call + expect(mockBackendSrv.get).toHaveBeenCalledTimes(1); + }); + + it('should make N sequential requests with fetchScopeNode (old pattern)', async () => { + mockBackendSrv.get.mockResolvedValue({ + metadata: { name: 'test' }, + spec: { nodeType: 'leaf', title: 'Test', parentName: '' }, + }); + + // Simulate old pattern of fetching nodes one by one + await Promise.all([ + apiClient.fetchScopeNode('node-1'), + apiClient.fetchScopeNode('node-2'), + apiClient.fetchScopeNode('node-3'), + apiClient.fetchScopeNode('node-4'), + apiClient.fetchScopeNode('node-5'), + ]); + + // Should make 5 separate API calls + expect(mockBackendSrv.get).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/public/app/features/scopes/ScopesService.test.ts b/public/app/features/scopes/ScopesService.test.ts index fdc0c8bd598..a90de0ef95b 100644 --- a/public/app/features/scopes/ScopesService.test.ts +++ b/public/app/features/scopes/ScopesService.test.ts @@ -1,5 +1,6 @@ import { BehaviorSubject } from 'rxjs'; +import { ScopeSpecFilter } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import { ScopesService } from './ScopesService'; @@ -16,8 +17,20 @@ describe('ScopesService', () => { let locationService: jest.Mocked; let selectorStateSubscription: | (( - state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }, - prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> } + state: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + }, + prevState: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + } ) => void) | undefined; let dashboardsStateSubscription: @@ -274,9 +287,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -298,9 +313,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -320,9 +337,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); @@ -344,9 +363,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -370,15 +391,171 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); expect(locationService.partial).not.toHaveBeenCalled(); }); + describe('defaultPath support', () => { + it('should extract scope_node from defaultPath when available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent-node', 'correct-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'correct-node', + scope_parent: null, + }, + true + ); + }); + + it('should fallback to scopeNodeId when defaultPath is not available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should handle empty defaultPath gracefully', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: [], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId when defaultPath is empty + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should detect changes in defaultPath-derived scopeNodeId', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'new-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'old-node'], + filters: [], + }, + }, + }, + } + ); + + // Should detect the change in defaultPath-derived scopeNodeId + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'new-node', + scope_parent: null, + }, + true + ); + }); + }); + it('should write navigation_scope to URL when navigationScope changes', () => { if (!dashboardsStateSubscription) { throw new Error('dashboardsStateSubscription not set'); @@ -622,6 +799,30 @@ describe('ScopesService', () => { true ); }); + + it('should use defaultPath for scope_node when enabling scopes', () => { + selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }]; + selectorService.state.scopes = { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'correct-node-from-defaultPath'], + filters: [], + }, + }, + }; + + service.setEnabled(true); + + // Should use defaultPath instead of scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + expect.objectContaining({ + scope_node: 'correct-node-from-defaultPath', + }), + true + ); + }); }); describe('back/forward navigation handling', () => { diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index c92d6328749..8dd40fd953f 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -151,12 +151,26 @@ export class ScopesService implements ScopesContextValue { // Update the URL based on change in the scopes state this.subscriptions.push( selectorService.subscribeToState((state, prevState) => { - const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId; - const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId; - const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId); const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId); + // Extract scopeNodeId from defaultPath when available + const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => { + const firstScope = appliedScopes[0]; + if (!firstScope) { + return undefined; + } + const scope = scopes[firstScope.scopeId]; + // Prefer defaultPath when available + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + return firstScope.scopeNodeId; + }; + + const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes); + const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes); + const scopesChanged = !isEqual(oldScopeNames, newScopeNames); const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId; @@ -230,7 +244,7 @@ export class ScopesService implements ScopesContextValue { if (this.state.enabled !== enabled) { this.updateState({ enabled }); if (enabled) { - const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId; + const scopeNodeId = this.getScopeNodeIdForUrl(); this.locationService.partial( { scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId), @@ -243,6 +257,29 @@ export class ScopesService implements ScopesContextValue { } }; + /** + * Extracts the scopeNodeId for URL syncing, preferring defaultPath when available. + * When a scope has defaultPath, that is the source of truth for the node ID. + * @private + */ + private getScopeNodeIdForUrl(): string | undefined { + const firstScope = this.selectorService.state.appliedScopes[0]; + if (!firstScope) { + return undefined; + } + + const scope = this.selectorService.state.scopes[firstScope.scopeId]; + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + + // Fallback to next in priority order: scopeNodeId from appliedScopes + return firstScope.scopeNodeId; + } + /** * Returns observable that emits when relevant parts of the selectorService state change. * @private diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index a180c523e6b..205c3b0356f 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -31,13 +31,33 @@ export function ScopesInput({ onInputClick, onRemoveAllClick, }: ScopesInputProps) { - const scopeNodeId = appliedScopes[0]?.scopeNodeId; + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; const styles = useStyles2(getStyles); - const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + let scopeNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } else { + // Fallback to next in priority order: scopeNodeId from appliedScopes + scopeNodeId = firstScope?.scopeNodeId; + } + const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId); - // Get parent from scope node if available, otherwise fallback to parent - const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + // Prefer parentNodeId from defaultPath if available + let parentNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) { + // Extract parentNodeId from the second-to-last element of defaultPath + parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2]; + } else { + // Fallback to parent from scope node or recent scopes + const parentNodeIdFromRecentScopes = firstScope?.parentNodeId; + parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + } + const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title @@ -99,16 +119,31 @@ export function ScopesInput({ ); } -const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => { +const getScopesPath = ( + appliedScopes: SelectedScope[], + nodes: NodesMap, + defaultPath?: string[] +): string[] | undefined => { let nicePath: string[] | undefined; - if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) { - let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes); - // Get reed of empty root section and the actual scope node - path = path.slice(1, -1); + if (appliedScopes.length > 0) { + const firstScope = appliedScopes[0]; - // We may not have all the nodes in path loaded - nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + // Prefer defaultPath from scope metadata + if (defaultPath && defaultPath.length > 1) { + // Get all nodes except the last one (which is the scope itself) + const pathNodeIds = defaultPath.slice(0, -1); + nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title); + } + // Fallback to walking the node tree + else if (firstScope.scopeNodeId) { + let path = getPathOfNode(firstScope.scopeNodeId, nodes); + // Get rid of empty root section and the actual scope node + path = path.slice(1, -1); + + // We may not have all the nodes in path loaded + nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + } } return nicePath; @@ -127,7 +162,9 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable return t('scopes.selector.input.tooltip', 'Select scope'); } - const nicePath = getScopesPath(appliedScopes, nodes); + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; + const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath); const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId; diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index 58ddb31a92f..e2102db5db7 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -77,7 +77,14 @@ describe('ScopesSelectorService', () => { }), fetchDashboards: jest.fn().mockResolvedValue([]), fetchScopeNavigations: jest.fn().mockResolvedValue([]), - fetchScopeNode: jest.fn().mockResolvedValue(mockNode), + fetchScopeNode: jest.fn().mockImplementation((id: string) => { + // Return undefined for empty string (root node) + if (id === '') { + return Promise.resolve(undefined); + } + return Promise.resolve(mockNode); + }), + fetchMultipleScopeNodes: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; dashboardsService = { @@ -435,7 +442,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(service.state.appliedScopes).toEqual([]); }); @@ -443,7 +450,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, undefined); }); }); @@ -1236,4 +1243,1076 @@ describe('ScopesSelectorService', () => { expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1'); }); }); + + // Mock data for defaultPath and path helper tests + const regionNode: ScopeNode = { + metadata: { name: 'region-us-west' }, + spec: { + nodeType: 'container', + title: 'US West', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const countryNode: ScopeNode = { + metadata: { name: 'country-usa' }, + spec: { + nodeType: 'container', + title: 'USA', + parentName: 'region-us-west', + linkType: undefined, + linkId: undefined, + }, + }; + + const cityNode: ScopeNode = { + metadata: { name: 'city-seattle' }, + spec: { + nodeType: 'container', + title: 'Seattle', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const datacenterNode: ScopeNode = { + metadata: { name: 'datacenter-sea-1' }, + spec: { + nodeType: 'leaf', + title: 'SEA-1', + parentName: 'city-seattle', + linkType: 'scope', + linkId: 'scope-sea-1', + }, + }; + + const scopeWithDefaultPath: Scope = { + metadata: { name: 'scope-sea-1' }, + spec: { + title: 'Seattle Datacenter 1', + defaultPath: ['region-us-west', 'country-usa', 'city-seattle', 'datacenter-sea-1'], + filters: [], + }, + }; + + const scopeWithoutDefaultPath: Scope = { + metadata: { name: 'scope-no-path' }, + spec: { + title: 'No Path Scope', + filters: [], + }, + }; + + const parentNode: ScopeNode = { + metadata: { name: 'parent' }, + spec: { + nodeType: 'container', + title: 'Parent', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const childNode: ScopeNode = { + metadata: { name: 'child' }, + spec: { + nodeType: 'leaf', + title: 'Child', + parentName: 'parent', + linkType: 'scope', + linkId: 'test-scope', + }, + }; + + const grandchildNode: ScopeNode = { + metadata: { name: 'grandchild' }, + spec: { + nodeType: 'leaf', + title: 'Grandchild', + parentName: 'child', + linkType: 'scope', + linkId: 'test-scope-2', + }, + }; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + // Tests for defaultPath functionality + // Note: Tests access protected updateState method via (service as any) casting to set up test state + describe('getScopeNodes', () => { + it('should return cached nodes when available', async () => { + // Pre-populate cache + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + 'country-usa': countryNode, + }, + }); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(result).toEqual([regionNode, countryNode]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should fetch only non-cached nodes', async () => { + // Pre-populate cache with one node + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([countryNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['country-usa']); + expect(result).toEqual([regionNode, countryNode]); + }); + + it('should maintain order of requested nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([cityNode, countryNode, regionNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa', 'city-seattle']); + + expect(result).toEqual([regionNode, countryNode, cityNode]); + }); + + it('should update state with fetched nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(service.state.nodes).toEqual({ + 'region-us-west': regionNode, + 'country-usa': countryNode, + }); + }); + + it('should handle empty array input', async () => { + const result = await service.getScopeNodes([]); + + expect(result).toEqual([]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should filter out undefined nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + const result = await service.getScopeNodes(['non-existent-node']); + + expect(result).toEqual([]); + }); + }); + + describe('resolvePathToRoot with defaultPath', () => { + beforeEach(() => { + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should use defaultPath when scope has it defined', async () => { + // Pre-populate scope cache + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should fetch all nodes in defaultPath at once + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back to recursive path walking when no scopeId provided', async () => { + // Setup nodes in cache for recursive walking + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back when scope has no defaultPath', async () => { + (service as any).updateState({ + scopes: { + 'scope-no-path': scopeWithoutDefaultPath, + }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-no-path'); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should insert path nodes into tree', async () => { + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + expect(result.tree.children?.['region-us-west']).toBeDefined(); + expect(result.tree.children?.['region-us-west']?.children?.['country-usa']).toBeDefined(); + expect( + result.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle'] + ).toBeDefined(); + }); + }); + + describe('applyScopes with defaultPath pre-fetching', () => { + it('should pre-fetch all nodes from defaultPath when applying scopes', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + await service.changeScopes(['scope-sea-1']); + + // Should batch fetch all nodes in defaultPath + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + + // All nodes should be in cache + expect(service.state.nodes['region-us-west']).toEqual(regionNode); + expect(service.state.nodes['country-usa']).toEqual(countryNode); + expect(service.state.nodes['city-seattle']).toEqual(cityNode); + expect(service.state.nodes['datacenter-sea-1']).toEqual(datacenterNode); + }); + + it("should only pre-fetch the first scope's defaultPath", async () => { + const scope2: Scope = { + metadata: { name: 'scope-2' }, + spec: { + title: 'Scope 2', + defaultPath: ['region-us-west', 'country-usa', 'city-portland', 'datacenter-pdx-1'], + filters: [], + }, + }; + + const portlandNode: ScopeNode = { + metadata: { name: 'city-portland' }, + spec: { + nodeType: 'container', + title: 'Portland', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const pdxDatacenterNode: ScopeNode = { + metadata: { name: 'datacenter-pdx-1' }, + spec: { + nodeType: 'leaf', + title: 'PDX-1', + parentName: 'city-portland', + linkType: 'scope', + linkId: 'scope-2', + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath, scope2]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode, portlandNode, pdxDatacenterNode]); + + await service.changeScopes(['scope-sea-1', 'scope-2']); + + // Should only fetch the first scope's defaultPath (not the second scope's) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + }); + + it('should not fetch when scopes have no defaultPath', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithoutDefaultPath]); + + await service.changeScopes(['scope-no-path']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should handle empty defaultPath array', async () => { + const scopeWithEmptyPath: Scope = { + metadata: { name: 'scope-empty' }, + spec: { + title: 'Scope Empty', + defaultPath: [], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithEmptyPath]); + + await service.changeScopes(['scope-empty']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('open selector with defaultPath expansion', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([regionNode]); + } else if (options.parent === 'region-us-west') { + return Promise.resolve([countryNode]); + } else if (options.parent === 'country-usa') { + return Promise.resolve([cityNode]); + } else if (options.parent === 'city-seattle') { + return Promise.resolve([datacenterNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should expand to defaultPath when opening selector with applied scope', async () => { + // Apply a scope with defaultPath + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + appliedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + }); + + await service.open(); + + // Should fetch all nodes in the path + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalled(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['region-us-west']?.expanded).toBe(true); + expect(service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.expanded).toBe(true); + expect( + service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle']?.expanded + ).toBe(true); + }); + + it('should fall back to parentNodeId when scope has no defaultPath', async () => { + // Pre-populate nodes for fallback behavior + (service as any).updateState({ + scopes: { 'scope-no-path': scopeWithoutDefaultPath }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + appliedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + selectedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + }); + + await service.open(); + + // Should still expand, but using parentNodeId logic + expect(service.state.opened).toBe(true); + }); + + it('should handle opening selector when scope is not yet loaded', async () => { + (service as any).updateState({ + appliedScopes: [{ scopeId: 'scope-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1' }], + }); + + await service.open(); + + // Should not crash, just open with root nodes + expect(service.state.opened).toBe(true); + }); + }); + + describe('performance improvements', () => { + it('should make only 1 API call for deep hierarchy with defaultPath', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should make exactly 1 API call + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledTimes(1); + }); + + it('should make N API calls for deep hierarchy without defaultPath (old behavior)', async () => { + // This test documents the old recursive behavior for comparison + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + const nodeMap: Record = { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }; + return Promise.resolve(nodeMap[id]); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree); + + // Would make 4 sequential calls in the old implementation + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('datacenter-sea-1'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('city-seattle'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('country-usa'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('region-us-west'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledTimes(4); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle defaultPath with missing nodes gracefully', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + // API returns fewer nodes than requested + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should handle partial path gracefully + expect(result.path).toEqual([regionNode, countryNode]); + }); + + it('should handle API errors during batch fetch', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.changeScopes(['scope-sea-1']); + + // Should not crash, state should be consistent + expect(service.state.appliedScopes).toEqual([{ scopeId: 'scope-sea-1' }]); + }); + + it('should deduplicate node IDs in defaultPath', async () => { + const scopeWithDuplicates: Scope = { + metadata: { name: 'scope-dupe' }, + spec: { + title: 'Scope with Duplicates', + defaultPath: ['region-us-west', 'country-usa', 'region-us-west', 'country-usa'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDuplicates]); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.changeScopes(['scope-dupe']); + + // Should only fetch unique nodes + const calledWith = apiClient.fetchMultipleScopeNodes.mock.calls[0][0]; + const uniqueNodes = [...new Set(calledWith)]; + expect(calledWith.length).toBe(uniqueNodes.length); + }); + + it('should handle defaultPath with only root node', async () => { + const scopeWithRootOnly: Scope = { + metadata: { name: 'scope-root' }, + spec: { + title: 'Scope Root Only', + defaultPath: ['region-us-west'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'scope-root': scopeWithRootOnly }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('region-us-west', tree, 'scope-root'); + + expect(result.path).toEqual([regionNode]); + }); + }); + + describe('backwards compatibility', () => { + it('should work with existing code that does not provide scopeId to resolvePathToRoot', async () => { + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should not break when scope metadata is loaded after applying', async () => { + // This simulates the async nature of scope loading + apiClient.fetchMultipleScopes = jest.fn().mockImplementation(async () => { + // Simulate delay + await new Promise((resolve) => setTimeout(resolve, 10)); + return [scopeWithDefaultPath]; + }); + + await service.changeScopes(['scope-sea-1']); + + // Scope should eventually be in state + expect(service.state.scopes['scope-sea-1']).toEqual(scopeWithDefaultPath); + }); + }); + + // Tests for path helper methods + describe('getPathForScope (new helper method)', () => { + it('should prefer defaultPath from scope metadata', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // This tests the new getPathForScope method that should be created + // For now, this is testing the expected behavior through resolvePathToRoot + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['parent', 'child']); + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should fall back to scopeNodeId when no defaultPath', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should return empty array when both scopeId and scopeNodeId are undefined', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('', tree); + + expect(result.path).toEqual([]); + }); + + it('should handle scope not being in cache yet', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + // Scope not in cache, but scopeNodeId is provided + const result = await service.resolvePathToRoot('child', tree, 'unknown-scope'); + + // Should fall back to node-based path + expect(result.path).toEqual([parentNode, childNode]); + }); + }); + + describe('getNodePath - optimized implementation', () => { + it('should build path from cached nodes without API calls', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + grandchild: grandchildNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('grandchild', tree); + + // Should not make any API calls since all nodes are cached + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + expect(result.path).toEqual([parentNode, childNode, grandchildNode]); + }); + + it('should fetch missing nodes in the path', async () => { + // Only grandchild is cached + (service as any).updateState({ + nodes: { + grandchild: grandchildNode, + }, + }); + + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + if (id === 'child') { + return Promise.resolve(childNode); + } + if (id === 'parent') { + return Promise.resolve(parentNode); + } + return Promise.resolve(undefined); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('grandchild', tree); + + // Should fetch missing parent nodes + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('child'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('parent'); + }); + + it('should handle circular references gracefully', async () => { + const circularNode1: ScopeNode = { + metadata: { name: 'node1' }, + spec: { + nodeType: 'container', + title: 'Node 1', + parentName: 'node2', + }, + }; + + const circularNode2: ScopeNode = { + metadata: { name: 'node2' }, + spec: { + nodeType: 'container', + title: 'Node 2', + parentName: 'node1', + }, + }; + + (service as any).updateState({ + nodes: { + node1: circularNode1, + node2: circularNode2, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + // This should not hang or crash + // Implementation should detect circular references and stop recursion + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await service.resolvePathToRoot('node1', tree); + + expect(result).toBeDefined(); + // When circular reference is detected, it returns partial path (up to the circular point) + expect(result.path.length).toBeGreaterThan(0); + expect(consoleErrorSpy).toHaveBeenCalledWith('Circular reference detected in node path', expect.any(String)); + consoleErrorSpy.mockRestore(); + }); + + it('should stop at root node (empty parentName)', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree); + + expect(result.path).toEqual([parentNode, childNode]); + expect(result.path[0].spec.parentName).toBe(''); + }); + }); + + describe('expandToSelectedScope (new helper method)', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + }); + + it('should expand tree to show selected scope path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + await service.open(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']).toBeDefined(); + }); + + it('should not expand when no scopes are selected', async () => { + (service as any).updateState({ + selectedScopes: [], + appliedScopes: [], + }); + + await service.open(); + + // Root should have children loaded but not expanded beyond that + expect(service.state.tree.children).toBeDefined(); + }); + + it('should load children of the last node in the path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + }); + + // Mock API to return path nodes + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // Mock fetchNodes to return children of the last node + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + + await service.open(); + + // Should have loaded root children (called once during tree initialization) + expect(apiClient.fetchNodes).toHaveBeenCalled(); + // Verify the path nodes were fetched (parent already in cache from fetchNodes, so only child is fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child']); + }); + + it('should handle errors gracefully when expanding', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + // Mock API to fail + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + apiClient.fetchMultipleScopeNodes = jest.fn().mockRejectedValue(new Error('API Error')); + + // Should not crash + await expect(service.open()).resolves.not.toThrow(); + expect(service.state.opened).toBe(true); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('integration - full path resolution flow', () => { + it('should resolve path from defaultPath, insert into tree, and expand', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child', 'grandchild'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode, grandchildNode]); + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } else if (options.parent === 'child') { + return Promise.resolve([grandchildNode]); + } + return Promise.resolve([]); + }); + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + }); + + await service.open(); + + // Path should be resolved (parent already in cache from fetchNodes, so only child and grandchild are fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child', 'grandchild']); + + // Nodes should be in cache + expect(service.state.nodes['parent']).toEqual(parentNode); + expect(service.state.nodes['child']).toEqual(childNode); + expect(service.state.nodes['grandchild']).toEqual(grandchildNode); + + // Tree should be expanded + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']?.expanded).toBe(true); + }); + + it('should use cached nodes and avoid unnecessary API calls', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + // Pre-populate cache + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.open(); + + // Should not fetch nodes that are already cached + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('getScopeNode - caching behavior', () => { + it('should return cached node without API call', async () => { + (service as any).updateState({ + nodes: { + 'test-node': childNode, + }, + }); + + const result = await service.getScopeNode('test-node'); + + expect(result).toEqual(childNode); + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + }); + + it('should fetch and cache node when not in cache', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(childNode); + + const result = await service.getScopeNode('test-node'); + + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('test-node'); + expect(result).toEqual(childNode); + // Node is cached using its metadata.name, not the requested ID + expect(service.state.nodes['child']).toEqual(childNode); + }); + + it('should handle API errors gracefully', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(undefined); + + const result = await service.getScopeNode('non-existent'); + + expect(result).toBeUndefined(); + expect(service.state.nodes['non-existent']).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index abd837c7fb7..b7abee2a2cc 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -19,6 +19,7 @@ import { treeNodeAtPath, } from './scopesTreeUtils'; import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types'; + export const RECENT_SCOPES_KEY = 'grafana.scopes.recent'; export interface ScopesSelectorServiceState { @@ -101,22 +102,74 @@ export class ScopesSelectorService extends ScopesServiceBase => { + private getNodePath = async (scopeNodeId: string, visited: Set = new Set()): Promise => { + // Protect against circular references + if (visited.has(scopeNodeId)) { + console.error('Circular reference detected in node path', scopeNodeId); + return []; + } + const node = await this.getScopeNode(scopeNodeId); if (!node) { return []; } + + // Add current node to visited set + const newVisited = new Set(visited); + newVisited.add(scopeNodeId); + const parentPath = - node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : []; + node.spec.parentName && node.spec.parentName !== '' + ? await this.getNodePath(node.spec.parentName, newVisited) + : []; return [...parentPath, node]; }; + /** + * Determines the path to a scope node, preferring defaultPath from scope metadata. + * This is the single source of truth for path resolution. + * + * TODO: Consider making this public and exposing via a hook to avoid duplication + * with getScopesPath in ScopesInput.tsx + * + * @param scopeId - The scope ID to get the path for + * @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath + * @returns Promise resolving to array of ScopeNode objects representing the path + */ + private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise { + // 1. Check if scope has defaultPath (preferred method) + const scope = this.state.scopes[scopeId]; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Batch fetch all nodes in defaultPath + return await this.getScopeNodes(scope.spec.defaultPath); + } + + // 2. Fall back to calculating path from scopeNodeId + if (scopeNodeId) { + return await this.getNodePath(scopeNodeId); + } + + return []; + } + public resolvePathToRoot = async ( scopeNodeId: string, - tree: TreeNode + tree: TreeNode, + scopeId?: string ): Promise<{ path: ScopeNode[]; tree: TreeNode }> => { - const nodePath = await this.getNodePath(scopeNodeId); + let nodePath: ScopeNode[]; + + // Check if scope has defaultPath for optimized resolution + const scope = scopeId ? this.state.scopes[scopeId] : undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Use batch-fetched defaultPath (most efficient) + nodePath = await this.getPathForScope(scopeId!, scopeNodeId); + } else { + // Fall back to node-based path resolution + nodePath = await this.getNodePath(scopeNodeId); + } + const newTree = insertPathNodesIntoTree(tree, nodePath); this.updateState({ tree: newTree }); @@ -207,16 +260,39 @@ export class ScopesSelectorService extends ScopesServiceBase { - // Set parent query only when filtering within existing children - treeNode.children = {}; + // Preserve existing children that have nested structure (from insertPathNodesIntoTree) + const existingChildren = treeNode.children || {}; + const childrenToPreserve: Record = {}; + + // Keep children that have a children property (object, not undefined) + // This includes both empty objects {} (from path insertion) and populated ones + for (const [key, child] of Object.entries(existingChildren)) { + // Preserve if children is an object (not undefined) + if (child.children !== undefined && typeof child.children === 'object') { + childrenToPreserve[key] = child; + } + } + + // Start with preserved children, then add/update with fetched children + treeNode.children = { ...childrenToPreserve }; + for (const node of childNodes) { - treeNode.children[node.metadata.name] = { - expanded: false, - scopeNodeId: node.metadata.name, - // Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting. - query: query || '', - children: undefined, - }; + // If this child was preserved, merge with fetched data + if (childrenToPreserve[node.metadata.name]) { + treeNode.children[node.metadata.name] = { + ...childrenToPreserve[node.metadata.name], + // Update query but keep nested children + query: query || '', + }; + } else { + // New child from API + treeNode.children[node.metadata.name] = { + expanded: false, + scopeNodeId: node.metadata.name, + query: query || '', + children: undefined, + }; + } } // Set loaded to true if node is a container treeNode.childrenLoaded = true; @@ -356,16 +432,54 @@ export class ScopesSelectorService extends ScopesServiceBase 0) { + // Deduplicate and filter out already cached nodes + const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)]; + const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]); - this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId); + if (nodesToFetch.length > 0) { + await this.getScopeNodes(nodesToFetch); + } + } + + // Get scopeNode and parentNode, preferring defaultPath as the source of truth + let parentNode: ScopeNode | undefined; + let scopeNodeId: string | undefined; + + if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) { + // Extract from defaultPath (most reliable source) + // defaultPath format: ['', 'parent-id', 'scope-node-id', ...] + scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1]; + const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2]; + + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined; + } else { + // Fallback to next in priority order + scopeNodeId = scopes[0]?.scopeNodeId; + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + + const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName; + parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined; + } + + this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId); this.updateState({ scopes: newScopesState, loading: false }); } }; @@ -375,7 +489,7 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (!('url' in s.spec) || typeof s.spec.url !== 'string') { + if (!('url' in s.spec)) { return false; } return isCurrentPath(currentPath, s.spec.url); @@ -386,7 +500,6 @@ export class ScopesSelectorService extends ScopesServiceBase [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode]) .filter(([key, parentNode]) => parentNode !== undefined && key !== undefined) ); - - return parentNodes; }; /** @@ -499,40 +609,42 @@ export class ScopesSelectorService extends ScopesServiceBase n.metadata.name); - path.unshift(''); - nodeAtPath = treeNodeAtPath(newTree, path); - } catch (error) { - console.error('Failed to resolve path to root', error); - } - } - - // We have resolved to root, which means the parent node should be available - let parentPath = path.slice(0, -1); - let parentNodeAtPath = treeNodeAtPath(newTree, parentPath); - - if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { - // This will update the tree with the children - const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); - newTree = newTreeWithChildren; - } - - // Expand the nodes to the selected scope - must be done after loading children try { - newTree = expandNodes(newTree, parentPath); + // Get the path for the selected scope, preferring defaultPath from scope metadata + const pathNodes = await this.getPathForScope( + this.state.selectedScopes[0].scopeId, + this.state.selectedScopes[0].scopeNodeId + ); + + if (pathNodes.length > 0) { + // Convert to string path + const stringPath = pathNodes.map((n) => n.metadata.name); + stringPath.unshift(''); // Add root segment + + // Check if nodes are in tree + let nodeAtPath = treeNodeAtPath(newTree, stringPath); + + // If nodes aren't in tree yet, insert them + if (!nodeAtPath) { + newTree = insertPathNodesIntoTree(newTree, pathNodes); + // Update state so loadNodeChildren can see the inserted nodes + this.updateState({ tree: newTree }); + } + + // Load children of the parent node if needed to show all siblings + const parentPath = stringPath.slice(0, -1); + const parentNodeAtPath = treeNodeAtPath(newTree, parentPath); + + if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { + const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); + newTree = newTreeWithChildren; + } + + // Expand the nodes to show the selected scope + newTree = expandNodes(newTree, parentPath); + } } catch (error) { - console.error('Failed to expand nodes', error); + console.error('Failed to expand to selected scope', error); } } @@ -580,9 +692,14 @@ export class ScopesSelectorService extends ScopesServiceBase !nodesMap[name]); - const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); - for (const node of nodes) { - nodesMap[node.metadata.name] = node; + if (nodesToFetch.length > 0) { + const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); + // Handle case where API returns undefined or non-array + if (Array.isArray(nodes)) { + for (const node of nodes) { + nodesMap[node.metadata.name] = node; + } + } } const newNodes = { ...this.state.nodes, ...nodesMap }; diff --git a/public/app/features/scopes/selector/scopesTreeUtils.ts b/public/app/features/scopes/selector/scopesTreeUtils.ts index 8824742505d..d4001db68c1 100644 --- a/public/app/features/scopes/selector/scopesTreeUtils.ts +++ b/public/app/features/scopes/selector/scopesTreeUtils.ts @@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => { if (!childNodeName) { console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]); treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; + return; + } + // Create node if it doesn't exist + if (!treeNode.children[childNodeName]) { + treeNode.children[childNodeName] = { + expanded: false, + scopeNodeId: childNodeName, + query: '', + children: {}, + childrenLoaded: false, + }; + } else { + // Node exists, ensure it has children object for nested insertion + if (treeNode.children[childNodeName].children === undefined) { + treeNode.children[childNodeName] = { + ...treeNode.children[childNodeName], + children: {}, + }; + } } - treeNode.children[childNodeName] = { - expanded: false, - scopeNodeId: childNodeName, - query: '', - children: undefined, - childrenLoaded: false, - }; treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; }); } return newTree; diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index f12f848657a..56921ac6f7d 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -105,7 +105,6 @@ describe('Selector', () => { // Lowercase because we don't have any backend that returns the correct case, then it falls back to the value in the URL expectScopesSelectorValue('grafana'); await openSelector(); - //screen.debug(undefined, 100000); expectResultApplicationsGrafanaSelected(); jest.spyOn(locationService, 'getLocation').mockRestore(); @@ -133,11 +132,12 @@ describe('Selector', () => { expectRecentScope('Grafana Applications'); expectRecentScope('Grafana, Mimir Applications'); await selectRecentScope('Grafana Applications'); + await jest.runOnlyPendingTimersAsync(); expectScopesSelectorValue('Grafana'); await openSelector(); - // Close to root node so we can see the recent scopes + // Collapse tree to root level to see recent scopes section await expandResultApplications(); await expandRecentScopes(); @@ -156,8 +156,8 @@ describe('Selector', () => { await applyScopes(); await openSelector(); - // Close to root node so we can try to see the recent scopes - await expandResultApplications(); + // Tree expands to show selected scope, so recent scopes are not visible + // (recent scopes only show at root level with tree collapsed) expectRecentScopeNotPresentInDocument(); }); @@ -175,6 +175,7 @@ describe('Selector', () => { await applyScopes(); // Deselect all scopes + await hoverSelector(); await clearSelector(); // Recent scopes should still be available @@ -197,6 +198,7 @@ describe('Selector', () => { await selectResultApplicationsMimir(); await applyScopes(); + await hoverSelector(); await clearSelector(); // Check recent scopes are updated diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index fc5fde93193..e49acb8670a 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -47,7 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => { export const updateScopes = async (service: ScopesService, scopes: string[]) => act(async () => service.changeScopes(scopes)); export const openSelector = async () => click(getSelectorInput); -export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput()); +export const hoverSelector = async () => userEvent.hover(getSelectorInput()); export const clearSelector = async () => click(getSelectorClear); export const applyScopes = async () => { await click(getSelectorApply); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx index 779ffcbb497..212c55bbe5e 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx @@ -33,6 +33,7 @@ describe('QueryEditor', () => { refId: 'A', maxNodes: 1000, groupBy: [], + includeExemplars: false, }, }, }); @@ -125,6 +126,7 @@ function setup(options: { props: Partial } = { props: {} }) { maxNodes: 1000, groupBy: [], limit: 42, + includeExemplars: false, }} datasource={setupDs()} onChange={onChange} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx index 1ae0dca0241..d3221a4bb25 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import * as React from 'react'; import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { useStyles2, RadioButtonGroup, MultiSelect, Input, InlineSwitch } from '@grafana/ui'; import { Query } from '../types'; @@ -56,6 +57,9 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) { if (query.maxNodes) { collapsedInfo.push(`Max nodes: ${query.maxNodes}`); } + if (query.includeExemplars) { + collapsedInfo.push(`With exemplars`); + } return ( @@ -142,6 +146,16 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) { }} /> + {config.featureToggles.profilesExemplars && ( + Include profile exemplars in the time series.}> + ) => { + onQueryChange({ ...query, includeExemplars: event.currentTarget.checked }); + }} + /> + + )} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue index 8e277aa6265..d2b279fc5ce 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue @@ -42,8 +42,10 @@ composableKinds: DataQuery: { // Sets the maximum number of nodes in the flamegraph. maxNodes?: int64 #PyroscopeQueryType: "metrics" | "profile" | *"both" @cuetsy(kind="type") - // If set to true, the response will contain annotations - annotations?: bool + // If set to true, the response will contain annotations + annotations?: bool + // If set to true, exemplars will be requested + includeExemplars: bool | *false } }] lenses: [] diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts index 8513d2f7d8e..5975ca95ecd 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts @@ -23,6 +23,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { * Allows to group the results. */ groupBy: Array; + /** + * If set to true, exemplars will be requested + */ + includeExemplars: boolean; /** * Specifies the query label selectors. */ @@ -47,6 +51,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { export const defaultGrafanaPyroscopeDataQuery: Partial = { groupBy: [], + includeExemplars: false, labelSelector: '{}', spanSelector: [], }; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index 08c768d8c57..34ab7b4e521 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -43,6 +43,7 @@ describe('Pyroscope data source', () => { queryType: 'both', profileTypeId: '', groupBy: [''], + includeExemplars: false, }, ]); expect(queries).toMatchObject([ @@ -118,6 +119,7 @@ describe('normalizeQuery', () => { queryType: 'metrics', profileTypeId: 'cpu', refId: '', + includeExemplars: false, }); expect(normalized).toMatchObject({ labelSelector: '{app="myapp"}', @@ -145,6 +147,7 @@ const defaultQuery = (query: Partial): Query => { labelSelector: '', profileTypeId: '', queryType: defaultPyroscopeQueryType, + includeExemplars: false, ...query, }; }; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts index eed703d2b03..312ca537cd9 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts @@ -129,6 +129,7 @@ export class PyroscopeDataSource extends DataSourceWithBackend { ]); }); + it('should escape span with multi line content correctly', () => { + const spanContent = [ + ` + SELECT * from "my_table" + WHERE "data_enabled" = 1 + ORDER BY "name" ASC`, + ]; + let escaped = getEscapedRegexValues(getEscapedValues(spanContent)); + expect(escaped).toEqual([ + '\\n SELECT \\\\* from \\"my_table\\"\\n WHERE \\"data_enabled\\" = 1\\n ORDER BY \\"name\\" ASC', + ]); + }); + it('should get field config correctly', () => { let datasourceUid = 's4Jvz8Qnk'; let tempoDatasourceUid = 'EbPO1fYnz'; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 67b7e708a19..4c926300cb3 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -1168,7 +1168,7 @@ export function getEscapedRegexValues(values: string[]) { } export function getEscapedValues(values: string[]) { - return values.map((value: string) => value.replace(/["\\]/g, '\\$&')); + return values.map((value: string) => value.replace(/["\\]/g, '\\$&').replace(/[\n]/g, '\\n')); } export function getFieldConfig( diff --git a/public/app/plugins/panel/piechart/suggestions.ts b/public/app/plugins/panel/piechart/suggestions.ts index c991c628334..559992a25da 100644 --- a/public/app/plugins/panel/piechart/suggestions.ts +++ b/public/app/plugins/panel/piechart/suggestions.ts @@ -7,8 +7,7 @@ import { VisualizationSuggestionsSupplier, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { LegendDisplayMode } from '@grafana/schema'; -import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; +import { defaultNumericVizOptions, SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { PieChartLabels, Options, PieChartType } from './panelcfg.gen'; @@ -16,12 +15,13 @@ const withDefaults = (suggestion: VisualizationSuggestion): Visualizati defaultsDeep(suggestion, { options: { displayLabels: [PieChartLabels.Percent], - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - values: [], - showLegend: false, + }, + cardOptions: { + previewModifier: (s) => { + s.options!.legend = { + ...SUGGESTIONS_LEGEND_OPTIONS, + values: [], + }; }, }, } satisfies VisualizationSuggestion); diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index b3f6abd74ba..265ed619714 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -10,15 +10,9 @@ import { VisualizationSuggestionsSupplier, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { - GraphDrawStyle, - GraphFieldConfig, - GraphGradientMode, - LegendDisplayMode, - LineInterpolation, - StackingMode, -} from '@grafana/schema'; +import { GraphDrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, StackingMode } from '@grafana/schema'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { Options } from './panelcfg.gen'; @@ -29,14 +23,6 @@ const withDefaults = ( suggestion: VisualizationSuggestion ): VisualizationSuggestion => defaultsDeep(suggestion, { - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, fieldConfig: { defaults: { custom: {}, @@ -46,6 +32,7 @@ const withDefaults = ( cardOptions: { previewModifier: (s) => { s.options!.disableKeyboardEvents = true; + s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } diff --git a/public/app/plugins/panel/trend/module.tsx b/public/app/plugins/panel/trend/module.tsx index 67434a8c542..023c1ea8a0d 100644 --- a/public/app/plugins/panel/trend/module.tsx +++ b/public/app/plugins/panel/trend/module.tsx @@ -1,8 +1,9 @@ import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data'; import { t } from '@grafana/i18n'; import { GraphDrawStyle } from '@grafana/schema'; -import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui'; +import { commonOptionsBuilder } from '@grafana/ui'; import { optsWithHideZeros } from '@grafana/ui/internal'; +import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; @@ -49,14 +50,6 @@ export const plugin = new PanelPlugin(TrendPanel) return [ { score: VisualizationSuggestionScore.Good, - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, fieldConfig: { defaults: { custom: {}, @@ -65,6 +58,7 @@ export const plugin = new PanelPlugin(TrendPanel) }, cardOptions: { previewModifier: (s) => { + s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 5c7e4ba151b..6fb728bb94c 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -1,6 +1,5 @@ import { AnnotationQuery, BusEventBase, BusEventWithPayload, eventFactory } from '@grafana/data'; import { IconName, ButtonVariant } from '@grafana/ui'; -import { HistoryEntryView } from 'app/core/components/AppChrome/types'; /** * Event Payloads @@ -217,7 +216,3 @@ export class PanelEditEnteredEvent extends BusEventWithPayload { export class PanelEditExitedEvent extends BusEventWithPayload { static type = 'panel-edit-finished'; } - -export class RecordHistoryEntryEvent extends BusEventWithPayload { - static type = 'record-history-entry'; -} diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index c6440d0bac1..8bc02c3e7bd 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -293,6 +293,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition { updated?: string; updated_by?: UpdatedBy | null; version?: number; + message?: string; } // types for Grafana-managed recording and alerting rules diff --git a/public/app/types/user.ts b/public/app/types/user.ts index e5cd1805e7f..9e575e2ad93 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -5,6 +5,7 @@ import { Role } from './accessControl'; export interface OrgUser extends WithAccessControlMetadata { avatarUrl: string; email: string; + created?: string; lastSeenAt: string; lastSeenAtAge: string; login: string; @@ -49,6 +50,7 @@ export interface UserDTO extends WithAccessControlMetadata { theme?: string; avatarUrl?: string; orgId?: number; + created?: string; lastSeenAt?: string; lastSeenAtAge?: string; licensedRole?: string; diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 91e89a580ff..d2ad07201c4 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3791,7 +3791,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4454,6 +4453,7 @@ }, "no-properties-changed": "Žádné relevantní vlastnosti se nezměnily", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Aktualizoval uživatel", "version": "Verze" @@ -4912,7 +4912,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Hodnoty oddělené čárkou" }, "datasource-options": { "name-filter": "Filtr názvu", @@ -6010,6 +6011,9 @@ }, "custom-variable-form": { "custom-options": "Vlastní možnosti", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Hodnoty oddělené čárkou", "selection-options": "Možnosti výběru" }, @@ -6601,6 +6605,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Nástěnka byla uložena" }, @@ -6624,6 +6633,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6683,8 +6693,11 @@ "tooltip-show-usages": "Zobrazit použití" }, "variable-values-preview": { - "preview-of-values": "Náhled hodnot", - "show-more": "Zobrazit více" + "show-more": "Zobrazit více", + "preview-of-values_one": "", + "preview-of-values_few": "", + "preview-of-values_many": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10795,18 +10808,6 @@ "help/documentation": "Dokumentace", "help/keyboard-shortcuts": "Klávesové zkratky", "help/support": "Podpora", - "history-container": { - "drawer-tittle": "Historie" - }, - "history-wrapper": { - "collapse": "Sbalit", - "expand": "Rozbalit", - "icon-selected": "Vybraný záznam", - "icon-unselected": "Normální záznam", - "show-more": "Zobrazit více", - "today": "Dnes", - "yesterday": "Včera" - }, "home": { "title": "Domů" }, diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e0de5a6bf30..1472f7a9d26 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Keine relevanten Eigenschaften geändert", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Aktualisiert von", "version": "Version" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Werte werden durch Komma getrennt" }, "datasource-options": { "name-filter": "Namensfilter", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Benutzerdefinierte Optionen", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Werte werden durch Komma getrennt", "selection-options": "Auswahloptionen" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard gespeichert" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Nutzungen anzeigen" }, "variable-values-preview": { - "preview-of-values": "Vorschau der Werte", - "show-more": "Mehr anzeigen" + "show-more": "Mehr anzeigen", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Dokumentation", "help/keyboard-shortcuts": "Tastaturbefehle", "help/support": "Support", - "history-container": { - "drawer-tittle": "Verlauf" - }, - "history-wrapper": { - "collapse": "Einklappen", - "expand": "Ausklappen", - "icon-selected": "Ausgewählter Eintrag", - "icon-unselected": "Normaler Eintrag", - "show-more": "Mehr anzeigen", - "today": "Heute", - "yesterday": "Gestern" - }, "home": { "title": "Home" }, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6aacf5f3939..9c8f04c6868 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "Clear history", - "empty": "Nothing viewed yet", "error": "Recently viewed dashboards couldn’t be loaded.", "retry": "Retry", "title": "Recently viewed" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "No relevant properties changed", "table": { + "notes": "Notes", "updated": "Date", "updatedBy": "Updated By", "version": "Version" @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Add panel", + "disabled-child-contains-tabs": "Cannot change to tabs because a row already contains tabs", "disabled-nested-grouping": "Grouping is limited to 2 levels", "disabled-nested-tabs": "Tabs cannot be nested inside other tabs", "group-into-row": "Group into row", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Could not find a valid Grafana.com ID", "invalid-json": "Not valid JSON", + "tag-too-long": "Dashboard tag too long, max 50 characters", "tags-expected-array": "tags expected array", "tags-expected-strings": "tags expected array of strings" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Add", "placeholder-new-tag": "New tag (enter key to add)", - "remove": "Remove tag: {{name}}" + "remove": "Remove tag: {{name}}", + "tag-too-long": "Tag too long, max 50 characters" }, "time-sync-button": { "aria-label-sync": "Sync times", @@ -10720,18 +10723,6 @@ "help/documentation": "Documentation", "help/keyboard-shortcuts": "Keyboard shortcuts", "help/support": "Support", - "history-container": { - "drawer-tittle": "History" - }, - "history-wrapper": { - "collapse": "Collapse", - "expand": "Expand", - "icon-selected": "Selected Entry", - "icon-unselected": "Normal Entry", - "show-more": "Show more", - "today": "Today", - "yesterday": "Yesterday" - }, "home": { "title": "Home" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 5530857665c..440085580a7 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "No se ha cambiado ninguna propiedad relevante", "table": { + "notes": "", "updated": "Fecha", "updatedBy": "Actualizada por", "version": "Versión" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valores separados por coma" }, "datasource-options": { "name-filter": "Nombrar filtro", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Opciones personalizadas", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valores separados por comas", "selection-options": "Opciones de selección" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard guardado" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Mostrar usos" }, "variable-values-preview": { - "preview-of-values": "Vista previa de los valores", - "show-more": "Mostrar más" + "show-more": "Mostrar más", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Documentación", "help/keyboard-shortcuts": "Atajos de teclado", "help/support": "Asistencia", - "history-container": { - "drawer-tittle": "Historial" - }, - "history-wrapper": { - "collapse": "Contraer", - "expand": "Expandir", - "icon-selected": "Entrada seleccionada", - "icon-unselected": "Entrada normal", - "show-more": "Mostrar más", - "today": "Hoy", - "yesterday": "Ayer" - }, "home": { "title": "Inicio" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 73d52b983df..b45e13289a0 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Aucune propriété pertinente n’a été modifiée", "table": { + "notes": "", "updated": "Date", "updatedBy": "Mis à jour par", "version": "Version" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valeurs séparées par une virgule" }, "datasource-options": { "name-filter": "Nom du filtre", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Personnaliser les options", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valeurs séparées par des virgules", "selection-options": "Options de sélection" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Tableau de bord enregistré" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Afficher les usages" }, "variable-values-preview": { - "preview-of-values": "Aperçu des valeurs", - "show-more": "Afficher plus" + "show-more": "Afficher plus", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Documentation", "help/keyboard-shortcuts": "Raccourcis clavier", "help/support": "Assistance", - "history-container": { - "drawer-tittle": "Historique" - }, - "history-wrapper": { - "collapse": "Réduire", - "expand": "Développer", - "icon-selected": "Entrée sélectionnée", - "icon-unselected": "Entrée normale", - "show-more": "Afficher plus", - "today": "Aujourd'hui", - "yesterday": "Hier" - }, "home": { "title": "Accueil" }, diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index bbf358886d4..3514fca802d 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nem változtak meg a releváns tulajdonságok", "table": { + "notes": "", "updated": "Dátum", "updatedBy": "Frissítette:", "version": "Verzió" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Értékek vesszővel elválasztva" }, "datasource-options": { "name-filter": "Névszűrő", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Egyéni opciók", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Értékek vesszővel elválasztva", "selection-options": "Kijelölés beállításai" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Irányítópult elmentve" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Használatok megjelenítése" }, "variable-values-preview": { - "preview-of-values": "Értékek előnézete", - "show-more": "Több megjelenítése" + "show-more": "Több megjelenítése", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Dokumentáció", "help/keyboard-shortcuts": "Gyorsbillentyűk", "help/support": "Ügyfélszolgálat", - "history-container": { - "drawer-tittle": "Előzmények" - }, - "history-wrapper": { - "collapse": "Összecsukás", - "expand": "Kibontás", - "icon-selected": "Kijelölt bejegyzés", - "icon-unselected": "Normál bejegyzés", - "show-more": "Több megjelenítése", - "today": "Ma", - "yesterday": "Tegnap" - }, "home": { "title": "Kezdőlap" }, diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index b94e5d8ffec..6e58a6f949b 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "Tidak ada properti yang relevan yang diubah", "table": { + "notes": "", "updated": "Tanggal", "updatedBy": "Diperbarui Oleh", "version": "Versi" @@ -4855,7 +4855,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Nilai dipisahkan dengan koma" }, "datasource-options": { "name-filter": "Filter nama", @@ -5947,6 +5948,9 @@ }, "custom-variable-form": { "custom-options": "Opsi kustom", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Nilai dipisahkan dengan koma", "selection-options": "Opsi pemilihan" }, @@ -6532,6 +6536,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dasbor disimpan" }, @@ -6555,6 +6564,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6624,8 @@ "tooltip-show-usages": "Tampilkan penggunaan" }, "variable-values-preview": { - "preview-of-values": "Pratinjau nilai", - "show-more": "Tampilkan lebih banyak" + "show-more": "Tampilkan lebih banyak", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10666,18 +10676,6 @@ "help/documentation": "Dokumentasi", "help/keyboard-shortcuts": "Pintasan keyboard", "help/support": "Dukungan", - "history-container": { - "drawer-tittle": "Sejarah" - }, - "history-wrapper": { - "collapse": "Ciutkan", - "expand": "Perluas", - "icon-selected": "Entri yang dipilih", - "icon-unselected": "Entri Normal", - "show-more": "Tampilkan lebih banyak", - "today": "Hari ini", - "yesterday": "Kemarin" - }, "home": { "title": "Beranda" }, diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index e8d62fdaa78..d64d1b54255 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nessuna proprietà rilevante modificata", "table": { + "notes": "", "updated": "Data", "updatedBy": "Aggiornato da", "version": "Versione" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valori separati da virgola" }, "datasource-options": { "name-filter": "Filtro nome", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Opzioni personalizzate", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valori separati da virgola", "selection-options": "Seleziona opzioni" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard salvata" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Mostra utilizzi" }, "variable-values-preview": { - "preview-of-values": "Anteprima dei valori", - "show-more": "Mostra di più" + "show-more": "Mostra di più", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Documentazione", "help/keyboard-shortcuts": "Scelte rapide da tastiera", "help/support": "Servizio Clienti", - "history-container": { - "drawer-tittle": "Cronologia" - }, - "history-wrapper": { - "collapse": "Riduci", - "expand": "Espandi", - "icon-selected": "Voce selezionata", - "icon-unselected": "Ingresso normale", - "show-more": "Mostra di più", - "today": "Oggi", - "yesterday": "Ieri" - }, "home": { "title": "Home" }, diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index fe83750305e..47ecf90b90a 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "関連するプロパティは変更されていません", "table": { + "notes": "", "updated": "日付", "updatedBy": "更新者", "version": "バージョン" @@ -4855,7 +4855,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "カンマで区切った値" }, "datasource-options": { "name-filter": "名前フィルター", @@ -5947,6 +5948,9 @@ }, "custom-variable-form": { "custom-options": "カスタムオプション", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "カンマ区切りの値", "selection-options": "選択オプション" }, @@ -6532,6 +6536,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "ダッシュボードが保存されました" }, @@ -6555,6 +6564,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6624,8 @@ "tooltip-show-usages": "使用状況を表示" }, "variable-values-preview": { - "preview-of-values": "値のプレビュー", - "show-more": "さらに表示" + "show-more": "さらに表示", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10666,18 +10676,6 @@ "help/documentation": "ドキュメント", "help/keyboard-shortcuts": "キーボードショートカット", "help/support": "サポート", - "history-container": { - "drawer-tittle": "履歴" - }, - "history-wrapper": { - "collapse": "折りたたみ表示", - "expand": "展開", - "icon-selected": "選択したエントリー", - "icon-unselected": "通常のエントリー", - "show-more": "さらに表示", - "today": "今日", - "yesterday": "昨日" - }, "home": { "title": "ホーム" }, diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 2638c2e40ce..ca5373ab022 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "변경된 관련 속성 없음", "table": { + "notes": "", "updated": "날짜", "updatedBy": "업데이트한 사용자", "version": "버전" @@ -4855,7 +4855,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "쉼표로 구분된 값" }, "datasource-options": { "name-filter": "이름 필터", @@ -5947,6 +5948,9 @@ }, "custom-variable-form": { "custom-options": "사용자 지정 옵션", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "쉼표로 구분된 값", "selection-options": "선택 옵션" }, @@ -6532,6 +6536,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "대시보드가 저장되었습니다" }, @@ -6555,6 +6564,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6624,8 @@ "tooltip-show-usages": "사용처 표시" }, "variable-values-preview": { - "preview-of-values": "값 미리 보기", - "show-more": "더 보기" + "show-more": "더 보기", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10666,18 +10676,6 @@ "help/documentation": "문서", "help/keyboard-shortcuts": "키보드 단축키", "help/support": "지원", - "history-container": { - "drawer-tittle": "이력" - }, - "history-wrapper": { - "collapse": "접기", - "expand": "펼치기", - "icon-selected": "선택된 항목", - "icon-unselected": "일반 항목", - "show-more": "더 보기", - "today": "오늘", - "yesterday": "어제" - }, "home": { "title": "홈" }, diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index e43d2ab91ec..8fe54d956c6 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Geen relevante eigenschappen gewijzigd", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Bijgewerkt door", "version": "Versie" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Waarden gescheiden door komma" }, "datasource-options": { "name-filter": "Filter een naam geven", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Aangepaste opties", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Waarden gescheiden door komma", "selection-options": "Selectiemogelijkheden" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Dashboard opgeslagen" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Gebruik weergeven" }, "variable-values-preview": { - "preview-of-values": "Voorbeeldweergave van waarden", - "show-more": "Meer weergeven" + "show-more": "Meer weergeven", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Documentatie", "help/keyboard-shortcuts": "Sneltoetsen", "help/support": "Ondersteuning", - "history-container": { - "drawer-tittle": "Geschiedenis" - }, - "history-wrapper": { - "collapse": "Samenvouwen", - "expand": "Uitvouwen", - "icon-selected": "Geselecteerde invoer", - "icon-unselected": "Gewone invoer", - "show-more": "Meer weergeven", - "today": "Vandaag", - "yesterday": "Gisteren" - }, "home": { "title": "Startpagina" }, diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 598411a8e47..6db3c217f3d 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3791,7 +3791,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4454,6 +4453,7 @@ }, "no-properties-changed": "Nie zmieniono istotnych właściwości", "table": { + "notes": "", "updated": "Data", "updatedBy": "Zaktualizowane przez", "version": "Wersja" @@ -4912,7 +4912,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Wartości rozdzielone przecinkami" }, "datasource-options": { "name-filter": "Filtr nazwy", @@ -6010,6 +6011,9 @@ }, "custom-variable-form": { "custom-options": "Opcje niestandardowe", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Wartości rozdzielone przecinkami", "selection-options": "Opcje wyboru" }, @@ -6601,6 +6605,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Pulpit został zapisany" }, @@ -6624,6 +6633,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6683,8 +6693,11 @@ "tooltip-show-usages": "Wyświetl użycie" }, "variable-values-preview": { - "preview-of-values": "Podgląd wartości", - "show-more": "Pokaż więcej" + "show-more": "Pokaż więcej", + "preview-of-values_one": "", + "preview-of-values_few": "", + "preview-of-values_many": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10795,18 +10808,6 @@ "help/documentation": "Dokumentacja", "help/keyboard-shortcuts": "Skróty klawiaturowe", "help/support": "Wsparcie", - "history-container": { - "drawer-tittle": "Historia" - }, - "history-wrapper": { - "collapse": "Zwiń", - "expand": "Rozwiń", - "icon-selected": "Zaznaczony wpis", - "icon-unselected": "Normalny wpis", - "show-more": "Pokaż więcej", - "today": "Dzisiaj", - "yesterday": "Wczoraj" - }, "home": { "title": "Strona główna" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 8542e2c4258..c8516e379e3 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nenhuma propriedade relevante alterada", "table": { + "notes": "", "updated": "Data", "updatedBy": "Atualizada por", "version": "Versão" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valores separados por vírgula" }, "datasource-options": { "name-filter": "Filtro de nome", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Opções personalizadas", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valores separados por vírgula", "selection-options": "Opções de seleção" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Painel de controle salvo" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Exibir usos" }, "variable-values-preview": { - "preview-of-values": "Pré-visualização de valores", - "show-more": "Exibir mais" + "show-more": "Exibir mais", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Documentação", "help/keyboard-shortcuts": "Atalhos do teclado", "help/support": "Suporte", - "history-container": { - "drawer-tittle": "Histórico" - }, - "history-wrapper": { - "collapse": "Recolher", - "expand": "Expandir", - "icon-selected": "Entrada selecionada", - "icon-unselected": "Entrada normal", - "show-more": "Exibir mais", - "today": "Hoje", - "yesterday": "Ontem" - }, "home": { "title": "Página inicial" }, diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 0374230582e..1a701840291 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Nenhuma propriedade relevante alterada", "table": { + "notes": "", "updated": "Data", "updatedBy": "Atualizado por", "version": "Versão" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Valores separados por vírgulas" }, "datasource-options": { "name-filter": "Filtro de nome", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Opções personalizadas", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Valores separados por vírgulas", "selection-options": "Opções de seleção" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Painel de controlo guardado" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Mostrar utilizações" }, "variable-values-preview": { - "preview-of-values": "Pré-visualização de valores", - "show-more": "Mostrar mais" + "show-more": "Mostrar mais", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Documentação", "help/keyboard-shortcuts": "Atalhos de teclado", "help/support": "Apoio", - "history-container": { - "drawer-tittle": "Histórico" - }, - "history-wrapper": { - "collapse": "Recolher", - "expand": "Expandir", - "icon-selected": "Entrada selecionada", - "icon-unselected": "Entrada normal", - "show-more": "Mostrar mais", - "today": "Hoje", - "yesterday": "Ontem" - }, "home": { "title": "Início" }, diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index b5998cd2504..60269a954eb 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3791,7 +3791,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4454,6 +4453,7 @@ }, "no-properties-changed": "Нет изменений соответствующих свойств", "table": { + "notes": "", "updated": "Дата", "updatedBy": "Обновлено", "version": "Версия" @@ -4912,7 +4912,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Значения, разделенные запятыми" }, "datasource-options": { "name-filter": "Фильтр по названию", @@ -6010,6 +6011,9 @@ }, "custom-variable-form": { "custom-options": "Пользовательские параметры", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Значения, разделенные запятыми", "selection-options": "Параметры выбора" }, @@ -6601,6 +6605,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Дашборд сохранен" }, @@ -6624,6 +6633,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6683,8 +6693,11 @@ "tooltip-show-usages": "Показать варианты использования" }, "variable-values-preview": { - "preview-of-values": "Просмотр значений", - "show-more": "Показать еще" + "show-more": "Показать еще", + "preview-of-values_one": "", + "preview-of-values_few": "", + "preview-of-values_many": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10795,18 +10808,6 @@ "help/documentation": "Документация", "help/keyboard-shortcuts": "Сочетания клавиш", "help/support": "Поддержка", - "history-container": { - "drawer-tittle": "История" - }, - "history-wrapper": { - "collapse": "Свернуть", - "expand": "Развернуть", - "icon-selected": "Выделенная запись", - "icon-unselected": "Обычная запись", - "show-more": "Показать еще", - "today": "Сегодня", - "yesterday": "Вчера" - }, "home": { "title": "Главная" }, diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 34ad2defca4..c2ea21f798f 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "Inga relevanta egenskaper har ändrats", "table": { + "notes": "", "updated": "Datum", "updatedBy": "Uppdaterad per", "version": "Version" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Värden åtskilda med kommatecken" }, "datasource-options": { "name-filter": "Namnfilter", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Anpassade alternativ", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Värden åtskilda med kommatecken", "selection-options": "Urvalsalternativ" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Kontrollpanelen sparades" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Visa användningar" }, "variable-values-preview": { - "preview-of-values": "Förhandsgranska värden", - "show-more": "Visa mer" + "show-more": "Visa mer", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Dokumentation", "help/keyboard-shortcuts": "Tangentbordsgenvägar", "help/support": "Support", - "history-container": { - "drawer-tittle": "Historik" - }, - "history-wrapper": { - "collapse": "Minimera", - "expand": "Expandera", - "icon-selected": "Markerad inmatning", - "icon-unselected": "Normal inmatning", - "show-more": "Visa mer", - "today": "Idag", - "yesterday": "Igår" - }, "home": { "title": "Hem" }, diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 7e3b1738568..d1eb1d9368a 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3759,7 +3759,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4416,6 +4415,7 @@ }, "no-properties-changed": "İlgili hiçbir özellik değiştirilmedi", "table": { + "notes": "", "updated": "Tarih", "updatedBy": "Güncelleyen:", "version": "Sürüm" @@ -4874,7 +4874,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "Virgülle ayrılmış değerler" }, "datasource-options": { "name-filter": "Ad filtresi", @@ -5968,6 +5969,9 @@ }, "custom-variable-form": { "custom-options": "Özel seçenekler", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "Virgülle ayrılmış değerler", "selection-options": "Seçim ayarları" }, @@ -6555,6 +6559,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "Pano kaydedildi" }, @@ -6578,6 +6587,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6637,8 +6647,9 @@ "tooltip-show-usages": "Kullanımları göster" }, "variable-values-preview": { - "preview-of-values": "Değerlerin ön izlemesi", - "show-more": "Daha fazla göster" + "show-more": "Daha fazla göster", + "preview-of-values_one": "", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10709,18 +10720,6 @@ "help/documentation": "Belgeler", "help/keyboard-shortcuts": "Klavye kısayolları", "help/support": "Destek", - "history-container": { - "drawer-tittle": "Geçmiş" - }, - "history-wrapper": { - "collapse": "Daralt", - "expand": "Genişlet", - "icon-selected": "Seçili giriş", - "icon-unselected": "Normal Giriş", - "show-more": "Daha fazla göster", - "today": "Bugün", - "yesterday": "Dün" - }, "home": { "title": "Ana sayfa" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 7d53f6e4fd1..24739ca7ffe 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "没有相关属性更改", "table": { + "notes": "", "updated": "日期", "updatedBy": "更新者", "version": "版本" @@ -4855,7 +4855,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "以逗号分隔的值" }, "datasource-options": { "name-filter": "名称筛选器", @@ -5947,6 +5948,9 @@ }, "custom-variable-form": { "custom-options": "自定义选项", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "以逗号分隔的值", "selection-options": "选择内容选项" }, @@ -6532,6 +6536,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "数据面板已保存" }, @@ -6555,6 +6564,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6624,8 @@ "tooltip-show-usages": "显示使用情况" }, "variable-values-preview": { - "preview-of-values": "值预览", - "show-more": "显示更多" + "show-more": "显示更多", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10666,18 +10676,6 @@ "help/documentation": "文档", "help/keyboard-shortcuts": "快捷键", "help/support": "支持", - "history-container": { - "drawer-tittle": "历史记录" - }, - "history-wrapper": { - "collapse": "收起", - "expand": "展开", - "icon-selected": "所选条目", - "icon-unselected": "正常条目", - "show-more": "显示更多", - "today": "今天", - "yesterday": "昨天" - }, "home": { "title": "首页" }, diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 2e6243b80c2..77ce4df8a24 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3743,7 +3743,6 @@ }, "recently-viewed": { "clear": "", - "empty": "", "error": "", "retry": "", "title": "" @@ -4397,6 +4396,7 @@ }, "no-properties-changed": "沒有相關的屬性變更", "table": { + "notes": "", "updated": "日期", "updatedBy": "更新者", "version": "版本" @@ -4855,7 +4855,8 @@ "apply": "", "change-value": "", "discard": "", - "modal-title": "" + "modal-title": "", + "values": "以逗號分隔的值" }, "datasource-options": { "name-filter": "名稱篩選", @@ -5947,6 +5948,9 @@ }, "custom-variable-form": { "custom-options": "自訂選項", + "json-values-tooltip": "", + "name-csv-values": "", + "name-json-values": "", "name-values-separated-comma": "以逗號分隔的值", "selection-options": "選擇選項" }, @@ -6532,6 +6536,11 @@ } } }, + "use-modal-editor": { + "description": { + "change-variable-query": "" + } + }, "use-save-dashboard": { "message-dashboard-saved": "儀表板已儲存" }, @@ -6555,6 +6564,7 @@ "label": "" }, "hidden": { + "description": "", "label": "" }, "hidden-label": { @@ -6614,8 +6624,8 @@ "tooltip-show-usages": "顯示使用情況" }, "variable-values-preview": { - "preview-of-values": "數值預覽", - "show-more": "顯示更多" + "show-more": "顯示更多", + "preview-of-values_other": "" }, "version-history": { "comparison": { @@ -10666,18 +10676,6 @@ "help/documentation": "文件", "help/keyboard-shortcuts": "鍵盤捷徑", "help/support": "支援", - "history-container": { - "drawer-tittle": "歷史紀錄" - }, - "history-wrapper": { - "collapse": "收闔", - "expand": "展開", - "icon-selected": "已選取條目", - "icon-unselected": "一般條目", - "show-more": "顯示更多", - "today": "今天", - "yesterday": "昨天" - }, "home": { "title": "首頁" }, diff --git a/public/openapi3.json b/public/openapi3.json index cda13e8a2dc..4a7c5f6be08 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -8018,6 +8018,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "format": "date-time", + "type": "string" + }, "email": { "type": "string" }, @@ -8264,10 +8268,7 @@ "type": "string" }, "timezone": { - "enum": [ - "utc", - "browser" - ], + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", "type": "string" }, "weekStart": { @@ -10039,6 +10040,9 @@ }, "ReportOptions": { "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -12651,10 +12655,7 @@ "type": "string" }, "timezone": { - "enum": [ - "utc", - "browser" - ], + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", "type": "string" }, "weekStart": { @@ -12941,6 +12942,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "format": "date-time", + "type": "string" + }, "email": { "type": "string" }, @@ -20724,13 +20729,21 @@ } }, { - "description": "A comma separated list of folder ID(s) to filter the elements by.", + "description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.", "in": "query", "name": "folderFilter", "schema": { "type": "string" } }, + { + "description": "A comma separated list of folder UID(s) to filter the elements by.", + "in": "query", + "name": "folderFilterUIDs", + "schema": { + "type": "string" + } + }, { "description": "The number of results per page.", "in": "query", diff --git a/public/sass/_angular.scss b/public/sass/_angular.scss index 271965fff82..7282a7f02e6 100644 --- a/public/sass/_angular.scss +++ b/public/sass/_angular.scss @@ -1161,56 +1161,6 @@ div.editor-option label { content: '\e902'; } -.bootstrap-tagsinput { - display: inline-block; - padding: 0 0 0 6px; - vertical-align: middle; - max-width: 100%; - line-height: 22px; - background-color: $input-bg; - border: 1px solid $input-border-color; - - input { - display: inline-block; - border: none; - margin: 0px; - border-radius: 0; - padding: 8px 6px; - height: 100%; - width: 70px; - box-sizing: border-box; - - &.gf-form-input--has-help-icon { - padding-right: $space-xl; - } - } - - .tag { - margin-right: 2px; - color: $white; - - [data-role='remove'] { - margin-left: 8px; - cursor: pointer; - - &::after { - content: 'x'; - padding: 0px 2px; - } - - &:hover { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.2), - 0 1px 2px rgba(0, 0, 0, 0.05); - - &:active { - box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); - } - } - } - } -} - .page-header { margin-top: $space-md; diff --git a/public/vendor/tagsinput/bootstrap-tagsinput.js b/public/vendor/tagsinput/bootstrap-tagsinput.js deleted file mode 100644 index 66e1aa270b4..00000000000 --- a/public/vendor/tagsinput/bootstrap-tagsinput.js +++ /dev/null @@ -1,512 +0,0 @@ -(function ($) { - "use strict"; - - var defaultOptions = { - tagClass: function(item) { - return 'label label-info'; - }, - itemValue: function(item) { - return item ? item.toString() : item; - }, - itemText: function(item) { - return this.itemValue(item); - }, - freeInput: true, - maxTags: undefined, - confirmKeys: [13], - onTagExists: function(item, $tag) { - $tag.hide().fadeIn(); - } - }; - - /** - * Constructor function - */ - function TagsInput(element, options) { - this.itemsArray = []; - - this.$element = $(element); - this.$element.hide(); - - this.widthClass = options.widthClass || 'width-9'; - this.isSelect = (element.tagName === 'SELECT'); - this.multiple = (this.isSelect && element.hasAttribute('multiple')); - this.objectItems = options && options.itemValue; - this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; - - this.$container = $('
    '); - this.$input = $('').appendTo(this.$container); - - this.$element.after(this.$container); - - this.build(options); - } - - TagsInput.prototype = { - constructor: TagsInput, - - /** - * Adds the given item as a new tag. Pass true to dontPushVal to prevent - * updating the elements val() - */ - add: function(item, dontPushVal) { - var self = this; - - if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) - return; - - // Ignore falsey values, except false - if (item !== false && !item) - return; - - // Throw an error when trying to add an object while the itemValue option was not set - if (typeof item === "object" && !self.objectItems) - throw("Can't add objects when itemValue option is not set"); - - // Ignore strings only containg whitespace - if (item.toString().match(/^\s*$/)) - return; - - // If SELECT but not multiple, remove current tag - if (self.isSelect && !self.multiple && self.itemsArray.length > 0) - self.remove(self.itemsArray[0]); - - if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { - var items = item.split(','); - if (items.length > 1) { - for (var i = 0; i < items.length; i++) { - this.add(items[i], true); - } - - if (!dontPushVal) - self.pushVal(); - return; - } - } - - var itemValue = self.options.itemValue(item), - itemText = self.options.itemText(item), - tagClass = self.options.tagClass(item); - - // Ignore items already added - var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; - if (existing) { - // Invoke onTagExists - if (self.options.onTagExists) { - var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); - self.options.onTagExists(item, $existingTag); - } - return; - } - - // register item in internal array and map - self.itemsArray.push(item); - - // add a tag element - var $tag = $('' + htmlEncode(itemText) + ''); - $tag.data('item', item); - self.findInputWrapper().before($tag); - $tag.after(' '); - - // add