diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 72635653fe4..bbdbb9040df 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -88,6 +88,7 @@ /apps/preferences/ @grafana/grafana-app-platform-squad @grafana/grafana-frontend-platform /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 @@ -629,6 +630,7 @@ /packages/grafana-runtime/rollup.config.ts @grafana/grafana-frontend-platform /packages/grafana-runtime/src/index.ts @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend /packages/grafana-runtime/src/internal/index.ts @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend +/packages/grafana-runtime/src/internal/openFeature @grafana/grafana-frontend-platform /packages/grafana-runtime/src/unstable.ts @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend /packages/grafana-runtime/tsconfig.build.json @grafana/grafana-frontend-platform /packages/grafana-runtime/tsconfig.json @grafana/grafana-frontend-platform @@ -1281,6 +1283,7 @@ embed.go @grafana/grafana-as-code /.github/license_finder.yaml @bergquist /.github/actionlint.yaml @grafana/grafana-developer-enablement-squad /.github/workflows/pr-test-docker.yml @grafana/grafana-developer-enablement-squad +/.github/workflows/update-schema-types.yml @grafana/plugins-platform-frontend # Generated files not requiring owner approval /packages/grafana-data/src/types/featureToggles.gen.ts @grafanabot diff --git a/.github/workflows/update-schema-types.yml b/.github/workflows/update-schema-types.yml new file mode 100644 index 00000000000..a079f1d313e --- /dev/null +++ b/.github/workflows/update-schema-types.yml @@ -0,0 +1,22 @@ +name: Update Schema Types + +on: + push: + branches: + - main + paths: + - docs/sources/developers/plugins/plugin.schema.json + workflow_dispatch: + +# These permissions are needed to assume roles from Github's OIDC. +permissions: + contents: read + id-token: write + +jobs: + bundle-schema-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: grafana/plugin-actions/bundle-schema-types@main diff --git a/Dockerfile b/Dockerfile index 81d19a46e3c..a5121873b10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,6 +99,7 @@ COPY apps/correlations apps/correlations COPY apps/preferences apps/preferences COPY apps/provisioning apps/provisioning COPY apps/secret apps/secret +COPY apps/scope apps/scope COPY apps/investigations apps/investigations COPY apps/advisor apps/advisor COPY apps/dashboard apps/dashboard diff --git a/apps/dashboard/pkg/migration/frontend_defaults.go b/apps/dashboard/pkg/migration/frontend_defaults.go index 6cce349a338..6f4cbbf87a4 100644 --- a/apps/dashboard/pkg/migration/frontend_defaults.go +++ b/apps/dashboard/pkg/migration/frontend_defaults.go @@ -198,7 +198,7 @@ func sortPanelsByGridPos(dashboard map[string]interface{}) { return } - sort.Slice(panels, func(i, j int) bool { + sort.SliceStable(panels, func(i, j int) bool { panelA := panels[i] panelB := panels[j] @@ -831,7 +831,7 @@ func cleanupPanelList(panels []interface{}) { // sortPanelsByGridPosition sorts panels by grid position (matches frontend sortPanelsByGridPos behavior) func sortPanelsByGridPosition(panels []interface{}) { - sort.Slice(panels, func(i, j int) bool { + sort.SliceStable(panels, func(i, j int) bool { panelA, okA := panels[i].(map[string]interface{}) panelB, okB := panels[j].(map[string]interface{}) if !okA || !okB { diff --git a/apps/dashboard/pkg/migration/schemaversion/v16.go b/apps/dashboard/pkg/migration/schemaversion/v16.go index d8fb357c3ef..da6db1dcc1a 100644 --- a/apps/dashboard/pkg/migration/schemaversion/v16.go +++ b/apps/dashboard/pkg/migration/schemaversion/v16.go @@ -49,10 +49,15 @@ func upgradeToGridLayout(dashboard map[string]interface{}) { maxPanelID := getMaxPanelID(rows) nextRowID := maxPanelID + 1 - // Get existing panels - var finalPanels []interface{} - if existingPanels, ok := dashboard["panels"].([]interface{}); ok { - finalPanels = existingPanels + // Match frontend: dashboard.panels already exists with top-level panels + // The frontend's this.dashboard.panels is initialized in the constructor with existing panels + // Then upgradeToGridLayout adds more panels to it + + // Initialize panels array - make a copy to avoid modifying the original + panels := []interface{}{} + if existingPanels, ok := dashboard["panels"].([]interface{}); ok && len(existingPanels) > 0 { + // Copy existing panels to preserve order + panels = append(panels, existingPanels...) } // Add special "row" panels if even one row is collapsed, repeated or has visible title (line 1028 in TS) @@ -72,7 +77,14 @@ func upgradeToGridLayout(dashboard map[string]interface{}) { height := getRowHeight(row) rowGridHeight := getGridHeight(height) - isCollapsed := GetBoolValue(row, "collapse") + // Check if collapse property exists and get its value + collapseValue, hasCollapseProperty := row["collapse"] + isCollapsed := false + if hasCollapseProperty { + if b, ok := collapseValue.(bool); ok { + isCollapsed = b + } + } var rowPanel map[string]interface{} @@ -110,9 +122,9 @@ func upgradeToGridLayout(dashboard map[string]interface{}) { }, } - // Set collapsed property only if the original row had a collapse property - // This matches the frontend behavior: rowPanel.collapsed = row.collapse - if _, hasCollapse := row["collapse"]; hasCollapse { + // Match frontend behavior: rowPanel.collapsed = row.collapse (line 1065 in TS) + // Only set collapsed property if the original row had a collapse property + if hasCollapseProperty { rowPanel["collapsed"] = isCollapsed } nextRowID++ @@ -128,20 +140,14 @@ func upgradeToGridLayout(dashboard map[string]interface{}) { continue } - // Check if panel already has gridPos but no valid span - // If span is missing or zero, and gridPos exists, preserve gridPos dimensions - var panelWidth, panelHeight int + // Match frontend logic: panel.span = panel.span || DEFAULT_PANEL_SPAN (line 1082 in TS) span := GetFloatValue(panel, "span", 0) - existingGridPos, hasGridPos := panel["gridPos"].(map[string]interface{}) - - if hasGridPos && span == 0 { - // Panel already has gridPos but no valid span - preserve its dimensions - panelWidth = GetIntValue(existingGridPos, "w", int(defaultPanelSpan*widthFactor)) - panelHeight = GetIntValue(existingGridPos, "h", rowGridHeight) - } else { - panelWidth, panelHeight = calculatePanelDimensionsFromSpan(span, panel, widthFactor, rowGridHeight) + if span == 0 { + span = defaultPanelSpan } + panelWidth, panelHeight := calculatePanelDimensionsFromSpan(span, panel, widthFactor, rowGridHeight) + panelPos := rowArea.getPanelPosition(panelHeight, panelWidth) yPos = rowArea.yPos @@ -157,21 +163,21 @@ func upgradeToGridLayout(dashboard map[string]interface{}) { // Remove span (line 1080 in TS) delete(panel, "span") - // Exact logic from lines 1082-1086 in TS + // Match frontend logic: lines 1101-1105 in TS if rowPanel != nil && isCollapsed { - // Add to collapsed row's nested panels + // Add to collapsed row's nested panels (line 1102) if rowPanelPanels, ok := rowPanel["panels"].([]interface{}); ok { rowPanel["panels"] = append(rowPanelPanels, panel) } } else { - // Add directly to dashboard panels - finalPanels = append(finalPanels, panel) + // Add directly to panels array like frontend (line 1104) + panels = append(panels, panel) } } - // Add row panel after processing all panels (lines 1089-1091 in TS) + // Add row panel after regular panels from this row (lines 1108-1110 in TS) if rowPanel != nil { - finalPanels = append(finalPanels, rowPanel) + panels = append(panels, rowPanel) } // Update yPos (lines 1093-1095 in TS) @@ -181,7 +187,7 @@ func upgradeToGridLayout(dashboard map[string]interface{}) { } // Update the dashboard - dashboard["panels"] = finalPanels + dashboard["panels"] = panels delete(dashboard, "rows") } @@ -313,10 +319,7 @@ func getGridHeight(height float64) int { } func calculatePanelDimensionsFromSpan(span float64, panel map[string]interface{}, widthFactor float64, defaultHeight int) (int, int) { - // Set default span if still 0 - if span == 0 { - span = defaultPanelSpan - } + // span should already be normalized by caller (line 1082 in DashboardMigrator.ts) if minSpan, hasMinSpan := panel["minSpan"]; hasMinSpan { if minSpanFloat, ok := ConvertToFloat(minSpan); ok && minSpanFloat > 0 { diff --git a/apps/dashboard/pkg/migration/schemaversion/v16_test.go b/apps/dashboard/pkg/migration/schemaversion/v16_test.go index 4ce1b423af3..007e912fc85 100644 --- a/apps/dashboard/pkg/migration/schemaversion/v16_test.go +++ b/apps/dashboard/pkg/migration/schemaversion/v16_test.go @@ -1532,6 +1532,123 @@ func TestV16(t *testing.T) { // rows field should be removed }, }, + { + name: "should handle span zero by defaulting to DEFAULT_PANEL_SPAN", + input: map[string]interface{}{ + "schemaVersion": 15, + "rows": []interface{}{ + map[string]interface{}{ + "collapse": false, + "showTitle": true, // Need this to create row panel + "title": "Test Row", + "height": 250, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "span": 0, // This should be defaulted to 4 (DEFAULT_PANEL_SPAN) + }, + map[string]interface{}{ + "id": 2, + "type": "stat", + "span": 6, // Normal span value + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "gridPos": map[string]interface{}{ + "x": 0, + "y": 1, + "w": 8, // span 0 -> DEFAULT_PANEL_SPAN (4) -> 4 * 2 = 8 width + "h": 7, // default height + }, + }, + map[string]interface{}{ + "id": 2, + "type": "stat", + "gridPos": map[string]interface{}{ + "x": 8, // After first panel + "y": 1, + "w": 12, // span 6 -> 6 * 2 = 12 width + "h": 7, // default height + }, + }, + // Row panel should be created because showTitle is true + map[string]interface{}{ + "id": 3, + "type": "row", + "title": "Test Row", + "collapsed": false, // Set because input has "collapse": false + "repeat": "", + "panels": []interface{}{}, + "gridPos": map[string]interface{}{ + "x": 0, + "y": 0, + "w": 24, + "h": 7, + }, + }, + }, + }, + }, + { + name: "should not set collapsed property when input row has no collapse property", + input: map[string]interface{}{ + "schemaVersion": 15, + "rows": []interface{}{ + map[string]interface{}{ + // No "collapse" property in input + "showTitle": true, + "title": "Test Row", + "height": 250, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "span": 12, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "gridPos": map[string]interface{}{ + "x": 0, + "y": 1, + "w": 24, // span 12 -> 12 * 2 = 24 width + "h": 7, // default height + }, + }, + // Row panel should be created because showTitle is true + map[string]interface{}{ + "id": 2, + "type": "row", + "title": "Test Row", + // No "collapsed" property because input had no "collapse" property + "repeat": "", + "panels": []interface{}{}, + "gridPos": map[string]interface{}{ + "x": 0, + "y": 0, + "w": 24, + "h": 7, + }, + }, + }, + }, + }, } runMigrationTests(t, tests, schemaversion.V16) diff --git a/apps/dashboard/pkg/migration/testdata/input/v16.span_zero_demo.json b/apps/dashboard/pkg/migration/testdata/input/v16.span_zero_demo.json new file mode 100644 index 00000000000..5dbb57fa244 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v16.span_zero_demo.json @@ -0,0 +1,687 @@ +{ + "__requires": [ + { + "id": "grafana", + "name": "Grafana", + "type": "grafana", + "version": "8.0.0" + } + ], + "annotations": { + "list": [] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "links": [ + { + "icon": "external link", + "targetBlank": true, + "title": "External Documentation", + "type": "link", + "url": "https://example.com/docs" + } + ], + "panels": [ + { + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "options": { + "content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n", + "mode": "markdown" + }, + "title": "Application Monitoring", + "type": "text" + } + ], + "refresh": "10s", + "rows": [ + { + "collapse": false, + "collapsed": false, + "height": "250px", + "panels": [ + { + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 6, + "options": { + "content": "This service handles background processing tasks for the application system. It manages various types of operations including data synchronization, resource management, and batch processing.\n\nSupported operation types:\n1. Sync: Synchronizes data between different systems\n2. Process: Handles batch data processing tasks\n3. Cleanup: Removes outdated or temporary resources\n4. Update: Applies configuration changes across services\n\nService dependencies:\n- Data API: For reading and writing application data\n- Configuration Service: For managing system settings\n- Queue Service: For handling task scheduling\n- Storage Service: For persistent data management\n- Auth Service: For authentication and authorization\n- Metrics Service: For collecting operational statistics\n", + "mode": "markdown" + }, + "span": 0, + "title": "Service Overview", + "type": "text" + }, + { + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 7, + "options": { + "content": "Error monitoring helps identify issues in the system. This section displays error logs and success rates for operations.", + "mode": "markdown" + }, + "span": 0, + "title": "Error Monitoring", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "yellow", + "value": 0.95 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "percentunit" + } + }, + "gridPos": { + "h": 9, + "w": 3, + "x": 0, + "y": 19 + }, + "id": 8, + "span": 0, + "targets": [ + { + "expr": "sum by (action) (app_jobs_processed_total{outcome=\"success\", cluster=\"$cluster\", namespace=\"default\"})\n/\nsum by (action) (app_jobs_processed_total{cluster=\"$cluster\", namespace=\"default\"})\n", + "legendFormat": "{{action}}" + } + ], + "title": "Job Success Rate", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 3, + "y": 19 + }, + "id": 9, + "options": { + "enableLogDetails": true, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "span": 0, + "targets": [ + { + "expr": "{namespace=\"default\", cluster=\"$cluster\", job=\"app-service\"} | logfmt | level=\"error\"" + } + ], + "title": "Errors", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 9, + "w": 11, + "x": 13, + "y": 19 + }, + "id": 10, + "options": { + "enableLogDetails": true, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "span": 0, + "targets": [ + { + "expr": "{namespace=\"default\", cluster=\"$cluster\", job=\"app-service\"} | logfmt" + } + ], + "title": "All", + "type": "logs" + }, + { + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 28 + }, + "id": 11, + "options": { + "content": "Performance monitoring examines factors that affect system response times, including operation duration, queue lengths, and processing delays. This section provides metrics and traces for performance analysis.\n", + "mode": "markdown" + }, + "span": 0, + "title": "Performance Analysis", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Number of concurrent processing threads available for handling operations", + "gridPos": { + "h": 6, + "w": 5, + "x": 0, + "y": 31 + }, + "id": 12, + "span": 0, + "targets": [ + { + "expr": "max(app_worker_threads_active{cluster=\"$cluster\", namespace=\"default\"})", + "instant": true + } + ], + "title": "Concurrent Job Drivers", + "type": "stat" + }, + { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, + "gridPos": { + "h": 6, + "w": 19, + "x": 5, + "y": 31 + }, + "id": 13, + "span": 0, + "targets": [ + { + "filters": [ + { + "id": "span-name", + "operator": "=", + "scope": "span", + "tag": "name", + "value": [ + "provisioning.sync.process" + ] + }, + { + "id": "k8s-cluster-name", + "operator": "=", + "scope": "resource", + "tag": "k8s.cluster.name", + "value": [ + "$cluster" + ] + } + ], + "query": "{name=\"app.operation.process\"}", + "queryType": "traceqlSearch" + } + ], + "title": "Recent Operation Traces", + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "yellow", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + } + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 0, + "y": 55 + }, + "id": 14, + "span": 0, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) > 0", + "legendFormat": "{{action}} q0.99 - size {{resources_changed_bucket}}", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.9, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) > 0", + "legendFormat": "{{action}} q0.95 - size {{resources_changed_bucket}}", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) > 0", + "legendFormat": "{{action}} q0.5 - size {{resources_changed_bucket}}", + "refId": "D" + }, + { + "expr": "histogram_quantile(0.1, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) > 0", + "legendFormat": "{{action}} q0.1 - size {{resources_changed_bucket}}", + "refId": "E" + } + ], + "timeFrom": "7d", + "title": "7d avg of job durations", + "transformations": [ + { + "id": "reduce", + "options": { + "mode": "seriesToRows", + "reducers": [ + "mean" + ] + } + }, + { + "id": "seriesToRows" + }, + { + "id": "organize", + "options": { + "renameByName": { + "Field": "Type", + "Mean": "Avg Duration", + "Metric": "Legend", + "Value": "Duration" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", + "gridPos": { + "h": 10, + "w": 16, + "x": 8, + "y": 55 + }, + "id": 15, + "span": 0, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.99 - size {{resources_changed_bucket}}", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.95 - size {{resources_changed_bucket}}", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.5 - size {{resources_changed_bucket}}", + "refId": "D" + }, + { + "expr": "histogram_quantile(0.1, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.1 - size {{resources_changed_bucket}}", + "refId": "E" + } + ], + "title": "Job Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Total number of jobs waiting to be processed", + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 65 + }, + "id": 16, + "span": 0, + "targets": [ + { + "expr": "clamp_min(sum(app_operation_queue_size{cluster=\"$cluster\", namespace=\"default\"}), 0)", + "legendFormat": "Queue size" + } + ], + "title": "Queue Size", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + } + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 65 + }, + "id": 17, + "span": 0, + "targets": [ + { + "expr": "avg(histogram_quantile(0.5, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le)))", + "legendFormat": "Queue size" + } + ], + "timeFrom": "7d", + "title": "7d avg Queue Wait Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "How long a job is in the queue before being picked up", + "gridPos": { + "h": 5, + "w": 16, + "x": 8, + "y": 65 + }, + "id": 18, + "span": 0, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.99", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.95", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.5", + "refId": "D" + }, + { + "expr": "histogram_quantile(0.1, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.1", + "refId": "E" + } + ], + "title": "Queue Wait Time", + "type": "timeseries" + }, + { + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 52 + }, + "id": 19, + "options": { + "content": "Resource utilization monitoring for application containers", + "mode": "markdown" + }, + "span": 0, + "title": "Resource Monitoring", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 9, + "w": 7, + "x": 0, + "y": 55 + }, + "id": 20, + "span": 0, + "targets": [ + { + "expr": "count by (cluster, channel)(label_replace(label_replace(kube_pod_container_info{namespace=\"default\", container=\"app-worker\", pod=~\"app-worker.*\", cluster=~\"$cluster\"}, \"version\", \"$1\", \"image\", \".+:(.+)\"), \"channel\", \"$1\", \"container\", \".+-(.+)\"))", + "legendFormat": "{{cluster}}" + } + ], + "title": "Running Pod(s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 7, + "y": 55 + }, + "id": 21, + "span": 0, + "targets": [ + { + "expr": "max(kube_pod_container_resource_requests{namespace=\"default\", resource=\"memory\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"})", + "legendFormat": "Memory Request" + }, + { + "expr": "max(kube_pod_container_resource_limits{namespace=\"default\", resource=\"memory\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"})", + "legendFormat": "Memory Limit" + }, + { + "expr": "max(container_memory_usage_bytes{namespace=\"default\",cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"}) by (pod)", + "legendFormat": "Container usage {{pod}}" + } + ], + "title": "Memory Utilization", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 9, + "w": 9, + "x": 15, + "y": 55 + }, + "id": 22, + "span": 0, + "targets": [ + { + "expr": "sum(irate(container_cpu_usage_seconds_total{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\"}[$__rate_interval])) by (pod, container, cpu)", + "legendFormat": "Usage {{pod}}" + }, + { + "expr": "sum(irate(container_cpu_cfs_throttled_seconds_total{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\"}[$__rate_interval])) by (pod, container)", + "legendFormat": "Throttling {{pod}}" + }, + { + "expr": "max(kube_pod_container_resource_limits{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\", resource=\"cpu\"})", + "legendFormat": "CPU limit" + }, + { + "expr": "max(kube_pod_container_resource_requests{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\", resource=\"cpu\"})", + "legendFormat": "CPU request" + } + ], + "title": "CPU Utilization", + "type": "timeseries" + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Application Service", + "titleSize": "h6" + } + ], + "schemaVersion": 15, + "style": "dark", + "tags": [ + "as-code" + ], + "templating": { + "list": [ + { + "current": { + "value": "prometheus-datasource" + }, + "hide": 0, + "label": "Data source", + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "value": "prometheus-datasource" + }, + "name": "prom", + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "value": "loki-datasource" + }, + "name": "loki", + "query": "loki", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "tempo-datasource", + "value": "tempo-datasource" + }, + "name": "tempo", + "query": "tempo", + "refresh": 1, + "regex": ".*tempo.*", + "type": "datasource" + }, + { + "current": { + "text": "demo-cluster", + "value": "demo-cluster" + }, + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "name": "cluster", + "query": "label_values(app_worker_threads_active,cluster)", + "refresh": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "utc", + "title": "Span Zero Demo Dashboard", + "uid": "span-zero-demo-dashboard", + "version": 0 +} diff --git a/apps/dashboard/pkg/migration/testdata/output/latest_version/v16.span_zero_demo.v42.json b/apps/dashboard/pkg/migration/testdata/output/latest_version/v16.span_zero_demo.v42.json new file mode 100644 index 00000000000..91012684ebf --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/latest_version/v16.span_zero_demo.v42.json @@ -0,0 +1,881 @@ +{ + "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": false, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [ + { + "icon": "external link", + "targetBlank": true, + "title": "External Documentation", + "type": "link", + "url": "https://example.com/docs" + } + ], + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n", + "mode": "markdown" + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Application Monitoring", + "type": "text" + }, + { + "collapsed": false, + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 23, + "panels": [], + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Application Service", + "type": "row" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 6, + "options": { + "content": "This service handles background processing tasks for the application system. It manages various types of operations including data synchronization, resource management, and batch processing.\n\nSupported operation types:\n1. Sync: Synchronizes data between different systems\n2. Process: Handles batch data processing tasks\n3. Cleanup: Removes outdated or temporary resources\n4. Update: Applies configuration changes across services\n\nService dependencies:\n- Data API: For reading and writing application data\n- Configuration Service: For managing system settings\n- Queue Service: For handling task scheduling\n- Storage Service: For persistent data management\n- Auth Service: For authentication and authorization\n- Metrics Service: For collecting operational statistics\n", + "mode": "markdown" + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Service Overview", + "type": "text" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 7, + "options": { + "content": "Error monitoring helps identify issues in the system. This section displays error logs and success rates for operations.", + "mode": "markdown" + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Error Monitoring", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "yellow", + "value": 0.95 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 8, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "sum by (action) (app_jobs_processed_total{outcome=\"success\", cluster=\"$cluster\", namespace=\"default\"})\n/\nsum by (action) (app_jobs_processed_total{cluster=\"$cluster\", namespace=\"default\"})\n", + "legendFormat": "{{action}}", + "refId": "A" + } + ], + "title": "Job Success Rate", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 8 + }, + "id": 9, + "options": { + "enableLogDetails": true, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "expr": "{namespace=\"default\", cluster=\"$cluster\", job=\"app-service\"} | logfmt | level=\"error\"", + "refId": "A" + } + ], + "title": "Errors", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 8 + }, + "id": 10, + "options": { + "enableLogDetails": true, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "expr": "{namespace=\"default\", cluster=\"$cluster\", job=\"app-service\"} | logfmt", + "refId": "A" + } + ], + "title": "All", + "type": "logs" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 8 + }, + "id": 11, + "options": { + "content": "Performance monitoring examines factors that affect system response times, including operation duration, queue lengths, and processing delays. This section provides metrics and traces for performance analysis.\n", + "mode": "markdown" + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Performance Analysis", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Number of concurrent processing threads available for handling operations", + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 15 + }, + "id": 12, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "max(app_worker_threads_active{cluster=\"$cluster\", namespace=\"default\"})", + "instant": true, + "refId": "A" + } + ], + "title": "Concurrent Job Drivers", + "type": "stat" + }, + { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 15 + }, + "id": 13, + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, + "filters": [ + { + "id": "span-name", + "operator": "=", + "scope": "span", + "tag": "name", + "value": [ + "provisioning.sync.process" + ] + }, + { + "id": "k8s-cluster-name", + "operator": "=", + "scope": "resource", + "tag": "k8s.cluster.name", + "value": [ + "$cluster" + ] + } + ], + "query": "{name=\"app.operation.process\"}", + "queryType": "traceqlSearch", + "refId": "A" + } + ], + "title": "Recent Operation Traces", + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "yellow", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 15 + }, + "id": 14, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.99, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.99 - size {{resources_changed_bucket}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.9, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.95 - size {{resources_changed_bucket}}", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.5, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.5 - size {{resources_changed_bucket}}", + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.1, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.1 - size {{resources_changed_bucket}}", + "refId": "E" + } + ], + "timeFrom": "7d", + "title": "7d avg of job durations", + "transformations": [ + { + "id": "reduce", + "options": { + "mode": "seriesToRows", + "reducers": [ + "mean" + ] + } + }, + { + "id": "seriesToRows" + }, + { + "id": "organize", + "options": { + "renameByName": { + "Field": "Type", + "Mean": "Avg Duration", + "Metric": "Legend", + "Value": "Duration" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 22 + }, + "id": 15, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.99, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.99 - size {{resources_changed_bucket}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.95, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.95 - size {{resources_changed_bucket}}", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.5, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.5 - size {{resources_changed_bucket}}", + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.1, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.1 - size {{resources_changed_bucket}}", + "refId": "E" + } + ], + "title": "Job Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Total number of jobs waiting to be processed", + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 22 + }, + "id": 16, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "clamp_min(sum(app_operation_queue_size{cluster=\"$cluster\", namespace=\"default\"}), 0)", + "legendFormat": "Queue size", + "refId": "A" + } + ], + "title": "Queue Size", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 22 + }, + "id": 17, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "avg(histogram_quantile(0.5, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le)))", + "legendFormat": "Queue size", + "refId": "A" + } + ], + "timeFrom": "7d", + "title": "7d avg Queue Wait Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "How long a job is in the queue before being picked up", + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 29 + }, + "id": 18, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.99, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.99", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.95, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.95", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.5, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.5", + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "histogram_quantile(0.1, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.1", + "refId": "E" + } + ], + "title": "Queue Wait Time", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 29 + }, + "id": 19, + "options": { + "content": "Resource utilization monitoring for application containers", + "mode": "markdown" + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Resource Monitoring", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 29 + }, + "id": 20, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "count by (cluster, channel)(label_replace(label_replace(kube_pod_container_info{namespace=\"default\", container=\"app-worker\", pod=~\"app-worker.*\", cluster=~\"$cluster\"}, \"version\", \"$1\", \"image\", \".+:(.+)\"), \"channel\", \"$1\", \"container\", \".+-(.+)\"))", + "legendFormat": "{{cluster}}", + "refId": "A" + } + ], + "title": "Running Pod(s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 36 + }, + "id": 21, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "max(kube_pod_container_resource_requests{namespace=\"default\", resource=\"memory\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"})", + "legendFormat": "Memory Request", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "max(kube_pod_container_resource_limits{namespace=\"default\", resource=\"memory\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"})", + "legendFormat": "Memory Limit", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "max(container_memory_usage_bytes{namespace=\"default\",cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"}) by (pod)", + "legendFormat": "Container usage {{pod}}", + "refId": "C" + } + ], + "title": "Memory Utilization", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 36 + }, + "id": 22, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "sum(irate(container_cpu_usage_seconds_total{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\"}[$__rate_interval])) by (pod, container, cpu)", + "legendFormat": "Usage {{pod}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "sum(irate(container_cpu_cfs_throttled_seconds_total{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\"}[$__rate_interval])) by (pod, container)", + "legendFormat": "Throttling {{pod}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "max(kube_pod_container_resource_limits{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\", resource=\"cpu\"})", + "legendFormat": "CPU limit", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "expr": "max(kube_pod_container_resource_requests{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\", resource=\"cpu\"})", + "legendFormat": "CPU request", + "refId": "D" + } + ], + "title": "CPU Utilization", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 42, + "tags": [ + "as-code" + ], + "templating": { + "list": [ + { + "current": { + "value": "prometheus-datasource" + }, + "hide": 0, + "label": "Data source", + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "value": "prometheus-datasource" + }, + "name": "prom", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "value": "loki-datasource" + }, + "name": "loki", + "options": [], + "query": "loki", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "tempo-datasource", + "value": "tempo-datasource" + }, + "name": "tempo", + "options": [], + "query": "tempo", + "refresh": 1, + "regex": ".*tempo.*", + "type": "datasource" + }, + { + "current": { + "text": "demo-cluster", + "value": "demo-cluster" + }, + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "name": "cluster", + "options": [], + "query": "label_values(app_worker_threads_active,cluster)", + "refresh": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "utc", + "title": "Span Zero Demo Dashboard", + "uid": "span-zero-demo-dashboard", + "weekStart": "" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/single_version/v16.span_zero_demo.v16.json b/apps/dashboard/pkg/migration/testdata/output/single_version/v16.span_zero_demo.v16.json new file mode 100644 index 00000000000..089f4ac16d3 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/single_version/v16.span_zero_demo.v16.json @@ -0,0 +1,694 @@ +{ + "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": false, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [ + { + "icon": "external link", + "targetBlank": true, + "title": "External Documentation", + "type": "link", + "url": "https://example.com/docs" + } + ], + "panels": [ + { + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n", + "mode": "markdown" + }, + "title": "Application Monitoring", + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 23, + "panels": [], + "title": "Application Service", + "type": "row" + }, + { + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 6, + "options": { + "content": "This service handles background processing tasks for the application system. It manages various types of operations including data synchronization, resource management, and batch processing.\n\nSupported operation types:\n1. Sync: Synchronizes data between different systems\n2. Process: Handles batch data processing tasks\n3. Cleanup: Removes outdated or temporary resources\n4. Update: Applies configuration changes across services\n\nService dependencies:\n- Data API: For reading and writing application data\n- Configuration Service: For managing system settings\n- Queue Service: For handling task scheduling\n- Storage Service: For persistent data management\n- Auth Service: For authentication and authorization\n- Metrics Service: For collecting operational statistics\n", + "mode": "markdown" + }, + "title": "Service Overview", + "type": "text" + }, + { + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 7, + "options": { + "content": "Error monitoring helps identify issues in the system. This section displays error logs and success rates for operations.", + "mode": "markdown" + }, + "title": "Error Monitoring", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "yellow", + "value": 0.95 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 8, + "targets": [ + { + "expr": "sum by (action) (app_jobs_processed_total{outcome=\"success\", cluster=\"$cluster\", namespace=\"default\"})\n/\nsum by (action) (app_jobs_processed_total{cluster=\"$cluster\", namespace=\"default\"})\n", + "legendFormat": "{{action}}", + "refId": "A" + } + ], + "title": "Job Success Rate", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 8 + }, + "id": 9, + "options": { + "enableLogDetails": true, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "expr": "{namespace=\"default\", cluster=\"$cluster\", job=\"app-service\"} | logfmt | level=\"error\"", + "refId": "A" + } + ], + "title": "Errors", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 8 + }, + "id": 10, + "options": { + "enableLogDetails": true, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "expr": "{namespace=\"default\", cluster=\"$cluster\", job=\"app-service\"} | logfmt", + "refId": "A" + } + ], + "title": "All", + "type": "logs" + }, + { + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 8 + }, + "id": 11, + "options": { + "content": "Performance monitoring examines factors that affect system response times, including operation duration, queue lengths, and processing delays. This section provides metrics and traces for performance analysis.\n", + "mode": "markdown" + }, + "title": "Performance Analysis", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Number of concurrent processing threads available for handling operations", + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 15 + }, + "id": 12, + "targets": [ + { + "expr": "max(app_worker_threads_active{cluster=\"$cluster\", namespace=\"default\"})", + "instant": true, + "refId": "A" + } + ], + "title": "Concurrent Job Drivers", + "type": "stat" + }, + { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 15 + }, + "id": 13, + "targets": [ + { + "filters": [ + { + "id": "span-name", + "operator": "=", + "scope": "span", + "tag": "name", + "value": [ + "provisioning.sync.process" + ] + }, + { + "id": "k8s-cluster-name", + "operator": "=", + "scope": "resource", + "tag": "k8s.cluster.name", + "value": [ + "$cluster" + ] + } + ], + "query": "{name=\"app.operation.process\"}", + "queryType": "traceqlSearch", + "refId": "A" + } + ], + "title": "Recent Operation Traces", + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "yellow", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 15 + }, + "id": 14, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.99 - size {{resources_changed_bucket}}", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.9, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.95 - size {{resources_changed_bucket}}", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.5 - size {{resources_changed_bucket}}", + "refId": "D" + }, + { + "expr": "histogram_quantile(0.1, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le, resources_changed_bucket, action)) and on(resources_changed_bucket, action) sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (resources_changed_bucket, action) \u003e 0", + "legendFormat": "{{action}} q0.1 - size {{resources_changed_bucket}}", + "refId": "E" + } + ], + "timeFrom": "7d", + "title": "7d avg of job durations", + "transformations": [ + { + "id": "reduce", + "options": { + "mode": "seriesToRows", + "reducers": [ + "mean" + ] + } + }, + { + "id": "seriesToRows" + }, + { + "id": "organize", + "options": { + "renameByName": { + "Field": "Type", + "Mean": "Avg Duration", + "Metric": "Legend", + "Value": "Duration" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 22 + }, + "id": 15, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.99 - size {{resources_changed_bucket}}", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.95 - size {{resources_changed_bucket}}", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.5 - size {{resources_changed_bucket}}", + "refId": "D" + }, + { + "expr": "histogram_quantile(0.1, sum(rate(app_operation_duration_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[5m])) by (le, resources_changed_bucket, action))", + "legendFormat": "{{action}} q0.1 - size {{resources_changed_bucket}}", + "refId": "E" + } + ], + "title": "Job Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "Total number of jobs waiting to be processed", + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 22 + }, + "id": 16, + "targets": [ + { + "expr": "clamp_min(sum(app_operation_queue_size{cluster=\"$cluster\", namespace=\"default\"}), 0)", + "legendFormat": "Queue size", + "refId": "A" + } + ], + "title": "Queue Size", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 22 + }, + "id": 17, + "targets": [ + { + "expr": "avg(histogram_quantile(0.5, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[7d])) by (le)))", + "legendFormat": "Queue size", + "refId": "A" + } + ], + "timeFrom": "7d", + "title": "7d avg Queue Wait Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "description": "How long a job is in the queue before being picked up", + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 29 + }, + "id": 18, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.99", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.95", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.5, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.5", + "refId": "D" + }, + { + "expr": "histogram_quantile(0.1, sum(rate(app_operation_queue_wait_seconds_bucket{cluster=\"$cluster\", namespace=\"default\"}[$__rate_interval])) by (le))", + "legendFormat": "q0.1", + "refId": "E" + } + ], + "title": "Queue Wait Time", + "type": "timeseries" + }, + { + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 29 + }, + "id": 19, + "options": { + "content": "Resource utilization monitoring for application containers", + "mode": "markdown" + }, + "title": "Resource Monitoring", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 29 + }, + "id": 20, + "targets": [ + { + "expr": "count by (cluster, channel)(label_replace(label_replace(kube_pod_container_info{namespace=\"default\", container=\"app-worker\", pod=~\"app-worker.*\", cluster=~\"$cluster\"}, \"version\", \"$1\", \"image\", \".+:(.+)\"), \"channel\", \"$1\", \"container\", \".+-(.+)\"))", + "legendFormat": "{{cluster}}", + "refId": "A" + } + ], + "title": "Running Pod(s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 36 + }, + "id": 21, + "targets": [ + { + "expr": "max(kube_pod_container_resource_requests{namespace=\"default\", resource=\"memory\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"})", + "legendFormat": "Memory Request", + "refId": "A" + }, + { + "expr": "max(kube_pod_container_resource_limits{namespace=\"default\", resource=\"memory\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"})", + "legendFormat": "Memory Limit", + "refId": "B" + }, + { + "expr": "max(container_memory_usage_bytes{namespace=\"default\",cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker.*\"}) by (pod)", + "legendFormat": "Container usage {{pod}}", + "refId": "C" + } + ], + "title": "Memory Utilization", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 36 + }, + "id": 22, + "targets": [ + { + "expr": "sum(irate(container_cpu_usage_seconds_total{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\"}[$__rate_interval])) by (pod, container, cpu)", + "legendFormat": "Usage {{pod}}", + "refId": "A" + }, + { + "expr": "sum(irate(container_cpu_cfs_throttled_seconds_total{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\"}[$__rate_interval])) by (pod, container)", + "legendFormat": "Throttling {{pod}}", + "refId": "B" + }, + { + "expr": "max(kube_pod_container_resource_limits{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\", resource=\"cpu\"})", + "legendFormat": "CPU limit", + "refId": "C" + }, + { + "expr": "max(kube_pod_container_resource_requests{namespace=\"default\", cluster=~\"$cluster\", container=\"app-worker\", pod=~\"app-worker-.*\", resource=\"cpu\"})", + "legendFormat": "CPU request", + "refId": "D" + } + ], + "title": "CPU Utilization", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 16, + "tags": [ + "as-code" + ], + "templating": { + "list": [ + { + "current": { + "value": "prometheus-datasource" + }, + "hide": 0, + "label": "Data source", + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "value": "prometheus-datasource" + }, + "name": "prom", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "value": "loki-datasource" + }, + "name": "loki", + "options": [], + "query": "loki", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "tempo-datasource", + "value": "tempo-datasource" + }, + "name": "tempo", + "options": [], + "query": "tempo", + "refresh": 1, + "regex": ".*tempo.*", + "type": "datasource" + }, + { + "current": { + "text": "demo-cluster", + "value": "demo-cluster" + }, + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, + "name": "cluster", + "options": [], + "query": "label_values(app_worker_threads_active,cluster)", + "refresh": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "utc", + "title": "Span Zero Demo Dashboard", + "uid": "span-zero-demo-dashboard", + "weekStart": "" +} \ No newline at end of file diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index f21e6a19526..3c4b1af454b 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -5,6 +5,7 @@ go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.46.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 + github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.1 k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b ) @@ -42,7 +43,6 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go b/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go new file mode 100644 index 00000000000..e203e6b560e --- /dev/null +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go @@ -0,0 +1,80 @@ +package v1alpha1 + +import ( + "slices" + "strings" +) + +func (stars *StarsSpec) Add(group, kind, name string) { + for i, r := range stars.Resource { + if r.Group == group && r.Kind == kind { + r.Names = append(r.Names, name) + slices.Sort(r.Names) + stars.Resource[i].Names = slices.Compact(r.Names) + return + } + } + + // Add the resource kind + stars.Resource = append(stars.Resource, StarsResource{ + Group: group, + Kind: kind, + Names: []string{name}, + }) + stars.Normalize() +} + +func (stars *StarsSpec) Remove(group, kind, name string) { + for i, r := range stars.Resource { + if r.Group == group && r.Kind == kind { + idx := slices.Index(r.Names, name) + if idx < 0 { + return // does not exist + } + r.Names = append(r.Names[:idx], r.Names[idx+1:]...) + stars.Resource[i].Names = r.Names + if len(r.Names) == 0 { + stars.Normalize() + } + return + } + } +} + +// Makes sure everything is in sorted order +func (stars *StarsSpec) Normalize() { + resources := make([]StarsResource, 0, len(stars.Resource)) + for _, r := range stars.Resource { + if len(r.Names) > 0 { + slices.Sort(r.Names) + r.Names = slices.Compact(r.Names) // removes any duplicates + resources = append(resources, r) + } + } + slices.SortFunc(resources, func(a StarsResource, b StarsResource) int { + return strings.Compare(a.Group+a.Kind, b.Group+b.Kind) + }) + if len(resources) == 0 { + resources = nil + } + stars.Resource = resources +} + +func Changes(current []string, target []string) (added []string, removed []string, same []string) { + lookup := map[string]bool{} + for _, k := range current { + lookup[k] = true + } + for _, k := range target { + if lookup[k] { + same = append(same, k) + delete(lookup, k) + } else { + added = append(added, k) + } + } + for k := range lookup { + removed = append(removed, k) + } + return +} diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go b/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go new file mode 100644 index 00000000000..5713b07a751 --- /dev/null +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go @@ -0,0 +1,235 @@ +package v1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type starItem struct { + group string + kind string + name string +} + +func TestStarsWrite(t *testing.T) { + t.Run("apply", func(t *testing.T) { + tests := []struct { + name string + spec *StarsSpec + item starItem + remove bool + expect *StarsSpec + }{{ + name: "add to an existing array", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "x"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "c", + }, + remove: false, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "c", "x"}, // added "b" (and sorted) + }}, + }, + }, { + name: "remove from an existing array", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "c"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "b", + }, + remove: true, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "c"}, // removed "b" + }}, + }, + }, { + name: "add to empty spec", + spec: &StarsSpec{}, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: false, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a"}, + }}, + }, + }, { + name: "remove item that does not exist", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"x"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + }, { + name: "add item that already exist", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"x"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "x", + }, + remove: false, + }, { + name: "remove from empty", + spec: &StarsSpec{}, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + }, { + name: "remove item that does not exist", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a", "b", "c"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "X", + }, + remove: true, + }, { + name: "remove last item", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a"}, + }}, + }, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + expect: &StarsSpec{}, // empty object + }, { + name: "remove last item (with others)", + spec: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g", + Kind: "k", + Names: []string{"a"}, + }, { + Group: "g2", + Kind: "k2", + Names: []string{"a"}, + }}}, + item: starItem{ + group: "g", + kind: "k", + name: "a", + }, + remove: true, + expect: &StarsSpec{ + Resource: []StarsResource{{ + Group: "g2", + Kind: "k2", + Names: []string{"a"}, + }}}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.expect == nil { + tt.expect = tt.spec.DeepCopy() + } + + if tt.remove { + tt.spec.Remove(tt.item.group, tt.item.kind, tt.item.name) + } else { + tt.spec.Add(tt.item.group, tt.item.kind, tt.item.name) + } + + require.Equal(t, tt.expect, tt.spec) + }) + } + }) + + t.Run("changes", func(t *testing.T) { + tests := []struct { + name string + current []string + target []string + added []string + removed []string + same []string + }{{ + name: "same", + current: []string{"a"}, + target: []string{"a"}, + same: []string{"a"}, + }, { + name: "adding one", + current: []string{"a"}, + target: []string{"a", "b"}, + same: []string{"a"}, + added: []string{"b"}, + }, { + name: "removing one", + current: []string{"a", "b"}, + target: []string{"a"}, + same: []string{"a"}, + removed: []string{"b"}, + }, { + name: "removed to empty", + current: []string{"a"}, + target: []string{}, + removed: []string{"a"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a, r, s := Changes(tt.current, tt.target) + require.Equal(t, tt.added, a, "added") + require.Equal(t, tt.removed, r, "removed") + require.Equal(t, tt.same, s, "same") + }) + } + }) +} diff --git a/apps/scope/go.mod b/apps/scope/go.mod new file mode 100644 index 00000000000..435e401adba --- /dev/null +++ b/apps/scope/go.mod @@ -0,0 +1,42 @@ +module github.com/grafana/grafana/apps/scope + +go 1.24.6 + +require ( + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a + k8s.io/apimachinery v0.34.1 + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect +) diff --git a/apps/scope/go.sum b/apps/scope/go.sum new file mode 100644 index 00000000000..25bae6f532a --- /dev/null +++ b/apps/scope/go.sum @@ -0,0 +1,118 @@ +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/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-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/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/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a h1:L7xgV9mP6MRF3L2/vDOjNR7heaBPbXPMGTDN9/jXSFQ= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a/go.mod h1:OK8NwS87D5YphchOcAsiIWk/feMZ0EzfAGME1Kff860= +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/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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/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/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/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/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.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +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/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/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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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/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= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/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.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/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/scope/pkg/apis/scope/v0alpha1/doc.go b/apps/scope/pkg/apis/scope/v0alpha1/doc.go new file mode 100644 index 00000000000..5012479c82d --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=scope.grafana.app + +package v0alpha1 // import "github.com/grafana/grafana/apps/pkg/apis/scope/v0alpha1" diff --git a/apps/scope/pkg/apis/scope/v0alpha1/register.go b/apps/scope/pkg/apis/scope/v0alpha1/register.go new file mode 100644 index 00000000000..881416421c4 --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/register.go @@ -0,0 +1,168 @@ +package v0alpha1 + +import ( + "fmt" + "time" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + GROUP = "scope.grafana.app" + VERSION = "v0alpha1" + APIVERSION = GROUP + "/" + VERSION +) + +var ScopeResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "scopes", "scope", "Scope", + func() runtime.Object { return &Scope{} }, + func() runtime.Object { return &ScopeList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + {Name: "Title", Type: "string"}, + {Name: "Filters", Type: "array"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*Scope) + if !ok { + return nil, fmt.Errorf("expected scope") + } + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + m.Spec.Title, + m.Spec.Filters, + }, nil + }, + }, // default table converter +) + +var ScopeDashboardBindingResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "scopedashboardbindings", "scopedashboardbinding", "ScopeDashboardBinding", + func() runtime.Object { return &ScopeDashboardBinding{} }, + func() runtime.Object { return &ScopeDashboardBindingList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + {Name: "Dashboard", Type: "string"}, + {Name: "Scope", Type: "string"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*ScopeDashboardBinding) + if !ok { + return nil, fmt.Errorf("expected scope dashboard binding") + } + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + m.Spec.Dashboard, + m.Spec.Scope, + }, nil + }, + }, +) + +var ScopeNavigationResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "scopenavigations", "scopenavigation", "ScopeNavigation", + func() runtime.Object { return &ScopeNavigation{} }, + func() runtime.Object { return &ScopeNavigationList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + {Name: "URL", Type: "string"}, + {Name: "Scope", Type: "string"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*ScopeNavigation) + if !ok { + return nil, fmt.Errorf("expected scope navigation") + } + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + m.Spec.URL, + m.Spec.Scope, + }, nil + }, + }, +) + +var ScopeNodeResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "scopenodes", "scopenode", "ScopeNode", + func() runtime.Object { return &ScopeNode{} }, + func() runtime.Object { return &ScopeNodeList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + {Name: "Title", Type: "string"}, + {Name: "Parent Name", Type: "string"}, + {Name: "Node Type", Type: "string"}, + {Name: "Link Type", Type: "string"}, + {Name: "Link ID", Type: "string"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*ScopeNode) + if !ok { + return nil, fmt.Errorf("expected scope node") + } + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + m.Spec.Title, + m.Spec.ParentName, + m.Spec.NodeType, + m.Spec.LinkType, + m.Spec.LinkID, + }, nil + }, + }, // default table converter +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} + InternalGroupVersion = schema.GroupVersion{Group: GROUP, Version: runtime.APIVersionInternal} + + // SchemaBuilder is used by standard codegen + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + localSchemeBuilder.Register(func(s *runtime.Scheme) error { + return AddKnownTypes(SchemeGroupVersion, s) + }) +} + +// Adds the list of known types to the given scheme. +func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { + scheme.AddKnownTypes(gv, + &Scope{}, + &ScopeList{}, + &ScopeDashboardBinding{}, + &ScopeDashboardBindingList{}, + &ScopeNode{}, + &ScopeNodeList{}, + &FindScopeNodeChildrenResults{}, + &FindScopeDashboardBindingsResults{}, + &ScopeNavigation{}, + &ScopeNavigationList{}, + &FindScopeNavigationsResults{}, + ) + //metav1.AddToGroupVersion(scheme, gv) + return nil +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/types.go b/apps/scope/pkg/apis/scope/v0alpha1/types.go new file mode 100644 index 00000000000..fcd7812c8da --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/types.go @@ -0,0 +1,238 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +/* +Please keep pkg/promlib/models/query.go and pkg/promlib/models/scope.go in sync +with this file until this package is out of the grafana/grafana module. +*/ + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Scope struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScopeSpec `json:"spec,omitempty"` +} + +type ScopeSpec struct { + Title string `json:"title"` + // Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. + // This will override whichever is selected from in the selector. + // The path is a list of node ids, starting at the direct parent of the selected node towards the root. + // +listType=atomic + DefaultPath []string `json:"defaultPath,omitempty"` + + // +listType=atomic + Filters []ScopeFilter `json:"filters,omitempty"` +} + +type ScopeFilter struct { + Key string `json:"key"` + Value string `json:"value"` + // Values is used for operators that require multiple values (e.g. one-of and not-one-of). + // +listType=atomic + Values []string `json:"values,omitempty"` + Operator FilterOperator `json:"operator"` +} + +// Type of the filter operator. +// +enum +type FilterOperator string + +// Defines values for FilterOperator. +const ( + FilterOperatorEquals FilterOperator = "equals" + FilterOperatorNotEquals FilterOperator = "not-equals" + FilterOperatorRegexMatch FilterOperator = "regex-match" + FilterOperatorRegexNotMatch FilterOperator = "regex-not-match" + FilterOperatorOneOf FilterOperator = "one-of" + FilterOperatorNotOneOf FilterOperator = "not-one-of" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Scope `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeDashboardBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScopeDashboardBindingSpec `json:"spec,omitempty"` + Status ScopeDashboardBindingStatus `json:"status,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeDashboardBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ScopeDashboardBinding `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FindScopeDashboardBindingsResults struct { + metav1.TypeMeta `json:",inline"` + + Items []ScopeDashboardBinding `json:"items,omitempty"` + Message string `json:"message,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeNode struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScopeNodeSpec `json:"spec,omitempty"` +} + +type ScopeDashboardBindingSpec struct { + Dashboard string `json:"dashboard"` + Scope string `json:"scope"` +} + +// Type of the item. +// +enum +// ScopeDashboardBindingStatus contains derived information about a ScopeDashboardBinding. +type ScopeDashboardBindingStatus struct { + // DashboardTitle should be populated and update from the dashboard + DashboardTitle string `json:"dashboardTitle"` + + // Groups is used for the grouping of dashboards that are suggested based + // on a scope. The source of truth for this information has not been + // determined yet. + Groups []string `json:"groups,omitempty"` + + // DashboardTitleConditions is a list of conditions that are used to determine if the dashboard title is valid. + // +optional + // +listType=map + // +listMapKey=type + DashboardTitleConditions []metav1.Condition `json:"dashboardTitleConditions,omitempty"` + + // DashboardTitleConditions is a list of conditions that are used to determine if the list of groups is valid. + // +optional + // +listType=map + // +listMapKey=type + GroupsConditions []metav1.Condition `json:"groupsConditions,omitempty"` +} + +type NodeType string + +// Defines values for ItemType. +const ( + NodeTypeContainer NodeType = "container" + NodeTypeLeaf NodeType = "leaf" +) + +// Type of the item. +// +enum +type LinkType string + +// Defines values for ItemType. +const ( + LinkTypeScope LinkType = "scope" +) + +type ScopeNodeSpec struct { + //+optional + ParentName string `json:"parentName,omitempty"` + + NodeType NodeType `json:"nodeType"` // container | leaf + + Title string `json:"title"` + Description string `json:"description,omitempty"` + DisableMultiSelect bool `json:"disableMultiSelect"` + + LinkType LinkType `json:"linkType,omitempty"` // scope (later more things) + LinkID string `json:"linkId,omitempty"` // the k8s name + // ?? should this be a slice of links +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeNodeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ScopeNode `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FindScopeNodeChildrenResults struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ScopeNode `json:"items,omitempty"` +} + +// Scoped navigation types + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeNavigation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScopeNavigationSpec `json:"spec,omitempty"` + Status ScopeNavigationStatus `json:"status,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ScopeNavigationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ScopeNavigation `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FindScopeNavigationsResults struct { + metav1.TypeMeta `json:",inline"` + + Items []ScopeNavigation `json:"items,omitempty"` + Message string `json:"message,omitempty"` +} + +type ScopeNavigationSpec struct { + URL string `json:"url"` + Scope string `json:"scope"` +} + +// Type of the item. +// +enum +// ScopeNavigationStatus contains derived information about a ScopeNavigation. +type ScopeNavigationStatus struct { + // Title should be populated and update from the dashboard + Title string `json:"title"` + + // Groups is used for the grouping of dashboards that are suggested based + // on a scope. The source of truth for this information has not been + // determined yet. + Groups []string `json:"groups,omitempty"` + + // TitleConditions is a list of conditions that are used to determine if the title is valid. + // +optional + // +listType=map + // +listMapKey=type + TitleConditions []metav1.Condition `json:"titleConditions,omitempty"` + + // GroupsConditions is a list of conditions that are used to determine if the list of groups is valid. + // +optional + // +listType=map + // +listMapKey=type + GroupsConditions []metav1.Condition `json:"groupsConditions,omitempty"` +} + +// Type of the filter operator. +// +enum +type ScopeNavigationLinkType string + +// Defines values for FilterOperator. +const ( + ScopeNavigationLinkTypeURL ScopeNavigationLinkType = "url" +) diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..a2be794e5bb --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,519 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FindScopeDashboardBindingsResults) DeepCopyInto(out *FindScopeDashboardBindingsResults) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeDashboardBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindScopeDashboardBindingsResults. +func (in *FindScopeDashboardBindingsResults) DeepCopy() *FindScopeDashboardBindingsResults { + if in == nil { + return nil + } + out := new(FindScopeDashboardBindingsResults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FindScopeDashboardBindingsResults) 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 *FindScopeNavigationsResults) DeepCopyInto(out *FindScopeNavigationsResults) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeNavigation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindScopeNavigationsResults. +func (in *FindScopeNavigationsResults) DeepCopy() *FindScopeNavigationsResults { + if in == nil { + return nil + } + out := new(FindScopeNavigationsResults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FindScopeNavigationsResults) 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 *FindScopeNodeChildrenResults) DeepCopyInto(out *FindScopeNodeChildrenResults) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeNode, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindScopeNodeChildrenResults. +func (in *FindScopeNodeChildrenResults) DeepCopy() *FindScopeNodeChildrenResults { + if in == nil { + return nil + } + out := new(FindScopeNodeChildrenResults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FindScopeNodeChildrenResults) 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 *Scope) DeepCopyInto(out *Scope) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Scope. +func (in *Scope) DeepCopy() *Scope { + if in == nil { + return nil + } + out := new(Scope) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Scope) 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 *ScopeDashboardBinding) DeepCopyInto(out *ScopeDashboardBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBinding. +func (in *ScopeDashboardBinding) DeepCopy() *ScopeDashboardBinding { + if in == nil { + return nil + } + out := new(ScopeDashboardBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeDashboardBinding) 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 *ScopeDashboardBindingList) DeepCopyInto(out *ScopeDashboardBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeDashboardBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBindingList. +func (in *ScopeDashboardBindingList) DeepCopy() *ScopeDashboardBindingList { + if in == nil { + return nil + } + out := new(ScopeDashboardBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeDashboardBindingList) 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 *ScopeDashboardBindingSpec) DeepCopyInto(out *ScopeDashboardBindingSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBindingSpec. +func (in *ScopeDashboardBindingSpec) DeepCopy() *ScopeDashboardBindingSpec { + if in == nil { + return nil + } + out := new(ScopeDashboardBindingSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeDashboardBindingStatus) DeepCopyInto(out *ScopeDashboardBindingStatus) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DashboardTitleConditions != nil { + in, out := &in.DashboardTitleConditions, &out.DashboardTitleConditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.GroupsConditions != nil { + in, out := &in.GroupsConditions, &out.GroupsConditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBindingStatus. +func (in *ScopeDashboardBindingStatus) DeepCopy() *ScopeDashboardBindingStatus { + if in == nil { + return nil + } + out := new(ScopeDashboardBindingStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeFilter) DeepCopyInto(out *ScopeFilter) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeFilter. +func (in *ScopeFilter) DeepCopy() *ScopeFilter { + if in == nil { + return nil + } + out := new(ScopeFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeList) DeepCopyInto(out *ScopeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Scope, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeList. +func (in *ScopeList) DeepCopy() *ScopeList { + if in == nil { + return nil + } + out := new(ScopeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeList) 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 *ScopeNavigation) DeepCopyInto(out *ScopeNavigation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigation. +func (in *ScopeNavigation) DeepCopy() *ScopeNavigation { + if in == nil { + return nil + } + out := new(ScopeNavigation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeNavigation) 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 *ScopeNavigationList) DeepCopyInto(out *ScopeNavigationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeNavigation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigationList. +func (in *ScopeNavigationList) DeepCopy() *ScopeNavigationList { + if in == nil { + return nil + } + out := new(ScopeNavigationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeNavigationList) 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 *ScopeNavigationSpec) DeepCopyInto(out *ScopeNavigationSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigationSpec. +func (in *ScopeNavigationSpec) DeepCopy() *ScopeNavigationSpec { + if in == nil { + return nil + } + out := new(ScopeNavigationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeNavigationStatus) DeepCopyInto(out *ScopeNavigationStatus) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TitleConditions != nil { + in, out := &in.TitleConditions, &out.TitleConditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.GroupsConditions != nil { + in, out := &in.GroupsConditions, &out.GroupsConditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigationStatus. +func (in *ScopeNavigationStatus) DeepCopy() *ScopeNavigationStatus { + if in == nil { + return nil + } + out := new(ScopeNavigationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeNode) DeepCopyInto(out *ScopeNode) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNode. +func (in *ScopeNode) DeepCopy() *ScopeNode { + if in == nil { + return nil + } + out := new(ScopeNode) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeNode) 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 *ScopeNodeList) DeepCopyInto(out *ScopeNodeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScopeNode, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNodeList. +func (in *ScopeNodeList) DeepCopy() *ScopeNodeList { + if in == nil { + return nil + } + out := new(ScopeNodeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScopeNodeList) 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 *ScopeNodeSpec) DeepCopyInto(out *ScopeNodeSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNodeSpec. +func (in *ScopeNodeSpec) DeepCopy() *ScopeNodeSpec { + if in == nil { + return nil + } + out := new(ScopeNodeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScopeSpec) DeepCopyInto(out *ScopeSpec) { + *out = *in + if in.DefaultPath != nil { + in, out := &in.DefaultPath, &out.DefaultPath + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]ScopeFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeSpec. +func (in *ScopeSpec) DeepCopy() *ScopeSpec { + if in == nil { + return nil + } + out := new(ScopeSpec) + in.DeepCopyInto(out) + return out +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.defaults.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.defaults.go new file mode 100644 index 00000000000..238fc2f4edc --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go new file mode 100644 index 00000000000..3f31343d82a --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,934 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. 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/scope/pkg/apis/scope/v0alpha1.FindScopeDashboardBindingsResults": schema_pkg_apis_scope_v0alpha1_FindScopeDashboardBindingsResults(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.FindScopeNavigationsResults": schema_pkg_apis_scope_v0alpha1_FindScopeNavigationsResults(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.FindScopeNodeChildrenResults": schema_pkg_apis_scope_v0alpha1_FindScopeNodeChildrenResults(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.Scope": schema_pkg_apis_scope_v0alpha1_Scope(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBinding(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingList": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingList(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingSpec": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingSpec(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingStatus": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingStatus(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeFilter": schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeList": schema_pkg_apis_scope_v0alpha1_ScopeList(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation": schema_pkg_apis_scope_v0alpha1_ScopeNavigation(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationList": schema_pkg_apis_scope_v0alpha1_ScopeNavigationList(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationSpec": schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationStatus": schema_pkg_apis_scope_v0alpha1_ScopeNavigationStatus(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode": schema_pkg_apis_scope_v0alpha1_ScopeNode(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeList": schema_pkg_apis_scope_v0alpha1_ScopeNodeList(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeSpec": schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref), + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeSpec": schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref), + } +} + +func schema_pkg_apis_scope_v0alpha1_FindScopeDashboardBindingsResults(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: "", + }, + }, + "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/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding"), + }, + }, + }, + }, + }, + "message": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_FindScopeNavigationsResults(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: "", + }, + }, + "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/scope/pkg/apis/scope/v0alpha1.ScopeNavigation"), + }, + }, + }, + }, + }, + "message": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_FindScopeNodeChildrenResults(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/scope/pkg/apis/scope/v0alpha1.ScopeNode"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_Scope(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{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBinding(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{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingSpec"), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingStatus"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingSpec", "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingList(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/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "dashboard": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "scope": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"dashboard", "scope"}, + }, + }, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Type of the item. ScopeDashboardBindingStatus contains derived information about a ScopeDashboardBinding.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "dashboardTitle": { + SchemaProps: spec.SchemaProps{ + Description: "DashboardTitle should be populated and update from the dashboard", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "groups": { + SchemaProps: spec.SchemaProps{ + Description: "Groups is used for the grouping of dashboards that are suggested based on a scope. The source of truth for this information has not been determined yet.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "dashboardTitleConditions": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "type", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "DashboardTitleConditions is a list of conditions that are used to determine if the dashboard title is valid.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"), + }, + }, + }, + }, + }, + "groupsConditions": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "type", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "DashboardTitleConditions is a list of conditions that are used to determine if the list of groups is valid.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"), + }, + }, + }, + }, + }, + }, + Required: []string{"dashboardTitle"}, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "value": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "values": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Values is used for operators that require multiple values (e.g. one-of and not-one-of).", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "operator": { + SchemaProps: spec.SchemaProps{ + Description: "Possible enum values:\n - `\"equals\"`\n - `\"not-equals\"`\n - `\"not-one-of\"`\n - `\"one-of\"`\n - `\"regex-match\"`\n - `\"regex-not-match\"`", + Default: "", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"equals", "not-equals", "not-one-of", "one-of", "regex-match", "regex-not-match"}, + }, + }, + }, + Required: []string{"key", "value", "operator"}, + }, + }, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeList(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/scope/pkg/apis/scope/v0alpha1.Scope"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.Scope", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNavigation(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{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationSpec"), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationStatus"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationSpec", "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNavigationList(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/scope/pkg/apis/scope/v0alpha1.ScopeNavigation"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "url": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "scope": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"url", "scope"}, + }, + }, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNavigationStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Type of the item. ScopeNavigationStatus contains derived information about a ScopeNavigation.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Description: "Title should be populated and update from the dashboard", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "groups": { + SchemaProps: spec.SchemaProps{ + Description: "Groups is used for the grouping of dashboards that are suggested based on a scope. The source of truth for this information has not been determined yet.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "titleConditions": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "type", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "TitleConditions is a list of conditions that are used to determine if the title is valid.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"), + }, + }, + }, + }, + }, + "groupsConditions": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "type", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "GroupsConditions is a list of conditions that are used to determine if the list of groups is valid.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"), + }, + }, + }, + }, + }, + }, + Required: []string{"title"}, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNode(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{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeSpec"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNodeList(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/scope/pkg/apis/scope/v0alpha1.ScopeNode"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "parentName": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "nodeType": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "disableMultiSelect": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "linkType": { + SchemaProps: spec.SchemaProps{ + Description: "Possible enum values:\n - `\"scope\"`", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"scope"}, + }, + }, + "linkId": { + SchemaProps: spec.SchemaProps{ + Description: "scope (later more things)", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"nodeType", "title", "disableMultiSelect"}, + }, + }, + } +} + +func schema_pkg_apis_scope_v0alpha1_ScopeSpec(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{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "defaultPath": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. This will override whichever is selected from in the selector. The path is a list of node ids, starting at the direct parent of the selected node towards the root.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "filters": { + 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/scope/pkg/apis/scope/v0alpha1.ScopeFilter"), + }, + }, + }, + }, + }, + }, + Required: []string{"title"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeFilter"}, + } +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..c2130851da8 --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,10 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,FindScopeDashboardBindingsResults,Items +API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,FindScopeNavigationsResults,Items +API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeDashboardBindingStatus,Groups +API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNavigationStatus,Groups +API rule violation: names_match,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNodeSpec,LinkID +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,FindScopeNodeChildrenResults,Items +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeDashboardBindingList,Items +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeList,Items +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNavigationList,Items +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNodeList,Items diff --git a/contribute/feature-toggles.md b/contribute/feature-toggles.md index 8e81c9c0c11..1e6b1b1b885 100644 --- a/contribute/feature-toggles.md +++ b/contribute/feature-toggles.md @@ -14,7 +14,30 @@ Once your feature toggle is defined, you can then wrap your feature around a che Examples: - [Backend](https://github.com/grafana/grafana/blob/feb2b5878b3e3ec551d64872c35edec2a0187812/pkg/services/authn/clients/session.go#L57): Use the `IsEnabled` function and pass in your feature toggle. -- [Frontend](https://github.com/grafana/grafana/blob/feb2b5878b3e3ec551d64872c35edec2a0187812/public/app/features/search/service/folders.ts#L14): Check the config for your feature toggle. + +### Frontend + +Use the new OpenFeature-based feature flag client for all new feature flags. There are some differences compared to the legacy `config.featureToggles` system: + +- Feature flag initialisation is async, but will be finished by the time the UI is rendered. This means you cannot get the value of a feature flag at the 'top level' of a module/file +- Call `evaluateBooleanFlag("flagName")` from `@grafana/runtime/internal` instead to get the value of a feature flag +- Feature flag values _may_ change over the lifetime of the session. Do not store the value in a variable that is used for longer than a single render - always call `evaluateBooleanFlag` lazily when you use the value. + +e.g. + +```ts +import { evaluateBooleanFlag } from '@grafana/runtime/internal'; + +// BAD - Don't do this. The feature toggle will not evaluate correctly +const isEnabled = evaluateBooleanFlag('newPreferences', false); + +function makeAPICall() { + // GOOD - The feature toggle should be called after app initialisation + if (evaluateBooleanFlag('newPreferences', false)) { + // do new things + } +} +``` ## Enabling toggles in development diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/index.md index afe5cac14da..8dde070b315 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/index.md @@ -174,7 +174,7 @@ resource "grafana_role" "my_new_role" { description = "My test role" version = 1 uid = "newroleuid" - global = true + global = false permissions { action = "org.users:add" diff --git a/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md index 5518ef51146..9ea6df4e47e 100644 --- a/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md +++ b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md @@ -156,7 +156,7 @@ On this screen, you can see: - The earliest time a user has been active in a dashboard - When they last accessed a shared dashboard -- The dashboards to they have access +- The dashboards they have access to - Their role You can also revoke a user's access to all shared dashboards on from this tab. diff --git a/docs/sources/datasources/prometheus/configure/aws-authentication.md b/docs/sources/datasources/prometheus/configure/aws-authentication.md new file mode 100644 index 00000000000..3740fcd3ee3 --- /dev/null +++ b/docs/sources/datasources/prometheus/configure/aws-authentication.md @@ -0,0 +1,347 @@ +--- +aliases: + - ../data-sources/prometheus/ + - ../features/datasources/prometheus/ +description: Guide for authenticating with Amazon Managed Service for Prometheus in Grafana +keywords: + - grafana + - prometheus + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Authenticating with SigV4 +title: Configure the Prometheus data source +weight: 200 +refs: + intro-to-prometheus: + - pattern: /docs/grafana/ + destination: /docs/grafana//fundamentals/intro-to-prometheus/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//fundamentals/intro-to-prometheus/ + exemplars: + - pattern: /docs/grafana/ + destination: /docs/grafana//fundamentals/exemplars/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//fundamentals/exemplars/ + configure-data-links-value-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/configure-data-links/#value-variables + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//panels-visualizations/configure-data-links/#value-variables + alerting-alert-rules: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/ + add-a-data-source: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/#add-a-data-source + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/#add-a-data-source + prom-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/prometheus/query-editor + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/prometheus/query-editor + default-manage-alerts-ui-toggle: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + provision-grafana: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/provisioning/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/provisioning/ + manage-alerts-toggle: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + manage-recording-rules-toggle: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_allow_recording_rules_target_alerts_ui_toggle + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_allow_recording_rules_target_alerts_ui_toggle + private-data-source-connect: + - pattern: /docs/grafana/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + configure-pdc: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + azure-active-directory: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/#configure-azure-active-directory-ad-authentication + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/#configure-azure-active-directory-ad-authentication + configure-grafana-configuration-file-location: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + grafana-managed-recording-rules: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules/ +--- + +# Connect to Amazon Managed Service for Prometheus + +1. In the data source configuration page, locate the **Auth** section +2. Enable **SigV4 auth** +3. Configure the following settings: + + | Setting | Description | Example | + | --------------------------- | ---------------------------------------------- | --------------------------------------------------------------- | + | **Authentication Provider** | Choose your auth method | `AWS SDK Default`, `Access & secret key`, or `Credentials file` | + | **Default Region** | AWS region for your workspace | `us-west-2` | + | **Access Key ID** | Your AWS access key (if using access key auth) | `AKIA...` | + | **Secret Access Key** | Your AWS secret key (if using access key auth) | `wJalrXUtn...` | + | **Assume Role ARN** | IAM role ARN (optional) | `arn:aws:iam::123456789:role/GrafanaRole` | + +4. Set the **HTTP URL** to your Amazon Managed Service for Prometheus workspace endpoint: `https://aps-workspaces.us-west-2.amazonaws.com/workspaces/ws-12345678-1234-1234-1234-123456789012/` + +5. Click **Save & test** to verify the connection + +## Example configuration + +```yaml +# Example provisioning configuration +apiVersion: 1 +datasources: + - name: 'Amazon Managed Prometheus' + type: 'grafana-amazonprometheus-datasource' + url: 'https://aps-workspaces.us-west-2.amazonaws.com/workspaces/ws-12345678-1234-1234-1234-123456789012/' + jsonData: + httpMethod: 'POST' + sigV4Auth: true + sigV4AuthType: 'keys' + sigV4Region: 'us-east-2' + secureJsonData: + sigV4AccessKey: '' + sigV4SecretKey: '' +``` + +## Migrate to Amazon Managed Service for Prometheus + +Learn more about why this is happening: [Prometheus data source update: Redefining our big tent philosophy](https://grafana.com/blog/2025/06/16/prometheus-data-source-update-redefining-our-big-tent-philosophy/) + +Before you begin, ensure you have the organization administrator role. If you are self-hosting Grafana, back up your existing dashboard configurations and queries. + +Grafana Cloud users will be automatically migrated to the relevant version of Prometheus, so no action needs to be taken. + +For air-gapped environments, download and install [Amazon Managed Service for Prometheus](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/), then follow the standard migration process. + +### Migrate + +1. Enable the `prometheusTypeMigration` feature toggle. For more information on feature toggles, refer to [Manage feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/#manage-feature-toggles). +2. Restart Grafana for the changes to take effect. + +{{< admonition type="note" >}} +This feature toggle will be removed in Grafana 13, and the migration will be automatic. +{{< /admonition >}} + +### Check migration status + +To determine if your Prometheus data sources have been migrated: + +1. Navigate to **Connections** > **Data sources** +2. Select your Prometheus data source +3. Look for a migration banner at the top of the configuration page + +The banner displays one of the following messages: + +- **"Migration Notice"** - The data source has already been migrated +- **"Deprecation Notice"** - The data source has not been migrated +- **No banner** - No migration is needed for this data source + +## Common migration issues + +The following sections contain troubleshooting guidance. + +**Migration banner not appearing** + +- Verify the `prometheusTypeMigration` feature toggle is enabled +- Restart Grafana after enabling the feature toggle + +**Amazon Managed Service for Prometheus is not installed** + +- Verify that Amazon Managed Service for Prometheus is installed by going to **Connections** > **Add new connection** and search for "Amazon Managed Service for Prometheus" +- Install Amazon Managed Service for Prometheus if not already installed + +**After migrating, my data source returns "401 Unauthorized"** + +- If you are using self-hosted Grafana, check your .ini for `grafana-amazonprometheus-datasource` is included in `forward_settings_to_plugins` under the `[aws]` heading. +- If you are using Grafana Cloud, contact Grafana support. + +### Rollback self-hosted Grafana without a backup + +If you don’t have a backup of your Grafana instance before the migration, remove the `prometheusTypeMigration` feature toggle, and run the following script. It reverts all Amazon Managed Service for Prometheus data sources back to core Prometheus. + +To revert the migration: + +1. Disable the `prometheusTypeMigration` feature toggle. For more information on feature toggles, refer to [Manage feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/#manage-feature-toggles). +2. Obtain a bearer token that has `read` and `write` permissions for your Grafana data source API. For more information on the data source API, refer to [Data source API](/docs/grafana//developers/http_api/data_source/). +3. Run the script below. Make sure to provide your Grafana URL and bearer token. +4. (Optional) Report the issue you were experiencing on the [Grafana repository](https://github.com/grafana/grafana/issues). Tag the issue with "datasource/migrate-prometheus-type" + +```bash +#!/bin/bash + +# Configuration +GRAFANA_URL="" +BEARER_TOKEN="" +LOG_FILE="grafana_migration_$(date +%Y%m%d_%H%M%S).log" + +# Function to log messages to both console and file +log_message() { + local message="$1" + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$timestamp] $message" | tee -a "$LOG_FILE" +} + +# Function to update a data source +update_data_source() { + local uid="$1" + local data="$2" + + response=$(curl -s -w "\n%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "$data" \ + "$GRAFANA_URL/api/datasources/uid/$uid") + + http_code=$(echo "$response" | tail -n1) + response_body=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + log_message "$uid successful" + else + log_message "$uid error: HTTP $http_code - $response_body" + fi +} + +# Function to process and update data source types +update_data_source_type() { + local result="$1" + local processed_count=0 + local updated_count=0 + local readonly_count=0 + local skipped_count=0 + + # Use jq to parse and process JSON + echo "$result" | jq -c '.[]' | while read -r data; do + uid=$(echo "$data" | jq -r '.uid') + prometheus_type_migration=$(echo "$data" | jq -r '.jsonData["prometheus-type-migration"] // false') + data_type=$(echo "$data" | jq -r '.type') + read_only=$(echo "$data" | jq -r '.readOnly // false') + + processed_count=$((processed_count + 1)) + + # Check conditions + if [[ "$prometheus_type_migration" != "true" ]] || [[ "$data_type" != "grafana-amazonprometheus-datasource" ]]; then + skipped_count=$((skipped_count + 1)) + continue + fi + + if [[ "$read_only" == "true" ]]; then + readonly_count=$((readonly_count + 1)) + log_message "$uid is readOnly. If this data source is provisioned, edit the data source type to be \`prometheus\` in the provisioning file." + continue + fi + + # Update the data + updated_data=$(echo "$data" | jq '.type = "prometheus" | .jsonData["prometheus-type-migration"] = false') + update_data_source "$uid" "$updated_data" + updated_count=$((updated_count + 1)) + + # Log the raw data for debugging (optional - uncomment if needed) + # log_message "DEBUG - Updated data for $uid: $updated_data" + done + + # Note: These counts won't work in the while loop due to subshell + # Moving summary to the main function instead +} + +# Function to get summary statistics +get_summary_stats() { + local result="$1" + local total_datasources=$(echo "$result" | jq '. | length') + local migration_candidates=$(echo "$result" | jq '[.[] | select(.jsonData["prometheus-type-migration"] == true and .type == "grafana-amazonprometheus-datasource")] | length') + local readonly_candidates=$(echo "$result" | jq '[.[] | select(.jsonData["prometheus-type-migration"] == true and .type == "grafana-amazonprometheus-datasource" and .readOnly == true)] | length') + local updateable_candidates=$(echo "$result" | jq '[.[] | select(.jsonData["prometheus-type-migration"] == true and .type == "grafana-amazonprometheus-datasource" and (.readOnly == false or .readOnly == null))] | length') + + log_message "=== MIGRATION SUMMARY ===" + log_message "Total data sources found: $total_datasources" + log_message "Migration candidates found: $migration_candidates" + log_message "Read-only candidates (will be skipped): $readonly_candidates" + log_message "Updateable candidates: $updateable_candidates" + log_message "==========================" +} + +# Main function to remove Prometheus type migration +remove_prometheus_type_migration() { + log_message "Starting remove Azure Prometheus migration" + log_message "Log file: $LOG_FILE" + log_message "Grafana URL: $GRAFANA_URL" + + response=$(curl -s -w "\n%{http_code}" -X GET \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + "$GRAFANA_URL/api/datasources/") + + http_code=$(echo "$response" | tail -n1) + response_body=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + log_message "Successfully fetched data sources" + get_summary_stats "$response_body" + update_data_source_type "$response_body" + log_message "Migration process completed" + else + log_message "error fetching data sources: HTTP $http_code - $response_body" + fi +} + +# Function to initialize log file +initialize_log() { + echo "=== Grafana Azure Prometheus Migration Log ===" > "$LOG_FILE" + echo "Started at: $(date)" >> "$LOG_FILE" + echo "=============================================" >> "$LOG_FILE" + echo "" >> "$LOG_FILE" +} + +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Please install jq to run this script." + exit 1 +fi + +# Check if required variables are set +if [[ -z "$GRAFANA_URL" || -z "$BEARER_TOKEN" ]]; then + echo "Error: Please set GRAFANA_URL and BEARER_TOKEN variables at the top of the script." + exit 1 +fi + +# Initialize log file +initialize_log + +# Execute main function +log_message "Script started" +remove_prometheus_type_migration +log_message "Script completed" + +# Final log message +echo "" +echo "Migration completed. Full log available at: $LOG_FILE" +``` + +If you continue to experience issues, check the Grafana server logs for detailed error messages and contact [Grafana Support](https://grafana.com/help/) with your troubleshooting results. diff --git a/docs/sources/datasources/prometheus/configure/azure-authentication.md b/docs/sources/datasources/prometheus/configure/azure-authentication.md new file mode 100644 index 00000000000..6333a160330 --- /dev/null +++ b/docs/sources/datasources/prometheus/configure/azure-authentication.md @@ -0,0 +1,359 @@ +--- +aliases: + - ../data-sources/prometheus/ + - ../features/datasources/prometheus/ +description: Guide for authenticating with Azure Monitor Managed Service for Prometheus in Grafana +keywords: + - grafana + - prometheus + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Authenticating with Azure +title: Configure the Prometheus data source +weight: 200 +refs: + intro-to-prometheus: + - pattern: /docs/grafana/ + destination: /docs/grafana//fundamentals/intro-to-prometheus/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//fundamentals/intro-to-prometheus/ + exemplars: + - pattern: /docs/grafana/ + destination: /docs/grafana//fundamentals/exemplars/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//fundamentals/exemplars/ + configure-data-links-value-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/configure-data-links/#value-variables + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//panels-visualizations/configure-data-links/#value-variables + alerting-alert-rules: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/ + add-a-data-source: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/#add-a-data-source + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/#add-a-data-source + prom-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/prometheus/query-editor + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/prometheus/query-editor + default-manage-alerts-ui-toggle: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + provision-grafana: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/provisioning/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/provisioning/ + manage-alerts-toggle: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_manage_alerts_ui_toggle + manage-recording-rules-toggle: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_allow_recording_rules_target_alerts_ui_toggle + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#default_allow_recording_rules_target_alerts_ui_toggle + private-data-source-connect: + - pattern: /docs/grafana/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + configure-pdc: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + azure-active-directory: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/#configure-azure-active-directory-ad-authentication + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/#configure-azure-active-directory-ad-authentication + configure-grafana-configuration-file-location: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + grafana-managed-recording-rules: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules/ +--- + +# Connect to Azure Monitor Managed Service for Prometheus + +After creating a Azure Monitor Managed Service for Prometheus data source: + +1. In the data source configuration page, locate the **Authentication** section +2. Select your authentication method: + - **Managed Identity**: For Azure-hosted Grafana instances. To learn more about Entra login for Grafana, refer to [Configure Azure AD/Entra ID OAuth authentication](/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread/#configure-azure-adentra-id-oauth-authentication) + - **App Registration**: For service principal authentication + - **Current User**: Uses the current user's Azure AD credentials + +3. Configure based on your chosen method: + +| Setting | Description | Example | +| --------------------------- | ------------------------------- | -------------------------------------- | +| **Directory (tenant) ID** | Your Azure AD tenant ID | `12345678-1234-1234-1234-123456789012` | +| **Application (client) ID** | Your app registration client ID | `87654321-4321-4321-4321-210987654321` | +| **Client secret** | Your app registration secret | `your-client-secret` | + +When using Managed Identity for authentication: + +- No additional configuration required if using system-assigned identity. +- For user-assigned identity, provide the **Client ID**. + +4. Set the **Prometheus server URL** to your Azure Monitor workspace endpoint: + + ``` + https://your-workspace.eastus2.prometheus.monitor.azure.com + ``` + +5. Click **Save & test** to verify the connection + +## Example configuration + +```yaml +# Example provisioning configuration for App Registration +apiVersion: 1 +datasources: + - name: 'Azure Monitor Prometheus' + type: 'grafana-azureprometheus-datasource' + url: 'https://your-workspace.eastus2.prometheus.monitor.azure.com' + jsonData: + azureCredentials: + authType: 'clientsecret' + azureCloud: 'AzureCloud' + clientId: '' + httpMethod: 'POST' + tenantId: '' + secureJsonData: + clientSecret: 'your-client-secret' +``` + +## Migrate to Azure Monitor Managed Service for Prometheus + +Learn more about why this is happening: [Prometheus data source update: Redefining our big tent philosophy](https://grafana.com/blog/2025/06/16/prometheus-data-source-update-redefining-our-big-tent-philosophy/) + +Before you begin, ensure you have the organization administrator role. If you are self-hosting Grafana, back up your existing dashboard configurations and queries. + +Grafana Cloud users will be automatically migrated to the relevant version of Prometheus, so no action needs to be taken. + +For air-gapped environments, download and install [Azure Monitor Managed Service for Prometheus](https://grafana.com/grafana/plugins/grafana-azureprometheus-datasource/), then follow the standard migration process. + +### Migrate + +1. Enable the `prometheusTypeMigration` feature toggle. For more information on feature toggles, refer to [Manage feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/#manage-feature-toggles). +2. Restart Grafana for the changes to take effect. + +{{< admonition type="note" >}} +This feature toggle will be removed in Grafana 13, and the migration will be automatic. +{{< /admonition >}} + +To determine if your Prometheus data sources have been migrated: + +1. Navigate to **Connections** > **Data sources** +2. Select your Prometheus data source +3. Look for a migration banner at the top of the configuration page + +The banner displays one of the following messages: + +- **"Migration Notice"** - The data source has already been migrated +- **"Deprecation Notice"** - The data source has not been migrated +- **No banner** - No migration is needed for this data source + +## Common migration issues + +The following sections contain troubleshooting guidance. + +**Migration banner not appearing** + +- Verify the `prometheusTypeMigration` feature toggle is enabled. +- Restart Grafana after enabling the feature toggle + +**Azure Monitor Managed Service for Prometheus is not installed** + +- Verify that Azure Monitor Managed Service for Prometheus is installed by going to **Connections** > **Add new connection** and search for "Azure Monitor Managed Service for Prometheus" +- Install Azure Monitor Managed Service for Prometheus if not already installed + +**After migrating, my data source returns "401 Unauthorized"** + +- If you are using self-hosted Grafana, check your .ini for `grafana-azureprometheus-datasource` is included in `forward_settings_to_plugins` under the `[azure]` heading. +- If you are using Grafana Cloud, contact Grafana support. + +### Rollback self-hosted Grafana without a backup + +If you don’t have a backup of your Grafana instance before the migration, remove the `prometheusTypeMigration` feature toggle, and run the following script. It reverts all the Azure Monitor Managed Service data source instances back to core Prometheus. + +To revert the migration: + +1. Disable the `prometheusTypeMigration` feature toggle. For more information on feature toggles, refer to [Manage feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/#manage-feature-toggles). +2. Obtain a bearer token that has `read` and `write` permissions for your Grafana data source API. For more information on the data source API, refer to [Data source API](/docs/grafana//developers/http_api/data_source/). +3. Run the script below. Make sure to provide your Grafana URL and bearer token. +4. (Optional) Report the issue you were experiencing on the [Grafana repository](https://github.com/grafana/grafana/issues). Tag the issue with "datasource/migrate-prometheus-type" + +```bash +#!/bin/bash + +# Configuration +GRAFANA_URL="" +BEARER_TOKEN="" +LOG_FILE="grafana_migration_$(date +%Y%m%d_%H%M%S).log" + +# Function to log messages to both console and file +log_message() { + local message="$1" + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$timestamp] $message" | tee -a "$LOG_FILE" +} + +# Function to update a data source +update_data_source() { + local uid="$1" + local data="$2" + + response=$(curl -s -w "\n%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -d "$data" \ + "$GRAFANA_URL/api/datasources/uid/$uid") + + http_code=$(echo "$response" | tail -n1) + response_body=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + log_message "$uid successful" + else + log_message "$uid error: HTTP $http_code - $response_body" + fi +} + +# Function to process and update data source types +update_data_source_type() { + local result="$1" + local processed_count=0 + local updated_count=0 + local readonly_count=0 + local skipped_count=0 + + # Use jq to parse and process JSON + echo "$result" | jq -c '.[]' | while read -r data; do + uid=$(echo "$data" | jq -r '.uid') + prometheus_type_migration=$(echo "$data" | jq -r '.jsonData["prometheus-type-migration"] // false') + data_type=$(echo "$data" | jq -r '.type') + read_only=$(echo "$data" | jq -r '.readOnly // false') + + processed_count=$((processed_count + 1)) + + # Check conditions + if [[ "$prometheus_type_migration" != "true" ]] || [[ "$data_type" != "grafana-azureprometheus-datasource" ]]; then + skipped_count=$((skipped_count + 1)) + continue + fi + + if [[ "$read_only" == "true" ]]; then + readonly_count=$((readonly_count + 1)) + log_message "$uid is readOnly. If this data source is provisioned, edit the data source type to be \`prometheus\` in the provisioning file." + continue + fi + + # Update the data + updated_data=$(echo "$data" | jq '.type = "prometheus" | .jsonData["prometheus-type-migration"] = false') + update_data_source "$uid" "$updated_data" + updated_count=$((updated_count + 1)) + + # Log the raw data for debugging (optional - uncomment if needed) + # log_message "DEBUG - Updated data for $uid: $updated_data" + done + + # Note: These counts won't work in the while loop due to subshell + # Moving summary to the main function instead +} + +# Function to get summary statistics +get_summary_stats() { + local result="$1" + local total_datasources=$(echo "$result" | jq '. | length') + local migration_candidates=$(echo "$result" | jq '[.[] | select(.jsonData["prometheus-type-migration"] == true and .type == "grafana-azureprometheus-datasource")] | length') + local readonly_candidates=$(echo "$result" | jq '[.[] | select(.jsonData["prometheus-type-migration"] == true and .type == "grafana-azureprometheus-datasource" and .readOnly == true)] | length') + local updateable_candidates=$(echo "$result" | jq '[.[] | select(.jsonData["prometheus-type-migration"] == true and .type == "grafana-azureprometheus-datasource" and (.readOnly == false or .readOnly == null))] | length') + + log_message "=== MIGRATION SUMMARY ===" + log_message "Total data sources found: $total_datasources" + log_message "Migration candidates found: $migration_candidates" + log_message "Read-only candidates (will be skipped): $readonly_candidates" + log_message "Updateable candidates: $updateable_candidates" + log_message "==========================" +} + +# Main function to remove Prometheus type migration +remove_prometheus_type_migration() { + log_message "Starting remove Azure Prometheus migration" + log_message "Log file: $LOG_FILE" + log_message "Grafana URL: $GRAFANA_URL" + + response=$(curl -s -w "\n%{http_code}" -X GET \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + "$GRAFANA_URL/api/datasources/") + + http_code=$(echo "$response" | tail -n1) + response_body=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + log_message "Successfully fetched data sources" + get_summary_stats "$response_body" + update_data_source_type "$response_body" + log_message "Migration process completed" + else + log_message "error fetching data sources: HTTP $http_code - $response_body" + fi +} + +# Function to initialize log file +initialize_log() { + echo "=== Grafana Azure Prometheus Migration Log ===" > "$LOG_FILE" + echo "Started at: $(date)" >> "$LOG_FILE" + echo "=============================================" >> "$LOG_FILE" + echo "" >> "$LOG_FILE" +} + +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Please install jq to run this script." + exit 1 +fi + +# Check if required variables are set +if [[ -z "$GRAFANA_URL" || -z "$BEARER_TOKEN" ]]; then + echo "Error: Please set GRAFANA_URL and BEARER_TOKEN variables at the top of the script." + exit 1 +fi + +# Initialize log file +initialize_log + +# Execute main function +log_message "Script started" +remove_prometheus_type_migration +log_message "Script completed" + +# Final log message +echo "" +echo "Migration completed. Full log available at: $LOG_FILE" +``` + +If you continue to experience issues, check the Grafana server logs for detailed error messages and contact [Grafana Support](https://grafana.com/help/) with your troubleshooting results. diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 3645aed2ef4..8cb3954f13d 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -688,7 +688,7 @@ }, "languages": { "type": "array", - "description": "The list of languages supported by the plugin. Each entry should be a locale identifier in the format `language-COUNTRY` (for example `en-US`, `fr-FR`, `es-ES`).", + "description": "The list of languages supported by the plugin. Each entry should be a locale identifier in the format `language-COUNTRY` (for example `en-US`, `es-ES`, `de-DE`).", "items": { "type": "string" } diff --git a/docs/sources/fundamentals/exemplars/index.md b/docs/sources/fundamentals/exemplars/index.md index 8b556d928d6..15e3bc1a50f 100644 --- a/docs/sources/fundamentals/exemplars/index.md +++ b/docs/sources/fundamentals/exemplars/index.md @@ -34,7 +34,7 @@ After you localize the latency problem to a few exemplar traces, you can combine Support for exemplars is available for the Prometheus data source only. After you enable the functionality, exemplar data is available by default. -For more information on exemplar configuration and how to enable exemplars, refer to [configuring exemplars in the Prometheus data source](../../datasources/prometheus/configure-prometheus-data-source/#exemplars). +For more information on exemplar configuration and how to enable exemplars, refer to the Exemplars section in [Prometheus configuration options](https://grafana.com/docs/grafana/latest/datasources/prometheus/configure/#configuration-options). Grafana shows exemplars alongside a metric in the Explore view and in dashboards. Each exemplar displays as a highlighted star. diff --git a/docs/sources/observability-as-code/get-started.md b/docs/sources/observability-as-code/get-started.md index ef9822aae5c..52aa16e4f66 100644 --- a/docs/sources/observability-as-code/get-started.md +++ b/docs/sources/observability-as-code/get-started.md @@ -37,7 +37,7 @@ For an integrated, UI-driven Git workflow focused on dashboards, explore Git Syn - Connect folders or entire Grafana instances directly to a GitHub repository to synchronize dashboard definitions, enabling version control, branching, and pull requests directly from Grafana. - Git Sync offers a simple, out-of-the-box approach for managing dashboards as code. {{< admonition type="note" >}} - Git Sync is an **experimental feature** in Grafana 12, available in Grafana OSS and Enterprise [nightly releases](https://grafana.com/grafana/download/nightly). It is not yet available in Grafana Cloud. + Git Sync is available in **private preview** for Grafana Cloud, and it's an **experimental feature** in Grafana 12, available in Grafana OSS and Enterprise [nightly releases](https://grafana.com/grafana/download/nightly). {{< /admonition >}} Refer to the [Git Sync documentation](https://grafana.com/docs/grafana//observability-as-code/provision-resources/intro-git-sync/) to learn more. diff --git a/docs/sources/observability-as-code/provision-resources/_index.md b/docs/sources/observability-as-code/provision-resources/_index.md index d920f6d5009..c8e6b313eec 100644 --- a/docs/sources/observability-as-code/provision-resources/_index.md +++ b/docs/sources/observability-as-code/provision-resources/_index.md @@ -18,16 +18,18 @@ weight: 300 # Provision resources and sync dashboards {{< admonition type="caution" >}} -Provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. -Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). +Git Sync is available in [private preview](https://grafana.com/docs/release-life-cycle/) for Grafana Cloud. Support and documentation is available but might be limited to enablement, configuration, and some troubleshooting. No SLAs are provided. You can sign up to the private preview using the [Git Sync early access form](https://forms.gle/WKkR3EVMcbqsNnkD9). + +Git Sync and local file provisioning are [experimental features](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. + {{< /admonition >}} -Provisioning is an experimental feature that allows you to configure how to store your dashboard JSONs and other files in GitHub repositories using either Git Sync or a local path. +Provisioning allows you to configure how to store your dashboard JSON and other files in GitHub repositories using either Git Sync or a local path. -Of the two options, **Git Sync** is the favorited method for provisioning your dashboards. You can synchronize any new dashboards and changes to existing dashboards from the UI to your configured GitHub repository. If you push a change in the repository, those changes are mirrored in your Grafana instance. See [Git Sync workflow](#git-sync-workflow). +Of the two options, **Git Sync** is the favorited method for provisioning your dashboards. You can synchronize any new dashboards and changes to existing dashboards from the UI to your configured GitHub repository. If you push a change in the repository, those changes are mirrored in your Grafana instance. Refer to [Git Sync workflow](#git-sync-workflow) for more information. -Alternatively, **local file provisioning** allows you to include in your Grafana instance resources (such as folders and dashboard JSON files) that are stored in a local file system. See [Local file workflow](local-file-workflow). +Alternatively, **local file provisioning** allows you to include in your Grafana instance resources (such as folders and dashboard JSON files) that are stored in a local file system. Refer to [Local file workflow](#local-file-workflow) for more information. ## Provisioned folders and connections @@ -40,8 +42,7 @@ You can set a single folder, or multiple folders to a different repository, with In the Git Sync workflow: - When you provision resources with Git Sync you can modify them from within the Grafana UI or within the GitHub repository. Changes made in either the repository or the Grafana UI are bidirectional. -- Any changes made in the provisioned files stored in the GitHub repository are reflected in the Grafana database. By default, Grafana polls GitHub every 60 seconds. -- The Grafana UI reads from the database and updates the UI to reflect these changes. +- Any changes made in the provisioned files stored in the GitHub repository are reflected in the Grafana database. By default, Grafana polls GitHub every 60 seconds. The Grafana UI reads from the database and updates the UI to reflect these changes. For example, if you update a dashboard within the Grafana UI and click **Save** to preserve the changes, you'll be notified that the dashboard is provisioned in a GitHub repository. Next you'll be prompted to choose how to preserve the changes: either directly to a branch, or pushed to a new branch using a pull request in GitHub. @@ -52,8 +53,7 @@ For more information, see [Introduction to Git Sync](https://grafana.com/docs/gr In the local file workflow: - All provisioned resources are changed in the local files. -- Any changes made in the provisioned files are reflected in the Grafana database. -- The Grafana UI reads the database and updates the UI to reflect these changes. +- Any changes made in the provisioned files are reflected in the Grafana database. The Grafana UI reads the database and updates the UI to reflect these changes. - You can't use the Grafana UI to edit or delete provisioned resources. Learn more in [Set up file provisioning](https://grafana.com/docs/grafana//observability-as-code/provision-resources/file-path-setup/). diff --git a/docs/sources/observability-as-code/provision-resources/file-path-setup.md b/docs/sources/observability-as-code/provision-resources/file-path-setup.md index 1832e8ad001..98fd256e594 100644 --- a/docs/sources/observability-as-code/provision-resources/file-path-setup.md +++ b/docs/sources/observability-as-code/provision-resources/file-path-setup.md @@ -16,9 +16,8 @@ weight: 200 # Set up file provisioning {{< admonition type="caution" >}} -Local file provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. -Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). +Local file provisioning is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions, but it's **not available in Grafana Cloud**. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. {{< /admonition >}} @@ -48,10 +47,14 @@ Refer to [Provision Grafana](https://grafana.com/docs/grafana// ### Limitations - A provisioned dashboard can't be deleted from within Grafana UI. The dashboard has to be deleted at the local file system and those changes synced to Grafana. -- Changes from the local file system are one way: you can't save changes from the UI to GitHub. +- Changes from the local file system are one way: you can't save changes from the Grafana UI to GitHub. ## Before you begin +{{< admonition type="note" >}} +Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. +{{< /admonition >}} + To set up file provisioning, you need: - Administration rights in your Grafana organization. @@ -122,15 +125,15 @@ The set up process verifies the path and provides an error message if a problem ### Choose what to synchronize -In this section, you determine the actions taken with the storage you selected. +Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections). -1. Select how resources should be handled in Grafana. +- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to. -- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. You can only have one provisioned connection with this selection. -- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 folders. - Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. - +- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections. -1. Select **Synchronize** to continue. +Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. + +Click **Synchronize** to continue. ### Synchronize with external storage diff --git a/docs/sources/observability-as-code/provision-resources/git-sync-setup.md b/docs/sources/observability-as-code/provision-resources/git-sync-setup.md index b0c44727ffd..fbe43aa7a57 100644 --- a/docs/sources/observability-as-code/provision-resources/git-sync-setup.md +++ b/docs/sources/observability-as-code/provision-resources/git-sync-setup.md @@ -16,50 +16,60 @@ weight: 100 # Set up Git Sync {{< admonition type="caution" >}} -Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. -Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). +Git Sync is available in [private preview](https://grafana.com/docs/release-life-cycle/) for Grafana Cloud, and is an [experimental feature](https://grafana.com/docs/release-life-cycle/) in Grafana v12 for open source and Enterprise editions. + +Support and documentation is available but might be limited to enablement, configuration, and some troubleshooting. No SLAs are provided. + +You can sign up to the private preview using the [Git Sync early access form](https://forms.gle/WKkR3EVMcbqsNnkD9). {{< /admonition >}} -Git Sync lets you manage Grafana dashboards as code by storing dashboards JSON files and folders in a remote GitHub repository. -Alternatively, you can configure a local file system instead of using GitHub. -Refer to [Set up file provisioning](https://grafana.com/docs/grafana//observability-as-code/provision-resources/file-path-setup/) for information. +Git Sync lets you manage Grafana dashboards as code by storing dashboard JSON files and folders in a remote GitHub repository. -This page explains how to use Git Sync with a GitHub repository. +To set up Git Sync and synchronize with a GitHub repository follow these steps: -To set up Git Sync, you need to: +1. [Enable feature toggles in Grafana](#enable-required-feature-toggles) (first time set up). +1. [Create a GitHub access token](#create-a-github-access-token). +1. [Configure a connection to your GitHub repository](#set-up-the-connection-to-github). +1. [Choose what content to sync with Grafana](#choose-what-to-synchronize). -1. Enable feature toggles in Grafana (first time set up). -1. Configure a connection to your GitHub repository. -1. Choose what content to sync with Grafana. -1. Optional: Extend Git Sync by enabling pull request notifications and image previews of dashboard changes. +Optionally, you can [extend Git Sync](#configure-webhooks-and-image-rendering) by enabling pull request notifications and image previews of dashboard changes. -| Capability | Benefit | Requires | -| ----------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------- | -| Adds a table summarizing changes to your pull request | Provides a convenient way to save changes back to GitHub. | Webhooks configured | -| Add a dashboard preview image to a PR | View a snapshot of dashboard changes to a pull request without opening Grafana. | Image renderer plugin and webhooks configured | +| Capability | Benefit | Requires | +| ----------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | +| Adds a table summarizing changes to your pull request | Provides a convenient way to save changes back to GitHub. | Webhooks configured | +| Add a dashboard preview image to a PR | View a snapshot of dashboard changes to a pull request without opening Grafana. | Image renderer and webhooks configured | + +{{< admonition type="note" >}} + +Alternatively, you can configure a local file system instead of using GitHub. Refer to [Set up file provisioning](https://grafana.com/docs/grafana//observability-as-code/provision-resources/file-path-setup/) for more information. + +{{< /admonition >}} ## Performance impacts of enabling Git Sync -Git Sync is an experimental feature and is under continuous development. +Git Sync is an experimental feature and is under continuous development. Reporting any issues you encounter can help us improve Git Sync. -We recommend evaluating the performance impact, if any, in a non-production environment. - -When Git Sync is enabled, the database load might increase, especially for instances with a lot of folders and nested folders. -Reporting any issues you encounter can help us improve Git Sync. +When Git Sync is enabled, the database load might increase, especially for instances with a lot of folders and nested folders. Evaluate the performance impact, if any, in a non-production environment. ## Before you begin +{{< admonition type="caution" >}} + +Refer to [Known limitations](https://grafana.com/docs/grafana//observability-as-code/provision-resources/intro-git-sync#known-limitations/) before using Git Sync. + +{{< /admonition >}} + To set up Git Sync, you need: - Administration rights in your Grafana organization. - Enable the required feature toggles in your Grafana instance. Refer to [Enable required feature toggles](#enable-required-feature-toggles) for instructions. - A GitHub repository to store your dashboards in. - If you want to use a local file path, refer to [the local file path guide](https://grafana.com/docs/grafana//observability-as-code/provision-resources/file-path-setup/). -- A GitHub access token. The Grafana UI will also explain this to you as you set it up. +- A GitHub access token. The Grafana UI will prompt you during setup. - Optional: A public Grafana instance. -- Optional: Image Renderer plugin to save image previews with your PRs. +- Optional: The [Image Renderer service](https://github.com/grafana/grafana-image-renderer) to save image previews with your PRs. ## Enable required feature toggles @@ -118,28 +128,25 @@ To connect your GitHub repository, follow these steps: ### Choose what to synchronize -You can choose to either use one repository for an entire organization or to a new Grafana folder (up to 10 connections). -If you choose to sync all resources with external storage, then all of your dashboards are synced to that one repository. -You won't have the option of setting up additional repositories to connect to. +{{< admonition type="caution" >}} -You can choose to synchronize all resources with GitHub or you can sync resources to a new Grafana folder. -The options you have depend on the status of your GitHub repository. -For example, if you are syncing with a new or empty repository, you won't have an option to migrate dashboards. +If you're using Git Sync in Grafana Cloud you can only sync specific folders for the moment. Git Sync will be available for your full instance soon. -1. Select how resources should be handled in Grafana. +{{< /admonition >}} -- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. You can only have one provisioned connection with this selection. -- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections. - Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. - +In this step you can decide which elements to synchronize. Keep in mind the available options depend on the status of your GitHub repository. The first time you connect Grafana with a GitHub repository, you need to synchronize with external storage. If you are syncing with a new or empty repository, you won't have an option to migrate dashboards. -1. Select **Synchronize** to continue. +1. Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections). + +- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to. + +- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections. + +1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. +1. Click **Synchronize** to continue. - -1. Optional: If you have the Grafana Image Renderer plugin configured, you can **Enable dashboards previews in pull requests**. If image rendering is not available, then you can't select this option. For more information, refer to [Grafana Image Renderer](https://grafana.com/grafana/plugins/grafana-image-renderer/). +1. Optional: If you have the Grafana Image Renderer plugin configured, you can **Enable dashboards previews in pull requests**. If image rendering is not available, then you can't select this option. For more information, refer to the [Image Renderer service](https://github.com/grafana/grafana-image-renderer). 1. Select **Finish** to proceed. ## Verify your dashboards in Grafana To verify that your dashboards are available at the location that you specified, click **Dashboards**. The name of the dashboard is listed in the **Name** column. -Now that your dashboards have been synced from a repository, you can customize the name, change the branch, and create a pull request (PR) for it. -Refer to [Use Git Sync](https://grafana.com/docs/grafana//observability-as-code/provision-resources/use-git-sync/) for more information. +Now that your dashboards have been synced from a repository, you can customize the name, change the branch, and create a pull request (PR) for it. Refer to [Manage provisioned repositories with Git Sync](https://grafana.com/docs/grafana//observability-as-code/provision-resources/use-git-sync/) for more information. ## Configure webhooks and image rendering @@ -214,8 +220,7 @@ The necessary paths required to be exposed are (RegExp): By setting up image rendering, you can add visual previews of dashboard updates directly in pull requests. Image rendering also requires webhooks. -You can enable this capability by installing the Grafana Image Renderer plugin in your Grafana instance. -For more information and installation instructions, refer to [Grafana Image Renderer](https://grafana.com/grafana/plugins/grafana-image-renderer/). +You can enable this capability by installing the Grafana Image Renderer in your Grafana instance. For more information and installation instructions, refer to the [Image Renderer service](https://github.com/grafana/grafana-image-renderer). ## Modify configurations after set up is complete diff --git a/docs/sources/observability-as-code/provision-resources/intro-git-sync.md b/docs/sources/observability-as-code/provision-resources/intro-git-sync.md index 42bc9691264..d99fdbdca6e 100644 --- a/docs/sources/observability-as-code/provision-resources/intro-git-sync.md +++ b/docs/sources/observability-as-code/provision-resources/intro-git-sync.md @@ -16,67 +16,86 @@ weight: 100 # Introduction to Git Sync {{< admonition type="caution" >}} -Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. -Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). +Git Sync is available in [private preview](https://grafana.com/docs/release-life-cycle/) for Grafana Cloud, and is an [experimental feature](https://grafana.com/docs/release-life-cycle/) in Grafana v12 for open source and Enterprise editions. + +Support and documentation is available but might be limited to enablement, configuration, and some troubleshooting. No SLAs are provided. + +You can sign up to the private preview using the [Git Sync early access form](https://forms.gle/WKkR3EVMcbqsNnkD9). {{< /admonition >}} +Git Sync in Grafana lets you manage your dashboards as code as JSON files stored in GitHub. You and your team can version control, collaborate, and automate deployments efficiently. + Using Git Sync, you can: +- Manage dashboard configuration outside of Grafana instances using Git - Introduce a review process for creating and modifying dashboards -- Manage dashboard configuration outside of Grafana instances - Replicate dashboards across multiple instances -Whenever a dashboard is modified, Grafana can commit changes to Git upon saving. Users can configure settings to either enforce PR approvals before merging or allow direct commits. - -Users can push changes directly to GitHub and see them in Grafana. Similarly, automated workflows can do changes that will be automatically represented in Grafana by updating Git. - -Because the dashboards are defined in JSON files, you can enable as-code workflows where the JSON is output from Go, TypeScript, or another coding language in the format of a dashboard schema. - -To learn more about creating dashboards in a coding language to provision them for Git Sync, refer to the [Foundation SDK](https://grafana.com/docs/grafana//observability-as-code/foundation-sdk) documentation. - ## How it works -Git Sync is bidirectional and also works with changes done directly in GitHub as well as within the Grafana UI. -Grafana periodically polls GitHub at a regular internal to synchronize any changes. -With the webhooks feature enabled, repository notifications appear almost immediately. -Without webhooks, Grafana polls for changes at the specified interval. -The default polling interval is 60 seconds. +Git Sync is bidirectional and works both with changes done directly in GitHub as well as in the Grafana UI. -Any changes made in the provisioned files stored in the GitHub repository are reflected in the Grafana database. -The Grafana UI reads the database and updates the UI to reflect these changes. +### Make changes in Grafana + +Whenever you modify a dashboard directly from the UI, Grafana can commit changes to Git upon saving. You can configure settings to either enforce PR approvals before merging in your repository, or allow direct commits. + +Grafana periodically polls GitHub at a regular internal to synchronize any changes. The default polling interval is 60 seconds, and you can change this setting in the Grafana UI. + +- If you enable the [webhooks feature](https://grafana.com/docs/grafana//observability-as-code/provision-resources/git-sync-setup/#configure-webhooks-and-image-rendering), repository notifications appear almost immediately. +- Without webhooks, Grafana polls for changes at the specified interval. + +### Make changes in your GitHub repositories + +With Git Sync, you can make changes in your provisioned files in GitHub and see them in Grafana. Automated workflows ensure those changes are automatically represented in the Grafana database by updating Git. The Grafana UI reads the database and updates the UI to reflect these changes. + +## Known limitations + +Git Sync is under development and the following limitations apply: + +- You can only authenticate in GitHub using your Personal Access Token token. +- Support for native Git, Git app, and other providers, such as GitLab or Bitbucket, is on the roadmap. +- If you're using Git Sync in Grafana Cloud you can only sync specific folders for the moment. Git Sync will be available for your full instance soon. +- Restoring resources from the UI is currently not possible. As an alternative, you can restore dashboards directly in your GitHub repository by raising a PR, and they will be updated in Grafana. ## Common use cases -Git Sync in Grafana lets you manage dashboards as code. -Because your dashboard JSON files are stored in GitHub, you and your team can version control, collaborate, and automate deployments efficiently. +You can use Git Sync in the following scenarios. ### Version control and auditing -Organizations can maintain a structured, version-controlled history of Grafana dashboards. -The version control lets you revert to previous versions when necessary, compare modifications across commits, and ensure transparency in dashboard management. +Organizations can maintain a structured, version-controlled history of Grafana dashboards. The version control lets you revert to previous versions when necessary, compare modifications across commits, and ensure transparency in dashboard management. + Additionally, having a detailed history of changes enhances compliance efforts, as teams can generate audit logs that document who made changes, when they were made, and why. ### Automated deployment and CI/CD integration -Teams can streamline their workflow by integrating dashboard updates into their CI/CD pipelines. -By pushing changes to GitHub, automated processes can trigger validation checks, test dashboard configurations, and deploy updates programmatically using the `grafanactl` CLI and Foundation SDK. +Teams can streamline their workflow by integrating dashboard updates into their CI/CD pipelines. By pushing changes to GitHub, automated processes can trigger validation checks, test dashboard configurations, and deploy updates programmatically using the `grafanactl` CLI and Foundation SDK. + This reduces the risk of human errors, ensures consistency across environments, and enables a faster, more reliable release cycle for dashboards used in production monitoring and analytics. ### Collaborative dashboard development With Git Sync, multiple users can work on dashboards simultaneously without overwriting each other’s modifications. -By leveraging pull requests and branch-based workflows, teams can submit changes for review before merging them into the main branch. This process not only improves quality control but also ensures that dashboards adhere to best practices and organizational standards. Additionally, GitHub’s built-in discussion and review tools facilitate effective collaboration, making it easier to address feedback before changes go live. +By leveraging pull requests and branch-based workflows, teams can submit changes for review before merging them into the main branch. This process not only improves quality control but also ensures that dashboards adhere to best practices and organizational standards. + +Additionally, GitHub’s built-in discussion and review tools facilitate effective collaboration, making it easier to address feedback before changes go live. ### Multi-environment synchronization -Enterprises managing multiple Grafana instances, such as development, staging, and production environments, can seamlessly sync dashboards across these instances. -This ensures consistency in visualization and monitoring configurations, reducing discrepancies that might arise from manually managing dashboards in different environments. +Enterprises managing multiple Grafana instances, such as development, staging, and production environments, can seamlessly sync dashboards across these instances. This ensures consistency in visualization and monitoring configurations, reducing discrepancies that might arise from manually managing dashboards in different environments. + By using Git Sync, teams can automate deployments across environments, eliminating repetitive setup tasks and maintaining a standardized monitoring infrastructure across the organization. ### Disaster recovery and backup By continuously syncing dashboards to GitHub, organizations can create an always-updated backup, ensuring dashboards are never lost due to accidental deletion or system failures. -If an issue arises--such as a corrupted dashboard, unintended modification, or a system crash--teams can quickly restore the latest functional version from the Git repository. -This not only minimizes downtime but also adds a layer of resilience to Grafana monitoring setups, ensuring critical dashboards remain available when needed. + +If an issue arises, such as a corrupted dashboard, unintended modification, or a system crash, teams can quickly restore the latest functional version from the Git repository. This not only minimizes downtime but also adds a layer of resilience to Grafana monitoring setups, ensuring critical dashboards remain available when needed. + +## Provision dashboards as code + +Because dashboards are defined in JSON files, you can enable as-code workflows where the JSON file is an output from Go, TypeScript, or another coding language in the format of a dashboard schema. + +To learn more about creating dashboards in a coding language to provision them for Git Sync, refer to the [Foundation SDK](https://grafana.com/docs/grafana//observability-as-code/foundation-sdk) documentation. diff --git a/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md b/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md index e92bb0800dc..fbe08fd3389 100644 --- a/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md +++ b/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md @@ -16,9 +16,10 @@ weight: 300 # Work with provisioned dashboards {{< admonition type="caution" >}} -Git Sync and File path provisioning an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana. These features aren't available publicly in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. -Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). +Git Sync is available in [private preview](https://grafana.com/docs/release-life-cycle/) for Grafana Cloud. Support and documentation is available but might be limited to enablement, configuration, and some troubleshooting. No SLAs are provided. You can sign up to the private preview using the [Git Sync early access form](https://forms.gle/WKkR3EVMcbqsNnkD9). + +Git Sync and local file provisioning are [experimental features](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. {{< /admonition >}} @@ -30,16 +31,17 @@ For more information, refer to the [Dashboards](https://grafana.com/docs/grafana Dashboards and folders synchronized using Git Sync or a local file path are referred to as "provisioned" resources. -Of the two experimental options, Git Sync is the recommended method for provisioning your dashboards. +### Git Sync provisioning + +Of the two experimental options, **Git Sync** is the recommended method for provisioning your dashboards. You can synchronize any new dashboards and changes to existing dashboards to your configured GitHub repository. If you push a change in the repository, those changes are mirrored in your Grafana instance. -For more information on configuring Git Sync, refer to [Set up Git Sync](https://grafana.com/docs/grafana//observability-as-code/provision-resources/intro-git-sync/). + +For more information on configuring Git Sync, refer to [Introduction to Git Sync](https://grafana.com/docs/grafana//observability-as-code/provision-resources/intro-git-sync/). ### Local path provisioning -Using the local path provisioning makes files from a specified path available within Grafana. -These provisioned resources can only be modified in the local files and not within Grafana. -Any changes made in the configured local path are updated in Grafana. +Local path provisioning makes files from a specified path available within Grafana, and any changes made in the configured local path are updated in Grafana. Note that these provisioned resources can only be modified in the local files and not within Grafana. Refer to [Set up file provisioning](https://grafana.com/docs/grafana//observability-as-code/provision-resources/file-path-setup) to learn more about the version of local file provisioning in Grafana 12. @@ -114,9 +116,9 @@ Saving changes requires opening a pull request in your GitHub repository. ### Remove dashboards -You can remove a provisioned dashboard by deleting the dashboard from the repository. +You can remove a provisioned dashboard by deleting the dashboard from the repository. The Grafana UI updates when the changes from the GitHub repository sync. -Grafana updates when the changes from the GitHub repository sync. +To restore a deleted dashboard, raise a PR directly in your GitHub repository. Restoring resources from the UI is currently not possible. ### Tips @@ -128,9 +130,6 @@ Grafana updates when the changes from the GitHub repository sync. ## Manage dashboards provisioned with file provisioning To update any resources in the local path, you need to edit the files directly and then save them locally. -These changes are synchronized to Grafana. -However, you can't create, edit, or delete these resources using the Grafana UI. - -For more information, refer to [How it works](https://grafana.com/docs/grafana//observability-as-code/provision-resources/). +These changes are synchronized to Grafana. However, you can't create, edit, or delete these resources using the Grafana UI. Refer to [Set up file provisioning](https://grafana.com/docs/grafana//observability-as-code/provision-resources/file-path-setup/) for configuration instructions. diff --git a/docs/sources/observability-as-code/provision-resources/use-git-sync.md b/docs/sources/observability-as-code/provision-resources/use-git-sync.md index e8d323e578c..11751314865 100644 --- a/docs/sources/observability-as-code/provision-resources/use-git-sync.md +++ b/docs/sources/observability-as-code/provision-resources/use-git-sync.md @@ -19,20 +19,22 @@ weight: 400 # Manage provisioned repositories with Git Sync {{< admonition type="caution" >}} -Git Sync is an [experimental feature](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature. This feature is not publicly available in Grafana Cloud yet. Only the cloud-hosted version of GitHub (GitHub.com) is supported at this time. GitHub Enterprise is not yet compatible. -Sign up for Grafana Cloud Git Sync early access using [this form](https://forms.gle/WKkR3EVMcbqsNnkD9). +Git Sync is available in [private preview](https://grafana.com/docs/release-life-cycle/) for Grafana Cloud, and is an [experimental feature](https://grafana.com/docs/release-life-cycle/) in Grafana v12 for open source and Enterprise editions. + +Support and documentation is available but might be limited to enablement, configuration, and some troubleshooting. No SLAs are provided. + +You can sign up to the private preview using the [Git Sync early access form](https://forms.gle/WKkR3EVMcbqsNnkD9). {{< /admonition >}} -After you have set up Git Sync, you can synchronize any changes in your existing dashboards with your configured GitHub repository. Similarly, if you push a change in the repository, those changes are mirrored in your Grafana instance. +After you have set up Git Sync, you can synchronize any changes you make in your existing provisioned folders in the UI with your configured GitHub repository. Similarly, if you push a change into your repository, those changes are mirrored in your Grafana instance. ## View current status of synchronization -Each repository synchronized with Git Sync has a dashboard that provides a summary of resources, health, pull status, webhook, sync jobs, resources, and files. -Use the detailed information accessed in **View** to help troubleshoot and understand the health of your repository's connection with Grafana. +When you synchronize a repository, Git Sync also creates a dashboard that provides a summary of resources, health, pull status, webhook, sync jobs, resources, and files. -To view the current status, follow these steps. +Use the **View** section in **Provisioning** to see detailed information about the current status of your sync, understand the health of your repository's connection with Grafana, and [troubleshoot](#troubleshoot-synchronization) possible issues: 1. Log in to your Grafana server with an account that has the Grafana Admin or Editor flag set. 1. Select **Administration** in the left-side menu and then **Provisioning**. @@ -44,7 +46,7 @@ To view the current status, follow these steps. Synchronizing resources from provisioned repositories into your Grafana instance pulls the resources into the selected folder. Existing dashboards with the same `uid` are overwritten. -To sync changes from your dashboards with your Git repository: +To sync changes from your Grafana dashboards with your Git repository: 1. From the left menu, select **Administration** > **Provisioning**. 1. Select **Pull** under the repository you want to sync. @@ -64,6 +66,12 @@ Refer to [Work with provisioned dashboards](../provisioned-dashboards) for infor ## Troubleshoot synchronization +{{< admonition type="caution" >}} + +Before you proceed to troubleshoot, understand the [known limitations](https://grafana.com/docs/grafana//observability-as-code/provision-resources/intro-git-sync#known-limitations/). + +{{< /admonition >}} + Monitor the **View** status page for synchronization issues and status updates. Common events include: - Sync started diff --git a/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts b/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts index 65e37aefbed..ad5008a0be1 100644 --- a/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/group-by-cujs.spec.ts @@ -71,9 +71,12 @@ test.describe( await test.step('3.Edit and restore default groupBy', async () => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); + // Wait for the page to load + const groupByVariable = getGroupByInput(dashboardPage, selectors); + await expect(groupByVariable).toBeVisible(); + const initialSelectedOptionsCount = await groupByValues.count(); - const groupByVariable = getGroupByInput(dashboardPage, selectors); await groupByVariable.click(); const groupByOption = groupByOptions.nth(1); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 227edc2fd71..c69363ff210 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1856,11 +1856,6 @@ "count": 1 } }, - "public/app/features/commandPalette/actions/recentScopesActions.ts": { - "react-hooks/rules-of-hooks": { - "count": 1 - } - }, "public/app/features/commandPalette/actions/scopeActions.tsx": { "react-hooks/rules-of-hooks": { "count": 4 @@ -3383,11 +3378,6 @@ "count": 1 } }, - "public/app/features/transformers/editors/GroupByTransformerEditor.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx": { "no-restricted-syntax": { "count": 1 @@ -4552,11 +4542,6 @@ "count": 1 } }, - "public/app/plugins/panel/logs/types.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/plugins/panel/nodeGraph/Edge.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/go.mod b/go.mod index e00dc78ec9c..1a66ddb70d2 100644 --- a/go.mod +++ b/go.mod @@ -246,6 +246,7 @@ require ( github.com/grafana/grafana/apps/plugins v0.0.0 // @grafana/plugins-platform-backend github.com/grafana/grafana/apps/preferences v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/provisioning v0.0.0 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/apps/scope v0.0.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana/apps/secret v0.0.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana/apps/shorturl v0.0.0 // @grafana/sharing-squad github.com/grafana/grafana/pkg/aggregator v0.0.0 // @grafana/grafana-app-platform-squad @@ -274,6 +275,7 @@ replace ( github.com/grafana/grafana/apps/plugins => ./apps/plugins github.com/grafana/grafana/apps/preferences => ./apps/preferences github.com/grafana/grafana/apps/provisioning => ./apps/provisioning + github.com/grafana/grafana/apps/scope => ./apps/scope github.com/grafana/grafana/apps/secret => ./apps/secret github.com/grafana/grafana/apps/shorturl => ./apps/shorturl diff --git a/go.work b/go.work index 2daa0eff86c..efba709a41b 100644 --- a/go.work +++ b/go.work @@ -18,6 +18,7 @@ use ( ./apps/plugins ./apps/preferences ./apps/provisioning + ./apps/scope ./apps/secret ./apps/shorturl ./pkg/aggregator diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 0407a15f193..d69ffe48ee8 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -90,6 +90,7 @@ grafana::codegen:run apps/dashboard/pkg grafana::codegen:run apps/provisioning/pkg grafana::codegen:run apps/folder/pkg grafana::codegen:run apps/preferences/pkg +grafana::codegen:run apps/scope/pkg grafana::codegen:run apps/alerting/alertenrichment/pkg if [ -d "pkg/extensions/apis" ]; then diff --git a/package.json b/package.json index 3bece98c2ab..64006c8d73c 100644 --- a/package.json +++ b/package.json @@ -302,6 +302,7 @@ "@locker/near-membrane-shared-dom": "0.14.0", "@msagl/core": "^1.1.19", "@msagl/parser": "^1.1.19", + "@openfeature/web-sdk": "^1.6.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.37.0", diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts index a654e102007..5771977d1f8 100644 --- a/packages/grafana-data/src/themes/createComponents.ts +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -1,5 +1,12 @@ import { ThemeColors } from './createColors'; import { ThemeShadows } from './createShadows'; +import type { Radii } from './createShape'; +import type { ThemeSpacingTokens } from './createSpacing'; + +interface MenuComponentTokens { + borderRadius: keyof Radii; + padding: ThemeSpacingTokens; +} /** @beta */ export interface ThemeComponents { @@ -53,6 +60,7 @@ export interface ThemeComponents { rowHoverBackground: string; rowSelected: string; }; + menu: MenuComponentTokens; } export function createComponents(colors: ThemeColors, shadows: ThemeShadows): ThemeComponents { @@ -71,6 +79,11 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th background: colors.mode === 'dark' ? colors.background.canvas : colors.background.primary, }; + const menu: MenuComponentTokens = { + borderRadius: 'default', + padding: 0.5, + }; + return { height: { sm: 3, @@ -114,5 +127,6 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th rowHoverBackground: colors.action.hover, rowSelected: colors.action.selected, }, + menu, }; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 21fe50934cb..546b77a1af5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -970,10 +970,6 @@ export interface FeatureToggles { */ multiTenantTempCredentials?: boolean; /** - * Enables localization for plugins - */ - localizationForPlugins?: boolean; - /** * Enables unified navbars * @default false */ diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 08767dcd549..4707c9123dd 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -165,6 +165,8 @@ export type PluginExtensionOpenModalOptions = { export type PluginExtensionEventHelpers = { context?: Readonly; + // The ID of the extension point that triggered this event + extensionPointId: string; // Opens a modal dialog and renders the provided React component inside it openModal: (options: PluginExtensionOpenModalOptions) => void; /** diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index c4e32ce6340..f9ba7ce203d 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -58,6 +58,9 @@ "@grafana/faro-web-sdk": "^1.13.2", "@grafana/schema": "12.3.0-pre", "@grafana/ui": "12.3.0-pre", + "@openfeature/core": "^1.9.0", + "@openfeature/ofrep-web-provider": "^0.3.3", + "@openfeature/web-sdk": "^1.6.1", "@types/systemjs": "6.15.3", "history": "4.10.1", "lodash": "4.17.21", diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index de669f7af74..aed6b86ebfb 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -27,3 +27,5 @@ export { } from '../services/pluginExtensions/getObservablePluginLinks'; export { UserStorage } from '../utils/userStorage'; + +export { initOpenFeature, evaluateBooleanFlag } from './openFeature'; diff --git a/packages/grafana-runtime/src/internal/openFeature/index.ts b/packages/grafana-runtime/src/internal/openFeature/index.ts new file mode 100644 index 00000000000..891eefe7958 --- /dev/null +++ b/packages/grafana-runtime/src/internal/openFeature/index.ts @@ -0,0 +1,33 @@ +import { OFREPWebProvider } from '@openfeature/ofrep-web-provider'; +import { OpenFeature } from '@openfeature/web-sdk'; + +import { FeatureToggles } from '@grafana/data'; + +import { config } from '../../config'; + +export type FeatureFlagName = keyof FeatureToggles; + +export async function initOpenFeature() { + /** + * Note: Currently we don't have a way to override OpenFeature flags for tests or localStorage. + * A few improvements we could make: + * - When running in tests (unit or e2e?), we could use InMemoryProvider instead + * - Use Multi-Provider to combine InMemoryProvider (for localStorage) with OFREPWebProvider + * to allow for overrides https://github.com/open-feature/js-sdk-contrib/tree/main/libs/providers/multi-provider + */ + + const ofProvider = new OFREPWebProvider({ + baseUrl: '/apis/features.grafana.app/v0alpha1/namespaces/' + config.namespace, + pollInterval: -1, // disable polling + timeoutMs: 5_000, + }); + + await OpenFeature.setProviderAndWait(ofProvider, { + targetingKey: config.namespace, + namespace: config.namespace, + }); +} + +export function evaluateBooleanFlag(flagName: FeatureFlagName, defaultValue: boolean): boolean { + return OpenFeature.getClient().getBooleanValue(flagName, defaultValue); +} diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 4fc8ccf355a..90b4c7194f1 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -41,6 +41,7 @@ export interface Options { showCommonLabels: boolean; showControls?: boolean; showLabels: boolean; + showLogAttributes?: boolean; showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index be3f30adeda..220beac70b3 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -37,7 +37,7 @@ const listFoldersHandler = () => const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10); const page = parseInt(url.searchParams.get('page') ?? '1', 10); - const tree = permission === 'Edit' ? mockTreeThatViewersCanEdit : mockTree; + const tree = permission?.toLowerCase() === 'edit' ? mockTreeThatViewersCanEdit : mockTree; // reconstruct a folder API response from the flat tree fixture const folders = tree diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index e937d7bb1e3..d6a844b1f18 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -92,7 +92,7 @@ export const CollapsableSection = ({ {loading ? ( ) : ( - + )}
@@ -107,17 +107,18 @@ export const CollapsableSection = ({ const collapsableSectionStyles = (theme: GrafanaTheme2) => ({ header: css({ display: 'flex', + alignItems: 'center', cursor: 'pointer', boxSizing: 'border-box', - flexDirection: 'row-reverse', position: 'relative', - justifyContent: 'space-between', + justifyContent: 'flex-start', fontSize: theme.typography.size.lg, padding: `${theme.spacing(0.5)} 0`, '&:focus-within': getFocusStyles(theme), }), button: css({ all: 'unset', + marginRight: theme.spacing(1), '&:focus-visible': { outline: 'none', outlineOffset: 'unset', @@ -141,6 +142,7 @@ const collapsableSectionStyles = (theme: GrafanaTheme2) => ({ }), label: css({ display: 'flex', + flex: '1 1 auto', fontWeight: theme.typography.fontWeightMedium, color: theme.colors.text.maxContrast, }), diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx index 8c50da47478..a1bdc9a945f 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -14,6 +14,9 @@ export interface ErrorBoundaryApi { } interface Props { + /** Name of the error boundary. Used when reporting errors in Faro. */ + boundaryName?: string; + children: (r: ErrorBoundaryApi) => ReactNode; /** Will re-render children after error if recover values changes */ dependencies?: unknown[]; @@ -37,10 +40,15 @@ export class ErrorBoundary extends PureComponent { }; componentDidCatch(error: Error, errorInfo: ErrorInfo) { - const logger = this.props.errorLogger ?? faro?.api?.pushError; - - if (logger) { - logger(error); + if (this.props.errorLogger) { + this.props.errorLogger(error); + } else { + faro?.api?.pushError(error, { + type: 'boundary', + context: { + source: this.props.boundaryName ?? 'unknown', + }, + }); } this.setState({ error, errorInfo }); @@ -85,6 +93,9 @@ export class ErrorBoundary extends PureComponent { * @public */ export interface ErrorBoundaryAlertProps { + /** Name of the error boundary. Used when reporting errors in Faro. */ + boundaryName?: string; + /** Title for the error boundary alert */ title?: string; @@ -107,10 +118,10 @@ export class ErrorBoundaryAlert extends PureComponent { }; render() { - const { title, children, style, dependencies, errorLogger } = this.props; + const { title, children, style, dependencies, errorLogger, boundaryName } = this.props; return ( - + {({ error, errorInfo }) => { if (!errorInfo) { return children; diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index 187943293db..f77a26bb0e7 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -25,6 +25,7 @@ export interface MenuProps extends React.HTMLAttributes { const MenuComp = React.forwardRef( ({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => { const styles = useStyles2(getStyles); + const componentTokens = useComponentTokens(); const localRef = useRef(null); useImperativeHandle(forwardedRef, () => localRef.current!); @@ -36,12 +37,11 @@ const MenuComp = React.forwardRef( {...otherProps} aria-label={ariaLabel} backgroundColor="elevated" - borderRadius="default" + borderRadius={componentTokens.borderRadius} boxShadow="z3" display="inline-block" onKeyDown={handleKeys} - paddingX={0.5} - paddingY={0.5} + padding={componentTokens.padding} ref={localRef} role="menu" tabIndex={-1} @@ -70,6 +70,18 @@ export const Menu = Object.assign(MenuComp, { Group: MenuGroup, }); +const useComponentTokens = () => + useStyles2((theme: GrafanaTheme2) => { + const { + components: { menu }, + } = theme; + + return { + padding: menu.padding, + borderRadius: menu.borderRadius, + }; + }); + const getStyles = (theme: GrafanaTheme2) => { return { header: css({ diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 80748c58888..f6619c1912b 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2, LinkTarget } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; -import { getFocusStyles } from '../../themes/mixins'; +import { getFocusStyles, getInternalRadius } from '../../themes/mixins'; import { IconName } from '../../types/icon'; import { Icon } from '../Icon/Icon'; import { Stack } from '../Layout/Stack/Stack'; @@ -213,6 +213,8 @@ export const MenuItem = React.memo( MenuItem.displayName = 'MenuItem'; const getStyles = (theme: GrafanaTheme2) => { + const menuPadding = theme.components.menu.padding * theme.spacing.gridSize; + return { item: css({ background: 'none', @@ -225,7 +227,7 @@ const getStyles = (theme: GrafanaTheme2) => { justifyContent: 'center', padding: theme.spacing(0.5, 1.5), minHeight: theme.spacing(4), - borderRadius: theme.shape.radius.default, + borderRadius: getInternalRadius(theme, menuPadding, { parentBorderWidth: 0 }), margin: 0, border: 'none', width: '100%', diff --git a/pkg/cmd/grafana-server/commands/cli.go b/pkg/cmd/grafana-server/commands/cli.go index 8850132105f..f13b3bada3e 100644 --- a/pkg/cmd/grafana-server/commands/cli.go +++ b/pkg/cmd/grafana-server/commands/cli.go @@ -11,9 +11,7 @@ import ( "syscall" "time" - "github.com/grafana/grafana/pkg/services/featuremgmt" _ "github.com/grafana/pyroscope-go/godeltaprof/http/pprof" - "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/api" @@ -21,8 +19,10 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/process" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/apiserver/standalone" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -111,6 +111,11 @@ func RunServer(opts standalone.BuildInfo, cli *cli.Context) error { return err } + // Initialize tracing early to ensure it's always available for other services + if err := tracing.InitTracing(cfg); err != nil { + return err + } + s, err := server.Initialize( cli.Context, cfg, diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 2dbdad4a9d6..9c052688d8f 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -57,4 +57,5 @@ import ( _ "github.com/grafana/tempo/pkg/traceql" _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" + _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" ) diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index f8b340d12df..c015569cf8a 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -11,6 +11,8 @@ import ( "sync" "time" + "github.com/go-kit/log/level" + "github.com/grafana/dskit/services" jaegerpropagator "go.opentelemetry.io/contrib/propagators/jaeger" "go.opentelemetry.io/contrib/samplers/jaegerremote" "go.opentelemetry.io/otel" @@ -27,11 +29,9 @@ import ( "go.opentelemetry.io/otel/trace/noop" "google.golang.org/grpc/credentials" - "github.com/go-kit/log/level" - - "github.com/grafana/dskit/services" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/setting" ) const ( @@ -105,6 +105,23 @@ func ProvideService(tracingCfg *TracingConfig) (*TracingService, error) { return ots, nil } +// InitTracing initializes the tracing service with the provided configuration. +// Used to initialize tracing early to ensure it's always available for other +// services, outside of the wire context. +func InitTracing(cfg *setting.Cfg) error { + tracingCfg, err := ParseTracingConfig(cfg) + if err != nil { + return fmt.Errorf("parse tracing config: %w", err) + } + + _, err = ProvideService(tracingCfg) + if err != nil { + return fmt.Errorf("initialize tracing: %w", err) + } + + return nil +} + func NewNoopTracerService() *TracingService { tp := &noopTracerProvider{TracerProvider: noop.NewTracerProvider()} otel.SetTracerProvider(tp) diff --git a/pkg/registry/apis/ofrep/proxy.go b/pkg/registry/apis/ofrep/proxy.go index efb5cc51930..b5791925b26 100644 --- a/pkg/registry/apis/ofrep/proxy.go +++ b/pkg/registry/apis/ofrep/proxy.go @@ -2,6 +2,7 @@ package ofrep import ( "bytes" + "context" "crypto/tls" "crypto/x509" "encoding/json" @@ -14,14 +15,21 @@ import ( "strconv" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/util/proxyutil" goffmodel "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/model" ) -func (b *APIBuilder) proxyAllFlagReq(isAuthedUser bool, w http.ResponseWriter, r *http.Request) { +func (b *APIBuilder) proxyAllFlagReq(ctx context.Context, isAuthedUser bool, w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(ctx, "ofrep.proxy.evalAllFlags") + defer span.End() + + r = r.WithContext(ctx) + proxy, err := b.newProxy(ofrepPath) if err != nil { + err = tracing.Error(span, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -61,9 +69,15 @@ func (b *APIBuilder) proxyAllFlagReq(isAuthedUser bool, w http.ResponseWriter, r proxy.ServeHTTP(w, r) } -func (b *APIBuilder) proxyFlagReq(flagKey string, isAuthedUser bool, w http.ResponseWriter, r *http.Request) { +func (b *APIBuilder) proxyFlagReq(ctx context.Context, flagKey string, isAuthedUser bool, w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(ctx, "ofrep.proxy.evalFlag") + defer span.End() + + r = r.WithContext(ctx) + proxy, err := b.newProxy(path.Join(ofrepPath, flagKey)) if err != nil { + err = tracing.Error(span, err) b.logger.Error("Failed to create proxy", "key", flagKey, "error", err) http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/registry/apis/ofrep/register.go b/pkg/registry/apis/ofrep/register.go index 154e3f6b6fc..b765bbadc32 100644 --- a/pkg/registry/apis/ofrep/register.go +++ b/pkg/registry/apis/ofrep/register.go @@ -10,6 +10,9 @@ import ( "net/url" "github.com/gorilla/mux" + "github.com/grafana/grafana/pkg/infra/tracing" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -31,6 +34,8 @@ var _ builder.APIGroupBuilder = (*APIBuilder)(nil) var _ builder.APIGroupRouteProvider = (*APIBuilder)(nil) var _ builder.APIGroupVersionProvider = (*APIBuilder)(nil) +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/registry/apis/ofrep") + const ofrepPath = "/ofrep/v1/evaluate/flags" const namespaceMismatchMsg = "rejecting request with namespace mismatch" @@ -240,7 +245,13 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { } func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "ofrep.handler.evalFlag") + defer span.End() + + r = r.WithContext(ctx) + if !b.validateNamespace(r) { + _ = tracing.Errorf(span, namespaceMismatchMsg) b.logger.Error(namespaceMismatchMsg) http.Error(w, namespaceMismatchMsg, http.StatusUnauthorized) return @@ -248,42 +259,54 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) { flagKey := mux.Vars(r)["flagKey"] if flagKey == "" { + _ = tracing.Errorf(span, "flagKey parameter is required") http.Error(w, "flagKey parameter is required", http.StatusBadRequest) return } + span.SetAttributes(attribute.String("flag_key", flagKey)) + isAuthedReq := b.isAuthenticatedRequest(r) + span.SetAttributes(attribute.Bool("authenticated", isAuthedReq)) // Unless the request is authenticated, we only allow public flags evaluations if !isAuthedReq && !isPublicFlag(flagKey) { + _ = tracing.Errorf(span, "unauthorized to evaluate flag: %s", flagKey) b.logger.Error("Unauthorized to evaluate flag", "flagKey", flagKey) http.Error(w, "unauthorized to evaluate flag", http.StatusUnauthorized) return } if b.providerType == setting.GOFFProviderType { - b.proxyFlagReq(flagKey, isAuthedReq, w, r) + b.proxyFlagReq(ctx, flagKey, isAuthedReq, w, r) return } - b.evalFlagStatic(flagKey, w, r) + b.evalFlagStatic(ctx, flagKey, w) } func (b *APIBuilder) allFlagsHandler(w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "ofrep.handler.evalAllFlags") + defer span.End() + + r = r.WithContext(ctx) + if !b.validateNamespace(r) { + _ = tracing.Errorf(span, namespaceMismatchMsg) b.logger.Error(namespaceMismatchMsg) http.Error(w, namespaceMismatchMsg, http.StatusUnauthorized) return } isAuthedReq := b.isAuthenticatedRequest(r) + span.SetAttributes(attribute.Bool("authenticated", isAuthedReq)) if b.providerType == setting.GOFFProviderType { - b.proxyAllFlagReq(isAuthedReq, w, r) + b.proxyAllFlagReq(ctx, isAuthedReq, w, r) return } - b.evalAllFlagsStatic(isAuthedReq, w, r) + b.evalAllFlagsStatic(ctx, isAuthedReq, w) } func writeResponse(statusCode int, result any, logger log.Logger, w http.ResponseWriter) { diff --git a/pkg/registry/apis/ofrep/static.go b/pkg/registry/apis/ofrep/static.go index 8d56104649d..18ff794349d 100644 --- a/pkg/registry/apis/ofrep/static.go +++ b/pkg/registry/apis/ofrep/static.go @@ -1,19 +1,28 @@ package ofrep import ( + "context" "net/http" + "github.com/grafana/grafana/pkg/infra/tracing" goffmodel "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/model" + "go.opentelemetry.io/otel/attribute" ) -func (b *APIBuilder) evalAllFlagsStatic(isAuthedUser bool, w http.ResponseWriter, r *http.Request) { - result, err := b.staticEvaluator.EvalAllFlags(r.Context()) +func (b *APIBuilder) evalAllFlagsStatic(ctx context.Context, isAuthedUser bool, w http.ResponseWriter) { + _, span := tracer.Start(ctx, "ofrep.static.evalAllFlags") + defer span.End() + + result, err := b.staticEvaluator.EvalAllFlags(ctx) if err != nil { + err = tracing.Error(span, err) b.logger.Error("Failed to evaluate all static flags", "error", err) http.Error(w, "failed to evaluate flags", http.StatusInternalServerError) return } + span.SetAttributes(attribute.Int("total_flags_count", len(result.Flags))) + if !isAuthedUser { var publicOnly []goffmodel.OFREPFlagBulkEvaluateSuccessResponse @@ -24,14 +33,21 @@ func (b *APIBuilder) evalAllFlagsStatic(isAuthedUser bool, w http.ResponseWriter } result.Flags = publicOnly + span.SetAttributes(attribute.Int("public_flags_count", len(publicOnly))) } writeResponse(http.StatusOK, result, b.logger, w) } -func (b *APIBuilder) evalFlagStatic(flagKey string, w http.ResponseWriter, r *http.Request) { - result, err := b.staticEvaluator.EvalFlag(r.Context(), flagKey) +func (b *APIBuilder) evalFlagStatic(ctx context.Context, flagKey string, w http.ResponseWriter) { + _, span := tracer.Start(ctx, "ofrep.static.evalFlag") + defer span.End() + + span.SetAttributes(attribute.String("flag_key", flagKey)) + + result, err := b.staticEvaluator.EvalFlag(ctx, flagKey) if err != nil { + err = tracing.Error(span, err) b.logger.Error("Failed to evaluate static flag", "key", flagKey, "error", err) http.Error(w, "failed to evaluate flag", http.StatusInternalServerError) return diff --git a/pkg/registry/apis/preferences/admission.go b/pkg/registry/apis/preferences/admission.go new file mode 100644 index 00000000000..0703b37b8df --- /dev/null +++ b/pkg/registry/apis/preferences/admission.go @@ -0,0 +1,35 @@ +package preferences + +import ( + "context" + "fmt" + + "k8s.io/apiserver/pkg/admission" + + preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" +) + +func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { + switch a.GetOperation() { + case admission.Create, admission.Update: + // ignore anything that is not CREATE | UPDATE + default: + return nil + } + + obj := a.GetObject() + if obj == nil { + return nil + } + + switch a.GetResource().Resource { + case "stars": + stars, ok := obj.(*preferences.Stars) + if !ok { + return fmt.Errorf("expected stars object: (%T)", obj) + } + stars.Spec.Normalize() + return nil + } + return nil +} diff --git a/pkg/registry/apis/preferences/legacy/queries.go b/pkg/registry/apis/preferences/legacy/queries.go index dd3826d9674..d51904a0601 100644 --- a/pkg/registry/apis/preferences/legacy/queries.go +++ b/pkg/registry/apis/preferences/legacy/queries.go @@ -26,21 +26,29 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlStarsQuery = mustTemplate("sql_stars_query.sql") - sqlStarsRV = mustTemplate("sql_stars_rv.sql") - sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql") - sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql") - sqlTeams = mustTemplate("sql_teams.sql") + sqlDashboardStarsQuery = mustTemplate("sql_dashboard_stars.sql") + sqlDashboardStarsRV = mustTemplate("sql_dashboard_stars_rv.sql") + sqlHistoryStarsQuery = mustTemplate("sql_history_stars.sql") + sqlHistoryStarsInsert = mustTemplate("sql_history_stars_insert.sql") + sqlHistoryStarsDelete = mustTemplate("sql_history_stars_delete.sql") + sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql") + sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql") + sqlTeams = mustTemplate("sql_teams.sql") ) type starQuery struct { sqltemplate.SQLTemplate - OrgID int64 // >= 1 if UserID != "" - UserUID string + OrgID int64 // >= 1 if UserID != "" + UserUID string + UserID int64 // for stars + QueryUIDs []string + QueryUID string - StarTable string - UserTable string + StarTable string + UserTable string + QueryHistoryStarsTable string + QueryHistoryTable string } func (r starQuery) Validate() error { @@ -57,8 +65,10 @@ func newStarQueryReq(sql *legacysql.LegacyDatabaseHelper, user string, orgId int UserUID: user, OrgID: orgId, - StarTable: sql.Table("star"), - UserTable: sql.Table("user"), + StarTable: sql.Table("star"), + UserTable: sql.Table("user"), + QueryHistoryStarsTable: sql.Table("query_history_star"), + QueryHistoryTable: sql.Table("query_history"), } } diff --git a/pkg/registry/apis/preferences/legacy/queries_test.go b/pkg/registry/apis/preferences/legacy/queries_test.go index e1beb8ef056..726049af7f5 100644 --- a/pkg/registry/apis/preferences/legacy/queries_test.go +++ b/pkg/registry/apis/preferences/legacy/queries_test.go @@ -23,6 +23,15 @@ func TestStarsQueries(t *testing.T) { return &v } + getHistoryReq := func(orgId int64, userId int64, stars []string, star string) sqltemplate.SQLTemplate { + v := newStarQueryReq(nodb, "", orgId) + v.UserID = userId + v.QueryUIDs = stars + v.QueryUID = star + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + getPreferencesQuery := func(orgId int64, cb func(q *preferencesQuery)) sqltemplate.SQLTemplate { v := newPreferencesQueryReq(nodb, orgId) v.SQLTemplate = mocks.NewTestingSQLTemplate() @@ -40,7 +49,7 @@ func TestStarsQueries(t *testing.T) { RootDir: "testdata", SQLTemplatesFS: sqlTemplatesFS, Templates: map[*template.Template][]mocks.TemplateTestCase{ - sqlStarsQuery: { + sqlDashboardStarsQuery: { { Name: "all", Data: getStarQuery(0, ""), @@ -54,12 +63,42 @@ func TestStarsQueries(t *testing.T) { Data: getStarQuery(3, "abc"), }, }, - sqlStarsRV: { + sqlDashboardStarsRV: { { Name: "get", Data: getStarQuery(0, ""), }, }, + sqlHistoryStarsQuery: { + { + Name: "user", + Data: getStarQuery(1, "abc"), + }, + }, + sqlHistoryStarsQuery: { + { + Name: "org", + Data: getStarQuery(1, ""), + }, + }, + sqlHistoryStarsInsert: { + { + Name: "add star", + Data: getHistoryReq(1, 3, nil, "XXX"), + }, + }, + sqlHistoryStarsDelete: { + { + Name: "remove star", + Data: getHistoryReq(1, 3, []string{"xxx", "yyy"}, ""), + }, + }, + sqlHistoryStarsDelete: { + { + Name: "remove all star", + Data: getHistoryReq(1, 3, nil, ""), + }, + }, sqlPreferencesQuery: { { Name: "all", diff --git a/pkg/registry/apis/preferences/legacy/sql.go b/pkg/registry/apis/preferences/legacy/sql.go index fb4cc60fcbf..93f4ea39949 100644 --- a/pkg/registry/apis/preferences/legacy/sql.go +++ b/pkg/registry/apis/preferences/legacy/sql.go @@ -12,6 +12,7 @@ import ( preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -58,13 +59,16 @@ func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user str req := newStarQueryReq(sql, user, orgId) - q, err := sqltemplate.Execute(sqlStarsQuery, req) + q, err := sqltemplate.Execute(sqlDashboardStarsQuery, req) if err != nil { - return nil, 0, fmt.Errorf("execute template %q: %w", sqlStarsQuery.Name(), err) + return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsQuery.Name(), err) } sess := sql.DB.GetSqlxSession() rows, err := sess.Query(ctx, q, req.GetArgs()...) + if err != nil { + return nil, 0, err + } defer func() { if rows != nil { _ = rows.Close() @@ -111,7 +115,7 @@ func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user str // Find the RV unless it is a user query if userUID == "" { req.Reset() - q, err = sqltemplate.Execute(sqlStarsRV, req) + q, err = sqltemplate.Execute(sqlDashboardStarsRV, req) if err != nil { return nil, 0, fmt.Errorf("execute template %q: %w", sqlPreferencesRV.Name(), err) } @@ -132,6 +136,90 @@ func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user str return stars, updated.UnixMilli(), err } +func (s *LegacySQL) getHistoryStars(ctx context.Context, orgId int64, user string) (map[string][]string, error) { + sql, err := s.db(ctx) + if err != nil { + return nil, err + } + req := newStarQueryReq(sql, user, orgId) + + q, err := sqltemplate.Execute(sqlHistoryStarsQuery, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlHistoryStarsQuery.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + rows, err := sess.Query(ctx, q, req.GetArgs()...) + if err != nil { + return nil, err + } + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + + last := user + res := make(map[string][]string) + buffer := make([]string, 0, 10) + var uid string + + for rows.Next() { + err := rows.Scan(&uid, &user) + if err != nil { + return nil, err + } + if user != last && len(buffer) > 0 { + res[last] = buffer + buffer = make([]string, 0, 10) + } + buffer = append(buffer, uid) + last = user + } + res[last] = buffer + return res, nil +} + +func (s *LegacySQL) removeHistoryStar(ctx context.Context, user *user.User, stars []string) error { + sql, err := s.db(ctx) + if err != nil { + return err + } + req := newStarQueryReq(sql, "", user.OrgID) + req.UserID = user.ID + if len(stars) > 0 { + req.QueryUIDs = stars + } + + q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + _, err = sess.Exec(ctx, q, req.GetArgs()...) + return err +} + +func (s *LegacySQL) addHistoryStar(ctx context.Context, user *user.User, star string) error { + sql, err := s.db(ctx) + if err != nil { + return err + } + req := newStarQueryReq(sql, "", user.OrgID) + req.UserID = user.ID + req.QueryUID = star + + q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + _, err = sess.Exec(ctx, q, req.GetArgs()...) + return err +} + // List all defined preferences in an org (valid for admin users only) func (s *LegacySQL) listPreferences(ctx context.Context, ns string, orgId int64, diff --git a/pkg/registry/apis/preferences/legacy/sql_stars_query.sql b/pkg/registry/apis/preferences/legacy/sql_dashboard_stars.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/sql_stars_query.sql rename to pkg/registry/apis/preferences/legacy/sql_dashboard_stars.sql diff --git a/pkg/registry/apis/preferences/legacy/sql_stars_rv.sql b/pkg/registry/apis/preferences/legacy/sql_dashboard_stars_rv.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/sql_stars_rv.sql rename to pkg/registry/apis/preferences/legacy/sql_dashboard_stars_rv.sql diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars.sql new file mode 100644 index 00000000000..93f59103707 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/sql_history_stars.sql @@ -0,0 +1,9 @@ +SELECT s.query_uid, u.uid as user_uid + FROM {{ .Ident .QueryHistoryStarsTable }} as s + JOIN {{ .Ident .QueryHistoryTable }} as h ON s.query_uid = h.uid + JOIN {{ .Ident .UserTable }} as u ON s.user_id = u.id + WHERE s.org_id = {{ .Arg .OrgID }} + {{ if .UserUID }} + AND u.uid = {{ .Arg .UserUID }} +{{ end }} + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql new file mode 100644 index 00000000000..4804740a42f --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql @@ -0,0 +1,6 @@ +DELETE FROM {{ .Ident .QueryHistoryStarsTable }} + WHERE org_id = {{ .Arg .OrgID }} + AND user_id = {{ .Arg .UserID }} + {{ if .QueryUIDs }} + AND query_uid IN ({{ .ArgList .QueryUIDs }}) +{{ end }} \ No newline at end of file diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql new file mode 100644 index 00000000000..a85d14f2ed9 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql @@ -0,0 +1,4 @@ +INSERT INTO {{ .Ident .QueryHistoryStarsTable }} + ( query_uid, user_id, org_id ) +VALUES + ( {{ .Arg .QueryUID }}, {{ .Arg .UserID }}, {{ .Arg .OrgID }} ) diff --git a/pkg/registry/apis/preferences/legacy/stars.go b/pkg/registry/apis/preferences/legacy/stars.go index b048c249170..f38f2fd786b 100644 --- a/pkg/registry/apis/preferences/legacy/stars.go +++ b/pkg/registry/apis/preferences/legacy/stars.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand" + "slices" "strconv" "strings" "time" @@ -12,6 +13,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/utils/ptr" @@ -107,8 +109,13 @@ func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversi if err != nil { return nil, err } + history, err := s.sql.getHistoryStars(ctx, ns.OrgID, "") + if err != nil { + return nil, err + } for _, v := range found { - list.Items = append(list.Items, asStarsResource(s.namespacer(v.OrgID), &v)) + list.Items = append(list.Items, + asStarsResource(s.namespacer(v.OrgID), &v, history[v.UserUID])) } if rv > 0 { list.ResourceVersion = strconv.FormatInt(rv, 10) @@ -141,19 +148,25 @@ func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *m if err != nil { return nil, err } + + history, err := s.sql.getHistoryStars(ctx, ns.OrgID, owner.Identifier) + if err != nil { + return nil, err + } + if len(found) == 0 || len(found[0].Dashboards) == 0 { return nil, apiserrors.NewNotFound(preferences.StarsResourceInfo.GroupResource(), name) } - obj := asStarsResource(ns.Value, &found[0]) + obj := asStarsResource(ns.Value, &found[0], history[owner.Identifier]) return &obj, nil } -func getDashboardStars(stars *preferences.Stars) []string { +func getStars(stars *preferences.Stars, gk schema.GroupKind) []string { if stars == nil || len(stars.Spec.Resource) == 0 { return []string{} } for _, r := range stars.Spec.Resource { - if r.Group == "dashboard.grafana.app" && r.Kind == "Dashboard" { + if r.Group == gk.Group && r.Kind == gk.Kind { return r.Names } } @@ -161,7 +174,7 @@ func getDashboardStars(stars *preferences.Stars) []string { } // Create implements rest.Creater. -func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars, old *preferences.Stars) (runtime.Object, error) { +func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars) (runtime.Object, error) { ns, owner, err := getNamespaceAndOwner(ctx, obj.Name) if err != nil { return nil, err @@ -177,7 +190,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star return nil, fmt.Errorf("namespace mismatch") } - stars := getDashboardStars(obj) + stars := getStars(obj, schema.GroupKind{Group: "dashboard.grafana.app", Kind: "Dashboard"}) if len(stars) == 0 { err = s.stars.DeleteByUser(ctx, user.ID) return &preferences.Stars{ObjectMeta: metav1.ObjectMeta{ @@ -232,6 +245,31 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star changed = true } + // Apply history stars + stars = getStars(obj, schema.GroupKind{Group: "history.grafana.app", Kind: "Query"}) + res, err := s.sql.getHistoryStars(ctx, user.OrgID, user.UID) + if err != nil { + return nil, err + } + history := res[user.UID] + if !slices.Equal(stars, history) { + changed = true + if len(stars) == 0 { + err = s.sql.removeHistoryStar(ctx, user, nil) + if err != nil { + return nil, err + } + } else { + added, removed, _ := preferences.Changes(history, stars) + if len(removed) > 0 { + _ = s.sql.removeHistoryStar(ctx, user, nil) + } + for _, v := range added { + _ = s.sql.addHistoryStar(ctx, user, v) // one at a time so duplicates do not fail everything + } + } + } + if changed { return s.Get(ctx, obj.Name, &metav1.GetOptions{}) } @@ -245,7 +283,7 @@ func (s *DashboardStarsStorage) Create(ctx context.Context, obj runtime.Object, return nil, fmt.Errorf("expected stars object") } - return s.write(ctx, stars, nil) + return s.write(ctx, stars) } // Update implements rest.Updater. @@ -265,13 +303,13 @@ func (s *DashboardStarsStorage) Update(ctx context.Context, name string, objInfo return nil, false, fmt.Errorf("expected stars object") } - obj, err = s.write(ctx, stars, old.(*preferences.Stars)) + obj, err = s.write(ctx, stars) return obj, false, err } // Delete implements rest.GracefulDeleter. func (s *DashboardStarsStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - obj, err := s.write(ctx, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil) + obj, err := s.write(ctx, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}) if err != nil { return nil, false, err } @@ -283,8 +321,8 @@ func (s *DashboardStarsStorage) DeleteCollection(ctx context.Context, deleteVali return nil, fmt.Errorf("not implemented yet") } -func asStarsResource(ns string, v *dashboardStars) preferences.Stars { - return preferences.Stars{ +func asStarsResource(ns string, v *dashboardStars, history []string) preferences.Stars { + stars := preferences.Stars{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("user-%s", v.UserUID), Namespace: ns, @@ -299,4 +337,13 @@ func asStarsResource(ns string, v *dashboardStars) preferences.Stars { }}, }, } + if len(history) > 0 { + stars.Spec.Resource = append(stars.Spec.Resource, preferences.StarsResource{ + Group: "history.grafana.app", + Kind: "Query", + Names: history, + }) + } + stars.Spec.Normalize() + return stars } diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-all.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-all.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-org.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-org.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-user.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_query-user.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_rv-get.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_stars_rv-get.sql rename to pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql new file mode 100755 index 00000000000..e651c6bc37c --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql @@ -0,0 +1,6 @@ +SELECT s.query_uid, u.uid as user_uid + FROM `grafana`.`query_history_star` as s + JOIN `grafana`.`query_history` as h ON s.query_uid = h.uid + JOIN `grafana`.`user` as u ON s.user_id = u.id + WHERE s.org_id = 1 + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql new file mode 100755 index 00000000000..0c18ffdc0a8 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql @@ -0,0 +1,3 @@ +DELETE FROM `grafana`.`query_history_star` + WHERE org_id = 1 + AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql new file mode 100755 index 00000000000..5a050187451 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql @@ -0,0 +1,4 @@ +INSERT INTO `grafana`.`query_history_star` + ( query_uid, user_id, org_id ) +VALUES + ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-all.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-all.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-org.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-org.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-user.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_query-user.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_rv-get.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_stars_rv-get.sql rename to pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql new file mode 100755 index 00000000000..d0fe6688c97 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql @@ -0,0 +1,6 @@ +SELECT s.query_uid, u.uid as user_uid + FROM "grafana"."query_history_star" as s + JOIN "grafana"."query_history" as h ON s.query_uid = h.uid + JOIN "grafana"."user" as u ON s.user_id = u.id + WHERE s.org_id = 1 + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql new file mode 100755 index 00000000000..0b31729f72e --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql @@ -0,0 +1,3 @@ +DELETE FROM "grafana"."query_history_star" + WHERE org_id = 1 + AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql new file mode 100755 index 00000000000..d4729521f0d --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql @@ -0,0 +1,4 @@ +INSERT INTO "grafana"."query_history_star" + ( query_uid, user_id, org_id ) +VALUES + ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-all.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-all.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-org.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-org.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-user.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_query-user.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_rv-get.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_stars_rv-get.sql rename to pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql new file mode 100755 index 00000000000..d0fe6688c97 --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql @@ -0,0 +1,6 @@ +SELECT s.query_uid, u.uid as user_uid + FROM "grafana"."query_history_star" as s + JOIN "grafana"."query_history" as h ON s.query_uid = h.uid + JOIN "grafana"."user" as u ON s.user_id = u.id + WHERE s.org_id = 1 + ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql new file mode 100755 index 00000000000..0b31729f72e --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql @@ -0,0 +1,3 @@ +DELETE FROM "grafana"."query_history_star" + WHERE org_id = 1 + AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql new file mode 100755 index 00000000000..d4729521f0d --- /dev/null +++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql @@ -0,0 +1,4 @@ +INSERT INTO "grafana"."query_history_star" + ( query_uid, user_id, org_id ) +VALUES + ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index ff186c6bdb2..786d200d7cd 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -29,7 +29,10 @@ import ( "github.com/grafana/grafana/pkg/storage/legacysql" ) -var _ builder.APIGroupBuilder = (*APIBuilder)(nil) +var ( + _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupMutation = (*APIBuilder)(nil) +) type APIBuilder struct { authorizer authorizer.Authorizer @@ -112,7 +115,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI if err != nil { return err } - stars = &starStorage{store: stars} // wrap List so we only return one value + stars = &starStorage{Storage: stars} // wrap List so we only return one value if b.legacyStars != nil && opts.DualWriteBuilder != nil { stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars) if err != nil { diff --git a/pkg/registry/apis/preferences/stars.go b/pkg/registry/apis/preferences/stars.go index 68bbb5c61bd..b5c8d0b0dcf 100644 --- a/pkg/registry/apis/preferences/stars.go +++ b/pkg/registry/apis/preferences/stars.go @@ -6,7 +6,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/internalversion" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" authlib "github.com/grafana/authlib/types" preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" @@ -17,7 +16,7 @@ import ( var _ grafanarest.Storage = (*starStorage)(nil) type starStorage struct { - store grafanarest.Storage + grafanarest.Storage } // When using list, we really just want to get the value for the single user @@ -34,7 +33,7 @@ func (s *starStorage) List(ctx context.Context, options *internalversion.ListOpt // Get the single user stars case authlib.TypeUser: stars := &preferences.StarsList{} - obj, _ := s.store.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{}) + obj, _ := s.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{}) if obj != nil { s, ok := obj.(*preferences.Stars) if ok { @@ -44,61 +43,6 @@ func (s *starStorage) List(ctx context.Context, options *internalversion.ListOpt return stars, nil default: - return s.store.List(ctx, options) + return s.Storage.List(ctx, options) } } - -// ConvertToTable implements rest.Storage. -func (s *starStorage) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*v1.Table, error) { - return s.store.ConvertToTable(ctx, obj, tableOptions) -} - -// Create implements rest.Storage. -func (s *starStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *v1.CreateOptions) (runtime.Object, error) { - return s.store.Create(ctx, obj, createValidation, options) -} - -// Delete implements rest.Storage. -func (s *starStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *v1.DeleteOptions) (runtime.Object, bool, error) { - return s.store.Delete(ctx, name, deleteValidation, options) -} - -// DeleteCollection implements rest.Storage. -func (s *starStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *v1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { - return s.store.DeleteCollection(ctx, deleteValidation, options, listOptions) -} - -// Destroy implements rest.Storage. -func (s *starStorage) Destroy() { - s.store.Destroy() -} - -// Get implements rest.Storage. -func (s *starStorage) Get(ctx context.Context, name string, options *v1.GetOptions) (runtime.Object, error) { - return s.store.Get(ctx, name, options) -} - -// GetSingularName implements rest.Storage. -func (s *starStorage) GetSingularName() string { - return s.store.GetSingularName() -} - -// NamespaceScoped implements rest.Storage. -func (s *starStorage) NamespaceScoped() bool { - return s.store.NamespaceScoped() -} - -// New implements rest.Storage. -func (s *starStorage) New() runtime.Object { - return s.store.New() -} - -// NewList implements rest.Storage. -func (s *starStorage) NewList() runtime.Object { - return s.store.NewList() -} - -// Update implements rest.Storage. -func (s *starStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *v1.UpdateOptions) (runtime.Object, bool, error) { - return s.store.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options) -} diff --git a/pkg/registry/apis/preferences/stars_update.go b/pkg/registry/apis/preferences/stars_update.go index bf3a4c0241d..2280a448e07 100644 --- a/pkg/registry/apis/preferences/stars_update.go +++ b/pkg/registry/apis/preferences/stars_update.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "slices" "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -110,11 +109,10 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, return } - if !apply(&obj.Spec, item, remove) { - responder.Object(http.StatusNoContent, &v1.Status{ - Code: http.StatusNoContent, - }) - return + if remove { + obj.Spec.Remove(item.group, item.kind, item.id) + } else { + obj.Spec.Add(item.group, item.kind, item.id) } if len(obj.Spec.Resource) == 0 { @@ -128,9 +126,7 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, responder.Error(err) return } - responder.Object(http.StatusOK, &v1.Status{ - Code: http.StatusOK, - }) + responder.Object(http.StatusOK, &v1.Status{Code: http.StatusOK}) }), nil } @@ -151,49 +147,3 @@ func itemFromPath(urlPath, prefix string) (starItem, error) { id: parts[2], }, nil } - -func apply(spec *preferences.StarsSpec, item starItem, remove bool) bool { - var stars *preferences.StarsResource - for idx, v := range spec.Resource { - if v.Group == item.group && v.Kind == item.kind { - stars = &spec.Resource[idx] - } - } - if stars == nil { - if remove { - return false - } - spec.Resource = append(spec.Resource, preferences.StarsResource{ - Group: item.group, - Kind: item.kind, - Names: []string{}, - }) - stars = &spec.Resource[len(spec.Resource)-1] - } - - idx := slices.Index(stars.Names, item.id) - if idx < 0 { // not found - if remove { - return false - } - stars.Names = append(stars.Names, item.id) - } else if remove { - stars.Names = append(stars.Names[:idx], stars.Names[idx+1:]...) - } else { - return false - } - slices.Sort(stars.Names) - - // Remove the slot if only one value - if len(stars.Names) == 0 { - tmp := preferences.StarsSpec{} - for _, v := range spec.Resource { - if v.Group == item.group && v.Kind == item.kind { - continue - } - tmp.Resource = append(tmp.Resource, v) - } - spec.Resource = tmp.Resource - } - return true -} diff --git a/pkg/registry/apis/preferences/stars_update_test.go b/pkg/registry/apis/preferences/stars_update_test.go index 8faf413550c..c4b58804cbc 100644 --- a/pkg/registry/apis/preferences/stars_update_test.go +++ b/pkg/registry/apis/preferences/stars_update_test.go @@ -4,194 +4,9 @@ import ( "testing" "github.com/stretchr/testify/require" - - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" ) func TestStarsWrite(t *testing.T) { - t.Run("apply", func(t *testing.T) { - tests := []struct { - name string - spec preferences.StarsSpec - item starItem - remove bool - changed bool - expect preferences.StarsSpec - }{{ - name: "add to an existing array", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "x", - }, - remove: false, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c", "x"}, // added "x" - }}, - }, - }, { - name: "remove from an existing array", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "b", - }, - remove: true, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "c"}, // removed "b" - }}, - }, - }, { - name: "add to empty spec", - spec: preferences.StarsSpec{}, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: false, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a"}, - }}, - }, - }, { - name: "remove item that does not exist", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"x"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: false, - }, { - name: "add item that already exist", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"x"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "x", - }, - remove: false, - changed: false, - }, { - name: "remove from empty", - spec: preferences.StarsSpec{}, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: false, - }, { - name: "remove item that does not exist", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a", "b", "c"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "X", - }, - remove: true, - changed: false, - }, { - name: "remove last item", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a"}, - }}, - }, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: true, - expect: preferences.StarsSpec{}, - }, { - name: "remove last item (with others)", - spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g", - Kind: "k", - Names: []string{"a"}, - }, { - Group: "g2", - Kind: "k2", - Names: []string{"a"}, - }}}, - item: starItem{ - group: "g", - kind: "k", - id: "a", - }, - remove: true, - changed: true, - expect: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ - Group: "g2", - Kind: "k2", - Names: []string{"a"}, - }}}, - }} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - changed := apply(&tt.spec, tt.item, tt.remove) - require.Equal(t, tt.changed, changed) - if changed { - require.Equal(t, tt.expect, tt.spec) - } - }) - } - }) - t.Run("path", func(t *testing.T) { tests := []struct { name string diff --git a/pkg/server/module_registerer.go b/pkg/server/module_registerer.go new file mode 100644 index 00000000000..8a459588937 --- /dev/null +++ b/pkg/server/module_registerer.go @@ -0,0 +1,20 @@ +package server + +import ( + "github.com/grafana/grafana/pkg/modules" +) + +// ModuleRegisterer is used to inject enterprise dskit modules into +// the module manager. This abstraction allows other builds (e.g. enterprise) to register +// additional modules while keeping the core server decoupled from build-specific dependencies. +type ModuleRegisterer interface { + RegisterModules(manager modules.Registry) +} + +type noopModuleRegisterer struct{} + +func (noopModuleRegisterer) RegisterModules(manager modules.Registry) {} + +func ProvideNoopModuleRegisterer() ModuleRegisterer { + return &noopModuleRegisterer{} +} diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go index 5c1517d354d..5c420b9d219 100644 --- a/pkg/server/module_server.go +++ b/pkg/server/module_server.go @@ -44,8 +44,9 @@ func NewModule(opts Options, promGatherer prometheus.Gatherer, tracer tracing.Tracer, // Ensures tracing is initialized license licensing.Licensing, + moduleRegisterer ModuleRegisterer, ) (*ModuleServer, error) { - s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, reg, promGatherer, license) + s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, reg, promGatherer, license, moduleRegisterer) if err != nil { return nil, err } @@ -66,6 +67,7 @@ func newModuleServer(opts Options, reg prometheus.Registerer, promGatherer prometheus.Gatherer, license licensing.Licensing, + moduleRegisterer ModuleRegisterer, ) (*ModuleServer, error) { rootCtx, shutdownFn := context.WithCancel(context.Background()) @@ -87,6 +89,7 @@ func newModuleServer(opts Options, promGatherer: promGatherer, registerer: reg, license: license, + moduleRegisterer: moduleRegisterer, } return s, nil @@ -124,6 +127,9 @@ type ModuleServer struct { httpServerRouter *mux.Router searchServerRing *ring.Ring searchServerRingClientPool *ringclient.Pool + + // moduleRegisterer allows registration of modules provided by other builds (e.g. enterprise). + moduleRegisterer ModuleRegisterer } // init initializes the server and its services. @@ -202,6 +208,9 @@ func (s *ModuleServer) Run() error { m.RegisterModule(modules.All, nil) + // Register modules provided by other builds (e.g. enterprise). + s.moduleRegisterer.RegisterModules(m) + return m.Run(s.context) } diff --git a/pkg/server/search_server_distributor_test.go b/pkg/server/search_server_distributor_test.go index 18b5431ed89..e7304876688 100644 --- a/pkg/server/search_server_distributor_test.go +++ b/pkg/server/search_server_distributor_test.go @@ -326,7 +326,7 @@ func initModuleServerForTest( ) testModuleServer { tracer := tracing.InitializeTracerForTest() - ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, nil) + ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, nil, ProvideNoopModuleRegisterer()) require.NoError(t, err) conn, err := grpc.NewClient(cfg.GRPCServer.Address, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index bcbb569df18..6b2be6c0da5 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -585,7 +585,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api ossProvider := guardian.ProvideGuardian() cacheServiceImpl := service9.ProvideCacheService(cacheService, sqlStore, ossProvider) shortURLService := shorturlimpl.ProvideService(sqlStore) - queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl) + queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl, featureToggles, eventualRestConfigProvider) dashboardService := service7.ProvideDashboardService(featureToggles, dashboardServiceImpl) dashverService := dashverimpl.ProvideService(cfg, sqlStore, dashboardService, featureToggles, k8sHandlerWithFallback) dashboardSnapshotStore := database5.ProvideStore(sqlStore, cfg) @@ -1182,7 +1182,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } shortURLService := shorturlimpl.ProvideService(sqlStore) - queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl) + queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl, featureToggles, eventualRestConfigProvider) dashboardService := service7.ProvideDashboardService(featureToggles, dashboardServiceImpl) dashverService := dashverimpl.ProvideService(cfg, sqlStore, dashboardService, featureToggles, k8sHandlerWithFallback) dashboardSnapshotStore := database5.ProvideStore(sqlStore, cfg) @@ -1621,7 +1621,8 @@ func InitializeModuleServer(cfg *setting.Cfg, opts Options, apiOpts api.ServerOp } hooksService := hooks.ProvideService() ossLicensingService := licensing.ProvideService(cfg, hooksService) - moduleServer, err := NewModule(opts, apiOpts, featureToggles, cfg, storageMetrics, bleveIndexMetrics, registerer, gatherer, tracingService, ossLicensingService) + moduleRegisterer := ProvideNoopModuleRegisterer() + moduleServer, err := NewModule(opts, apiOpts, featureToggles, cfg, storageMetrics, bleveIndexMetrics, registerer, gatherer, tracingService, ossLicensingService, moduleRegisterer) if err != nil { return nil, err } diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 5ba3b26347f..6b2163f4e34 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -191,6 +191,8 @@ var wireExtsModuleServerSet = wire.NewSet( // Unified storage resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, + // Overriden by enterprise + ProvideNoopModuleRegisterer, ) var wireExtsStandaloneAPIServerSet = wire.NewSet( diff --git a/pkg/services/auth/authimpl/external_session_store.go b/pkg/services/auth/authimpl/external_session_store.go index 95e379b79b6..33a43355e59 100644 --- a/pkg/services/auth/authimpl/external_session_store.go +++ b/pkg/services/auth/authimpl/external_session_store.go @@ -56,6 +56,8 @@ func (s *store) Get(ctx context.Context, ID int64) (*auth.ExternalSession, error return externalSession, nil } +// List returns a list of external sessions that match the given query. +// If the result set contains more than one entry, the entries are sorted by ID in descending order. func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) { ctx, span := s.tracer.Start(ctx, "externalsession.List") defer span.End() @@ -65,6 +67,10 @@ func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery) externalSession.ID = query.ID } + if query.UserID != 0 { + externalSession.UserID = query.UserID + } + hash := sha256.New() if query.SessionID != "" { @@ -80,7 +86,7 @@ func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery) queryResult := make([]*auth.ExternalSession, 0) err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - return sess.Find(&queryResult, externalSession) + return sess.Desc("id").Find(&queryResult, externalSession) }) if err != nil { return nil, err diff --git a/pkg/services/auth/external_session.go b/pkg/services/auth/external_session.go index e67b5a10259..32e9e1f4236 100644 --- a/pkg/services/auth/external_session.go +++ b/pkg/services/auth/external_session.go @@ -51,6 +51,7 @@ type UpdateExternalSessionCommand struct { type ListExternalSessionQuery struct { ID int64 + UserID int64 NameID string SessionID string } diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync.go index a63853648a7..cb2103437ce 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync.go @@ -93,7 +93,11 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Ident updateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) defer cancel() - token, refreshErr := s.service.TryTokenRefresh(updateCtx, id, id.SessionToken) + token, refreshErr := s.service.TryTokenRefresh(updateCtx, id, &oauthtoken.TokenRefreshMetadata{ + ExternalSessionID: id.SessionToken.ExternalSessionId, + AuthModule: id.GetAuthenticatedBy(), + AuthID: id.GetAuthID(), + }) if refreshErr != nil { if errors.Is(refreshErr, context.Canceled) { return nil, nil @@ -107,7 +111,7 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Ident ctxLogger.Error("Failed to refresh OAuth access token", "id", id.ID, "error", refreshErr) // log the user out - if err := s.sessionService.RevokeToken(ctx, id.SessionToken, false); err != nil { + if err := s.sessionService.RevokeToken(ctx, id.SessionToken, false); err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) { ctxLogger.Warn("Failed to revoke session token", "id", id.ID, "tokenId", id.SessionToken.Id, "error", err) } diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go index 5f5e1303a95..3178f5390f2 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go @@ -25,6 +25,7 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" ) @@ -77,6 +78,14 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { expectRevokeTokenCalled: false, expectToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)}, }, + { + desc: "should not invalidate session if token refresh fails with no refresh token", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, SessionToken: &auth.UserToken{}, AuthenticatedBy: login.AzureADAuthModule}, + expectedTryRefreshErr: oauthtoken.ErrNoRefreshTokenFound, + expectTryRefreshTokenCalled: true, + expectRevokeTokenCalled: true, + expectedErr: oauthtoken.ErrNoRefreshTokenFound, + }, // TODO: address coverage of oauthtoken sync } @@ -89,7 +98,7 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) { ) service := &oauthtokentest.MockOauthTokenService{ - TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester, _ *auth.UserToken) (*oauth2.Token, error) { + TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester, _ *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) { tryRefreshCalled = true return nil, tt.expectedTryRefreshErr }, diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go index 0e8053a39b7..e6d4b67d122 100644 --- a/pkg/services/authn/clients/oauth.go +++ b/pkg/services/authn/clients/oauth.go @@ -297,7 +297,9 @@ func (c *OAuth) Logout(ctx context.Context, user identity.Requester, sessionToke ctxLogger := c.log.FromContext(ctx).New("userID", userID) - if err := c.oauthService.InvalidateOAuthTokens(ctx, user, sessionToken); err != nil { + if err := c.oauthService.InvalidateOAuthTokens(ctx, user, &oauthtoken.TokenRefreshMetadata{ + ExternalSessionID: sessionToken.ExternalSessionId, + AuthModule: user.GetAuthenticatedBy()}); err != nil { ctxLogger.Error("Failed to invalidate tokens", "error", err) } diff --git a/pkg/services/authn/clients/oauth_test.go b/pkg/services/authn/clients/oauth_test.go index 2bd536d6544..b6a9a07c488 100644 --- a/pkg/services/authn/clients/oauth_test.go +++ b/pkg/services/authn/clients/oauth_test.go @@ -19,10 +19,12 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" @@ -481,7 +483,7 @@ func TestOAuth_Logout(t *testing.T) { "id_token": "some.id.token", }) }, - InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester, _ *auth.UserToken) error { + InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester, _ *oauthtoken.TokenRefreshMetadata) error { invalidateTokenCalled = true return nil }, @@ -492,7 +494,7 @@ func TestOAuth_Logout(t *testing.T) { } c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures(), tracing.InitializeTracerForTest()) - redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, nil) + redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, &usertoken.UserToken{}) assert.Equal(t, tt.expectedOK, ok) if tt.expectedOK { diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 105f7db7157..9af70a26277 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -760,12 +760,10 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, cacheHit := false if t.HasFolderSupport() { var err error - ok = false if !req.Options.SkipCache { - tree, ok = s.getCachedFolderTree(ctx, req.Namespace) - cacheHit = true + tree, cacheHit = s.getCachedFolderTree(ctx, req.Namespace) } - if !ok { + if !cacheHit { tree, err = s.buildFolderTree(ctx, req.Namespace) if err != nil { ctxLogger.Error("could not build folder and dashboard tree", "error", err) diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go index e430611f3cd..f30feca4ee2 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go @@ -14,12 +14,10 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/tracing" @@ -63,7 +61,7 @@ func Test_NoopServiceDoesNothing(t *testing.T) { func Test_CreateGetAndDeleteToken(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false) + s := setUpServiceTest(t) createResp, err := s.CreateToken(context.Background()) assert.NoError(t, err) @@ -88,7 +86,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) { t.Parallel() setupTest := func(ctx context.Context) (service *Service, snapshotUID string, sessionUID string) { - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) gmsClientFake := &gmsClientMock{} s.gmsClient = gmsClientFake @@ -365,7 +363,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) { func Test_OnlyQueriesStatusFromGMSWhenRequired(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) gmsClientMock := &gmsClientMock{ getSnapshotResponse: &cloudmigration.GetSnapshotStatusResponse{ @@ -427,14 +425,29 @@ func Test_OnlyQueriesStatusFromGMSWhenRequired(t *testing.T) { Status: status, }) assert.NoError(t, err) - _, err := s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{ + snapshot, err := s.GetSnapshot(context.Background(), cloudmigration.GetSnapshotsQuery{ SnapshotUID: uid, SessionUID: sess.UID, }) assert.NoError(t, err) - require.Eventually(t, func() bool { return gmsClientMock.GetSnapshotStatusCallCount() == i+1 }, time.Second, 10*time.Millisecond) + assert.Equal(t, status, snapshot.Status) + + require.Eventually( + t, + func() bool { return gmsClientMock.GetSnapshotStatusCallCount() == i+1 }, + 2*time.Second, + 100*time.Millisecond, + "GMS client mock GetSnapshotStatus count: %d", gmsClientMock.GetSnapshotStatusCallCount(), + ) } - assert.Never(t, func() bool { return gmsClientMock.GetSnapshotStatusCallCount() > 2 }, time.Second, 10*time.Millisecond) + + assert.Never( + t, + func() bool { return gmsClientMock.GetSnapshotStatusCallCount() > 2 }, + 2*time.Second, + 100*time.Millisecond, + "GMS client mock GetSnapshotStatus called more than expected: %d times", gmsClientMock.GetSnapshotStatusCallCount(), + ) } // Implementation inspired by ChatGPT, OpenAI's language model. @@ -463,7 +476,7 @@ func Test_SortFolders(t *testing.T) { func TestDeleteSession(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{UserUID: "user123"} t.Run("when deleting a session that does not exist in the database, it returns an error", func(t *testing.T) { @@ -515,7 +528,7 @@ func TestReportEvent(t *testing.T) { gmsMock := &gmsClientMock{} - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) s.gmsClient = gmsMock require.NotPanics(t, func() { @@ -533,7 +546,7 @@ func TestReportEvent(t *testing.T) { gmsMock := &gmsClientMock{} - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) s.gmsClient = gmsMock require.NotPanics(t, func() { @@ -547,7 +560,7 @@ func TestReportEvent(t *testing.T) { func TestGetFolderNamesForFolderUIDs(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -616,7 +629,7 @@ func TestGetParentNames(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{OrgID: 1} @@ -705,7 +718,7 @@ func TestGetParentNames(t *testing.T) { func TestGetLibraryElementsCommands(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -771,7 +784,7 @@ func TestIsPublicSignatureType(t *testing.T) { func TestGetPlugins(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -869,7 +882,7 @@ func TestGetPlugins(t *testing.T) { type configOverrides func(c *setting.Cfg) -func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...configOverrides) cloudmigration.Service { +func setUpServiceTest(t *testing.T, cfgOverrides ...configOverrides) cloudmigration.Service { secretsService := secretsfakes.NewFakeSecretsService() rr := routing.NewRouteRegister() tracer := tracing.InitializeTracerForTest() @@ -888,17 +901,6 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...conf cfg.CloudMigration.SnapshotFolder = filepath.Join(os.TempDir(), uuid.NewString()) dashboardService := dashboards.NewFakeDashboardService(t) - if withDashboardMock { - dashboardService.On("GetAllDashboards", mock.Anything).Return( - []*dashboards.Dashboard{ - { - UID: "1", - Data: simplejson.New(), - }, - }, - nil, - ) - } dsService := &datafakes.FakeDataSourceService{ DataSources: []*datasources.DataSource{ diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go index b382035c150..151d55562f9 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt_alerts_test.go @@ -45,7 +45,7 @@ func TestGetAlertMuteTimings(t *testing.T) { t.Run("it returns the mute timings", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations) user := &user.SignedInUser{OrgID: 1} @@ -69,7 +69,7 @@ func TestGetNotificationTemplates(t *testing.T) { t.Run("it returns the notification templates", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{OrgID: 1} @@ -92,7 +92,7 @@ func TestGetContactPoints(t *testing.T) { t.Run("it returns the contact points", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{ OrgID: 1, @@ -115,7 +115,7 @@ func TestGetContactPoints(t *testing.T) { t.Run("it returns an error when user lacks permission to read contact point secrets", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{ OrgID: 1, @@ -144,7 +144,7 @@ func TestGetNotificationPolicies(t *testing.T) { t.Run("it returns the contact points", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{OrgID: 1} @@ -172,7 +172,7 @@ func TestGetAlertRules(t *testing.T) { t.Run("it returns the alert rules", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: alertRulesPermissions}} @@ -191,7 +191,7 @@ func TestGetAlertRules(t *testing.T) { c.CloudMigration.AlertRulesState = setting.GMSAlertRulesPaused } - s := setUpServiceTest(t, false, alertRulesState).(*Service) + s := setUpServiceTest(t, alertRulesState).(*Service) user := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: alertRulesPermissions}} @@ -218,7 +218,7 @@ func TestGetAlertRuleGroups(t *testing.T) { t.Run("it returns the alert rule groups", func(t *testing.T) { t.Parallel() - s := setUpServiceTest(t, false).(*Service) + s := setUpServiceTest(t).(*Service) user := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: alertRulesPermissions}} @@ -257,7 +257,7 @@ func TestGetAlertRuleGroups(t *testing.T) { c.CloudMigration.AlertRulesState = setting.GMSAlertRulesPaused } - s := setUpServiceTest(t, false, alertRulesState).(*Service) + s := setUpServiceTest(t, alertRulesState).(*Service) user := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: alertRulesPermissions}} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9be34ab40c1..beca14a83cc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1677,13 +1677,6 @@ var ( HideFromDocs: true, Owner: awsDatasourcesSquad, }, - { - Name: "localizationForPlugins", - Description: "Enables localization for plugins", - Stage: FeatureStageExperimental, - Owner: grafanaPluginsPlatformSquad, - FrontendOnly: false, - }, { Name: "unifiedNavbars", Description: "Enables unified navbars", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 5f8f337c885..ea46bf6e069 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -217,7 +217,6 @@ unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false, alertingRulePermanentlyDelete,GA,@grafana/alerting-squad,false,false,true alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false -localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,false,false unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index cd03ada2338..b22fc6ec5eb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -878,10 +878,6 @@ const ( // use multi-tenant path for awsTempCredentials FlagMultiTenantTempCredentials = "multiTenantTempCredentials" - // FlagLocalizationForPlugins - // Enables localization for plugins - FlagLocalizationForPlugins = "localizationForPlugins" - // FlagUnifiedNavbars // Enables unified navbars FlagUnifiedNavbars = "unifiedNavbars" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 38b80c8554b..ac862d2312c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2270,7 +2270,8 @@ "metadata": { "name": "localizationForPlugins", "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-31T04:38:38Z" + "creationTimestamp": "2025-03-31T04:38:38Z", + "deletionTimestamp": "2025-09-29T07:10:59Z" }, "spec": { "description": "Enables localization for plugins", diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 3e9751d0ea2..095e3390ce9 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -5,6 +5,7 @@ import ( "strings" ) +//go:generate mockery --name AuthInfoService --structname MockAuthInfoService --outpkg authinfotest --filename auth_info_service_mock.go --output ./authinfotest/ type AuthInfoService interface { GetAuthInfo(ctx context.Context, query *GetAuthInfoQuery) (*UserAuth, error) GetUserLabels(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error) diff --git a/pkg/services/login/authinfotest/auth_info_service_mock.go b/pkg/services/login/authinfotest/auth_info_service_mock.go new file mode 100644 index 00000000000..42f9bd60b7f --- /dev/null +++ b/pkg/services/login/authinfotest/auth_info_service_mock.go @@ -0,0 +1,765 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package authinfotest + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/user" + mock "github.com/stretchr/testify/mock" +) + +// NewMockAuthInfoService creates a new instance of MockAuthInfoService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAuthInfoService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAuthInfoService { + mock := &MockAuthInfoService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockAuthInfoService is an autogenerated mock type for the AuthInfoService type +type MockAuthInfoService struct { + mock.Mock +} + +type MockAuthInfoService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAuthInfoService) EXPECT() *MockAuthInfoService_Expecter { + return &MockAuthInfoService_Expecter{mock: &_m.Mock} +} + +// DeleteUserAuthInfo provides a mock function for the type MockAuthInfoService +func (_mock *MockAuthInfoService) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + ret := _mock.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for DeleteUserAuthInfo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = returnFunc(ctx, userID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockAuthInfoService_DeleteUserAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUserAuthInfo' +type MockAuthInfoService_DeleteUserAuthInfo_Call struct { + *mock.Call +} + +// DeleteUserAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - userID int64 +func (_e *MockAuthInfoService_Expecter) DeleteUserAuthInfo(ctx interface{}, userID interface{}) *MockAuthInfoService_DeleteUserAuthInfo_Call { + return &MockAuthInfoService_DeleteUserAuthInfo_Call{Call: _e.mock.On("DeleteUserAuthInfo", ctx, userID)} +} + +func (_c *MockAuthInfoService_DeleteUserAuthInfo_Call) Run(run func(ctx context.Context, userID int64)) *MockAuthInfoService_DeleteUserAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int64 + if args[1] != nil { + arg1 = args[1].(int64) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAuthInfoService_DeleteUserAuthInfo_Call) Return(err error) *MockAuthInfoService_DeleteUserAuthInfo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockAuthInfoService_DeleteUserAuthInfo_Call) RunAndReturn(run func(ctx context.Context, userID int64) error) *MockAuthInfoService_DeleteUserAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetAuthInfo provides a mock function for the type MockAuthInfoService +func (_mock *MockAuthInfoService) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { + ret := _mock.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetAuthInfo") + } + + var r0 *login.UserAuth + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) (*login.UserAuth, error)); ok { + return returnFunc(ctx, query) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) *login.UserAuth); ok { + r0 = returnFunc(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*login.UserAuth) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *login.GetAuthInfoQuery) error); ok { + r1 = returnFunc(ctx, query) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockAuthInfoService_GetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthInfo' +type MockAuthInfoService_GetAuthInfo_Call struct { + *mock.Call +} + +// GetAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - query *login.GetAuthInfoQuery +func (_e *MockAuthInfoService_Expecter) GetAuthInfo(ctx interface{}, query interface{}) *MockAuthInfoService_GetAuthInfo_Call { + return &MockAuthInfoService_GetAuthInfo_Call{Call: _e.mock.On("GetAuthInfo", ctx, query)} +} + +func (_c *MockAuthInfoService_GetAuthInfo_Call) Run(run func(ctx context.Context, query *login.GetAuthInfoQuery)) *MockAuthInfoService_GetAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *login.GetAuthInfoQuery + if args[1] != nil { + arg1 = args[1].(*login.GetAuthInfoQuery) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAuthInfoService_GetAuthInfo_Call) Return(userAuth *login.UserAuth, err error) *MockAuthInfoService_GetAuthInfo_Call { + _c.Call.Return(userAuth, err) + return _c +} + +func (_c *MockAuthInfoService_GetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error)) *MockAuthInfoService_GetAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetUserLabels provides a mock function for the type MockAuthInfoService +func (_mock *MockAuthInfoService) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { + ret := _mock.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetUserLabels") + } + + var r0 map[int64]string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) (map[int64]string, error)); ok { + return returnFunc(ctx, query) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) map[int64]string); ok { + r0 = returnFunc(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[int64]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, login.GetUserLabelsQuery) error); ok { + r1 = returnFunc(ctx, query) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockAuthInfoService_GetUserLabels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserLabels' +type MockAuthInfoService_GetUserLabels_Call struct { + *mock.Call +} + +// GetUserLabels is a helper method to define mock.On call +// - ctx context.Context +// - query login.GetUserLabelsQuery +func (_e *MockAuthInfoService_Expecter) GetUserLabels(ctx interface{}, query interface{}) *MockAuthInfoService_GetUserLabels_Call { + return &MockAuthInfoService_GetUserLabels_Call{Call: _e.mock.On("GetUserLabels", ctx, query)} +} + +func (_c *MockAuthInfoService_GetUserLabels_Call) Run(run func(ctx context.Context, query login.GetUserLabelsQuery)) *MockAuthInfoService_GetUserLabels_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 login.GetUserLabelsQuery + if args[1] != nil { + arg1 = args[1].(login.GetUserLabelsQuery) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAuthInfoService_GetUserLabels_Call) Return(int64ToString map[int64]string, err error) *MockAuthInfoService_GetUserLabels_Call { + _c.Call.Return(int64ToString, err) + return _c +} + +func (_c *MockAuthInfoService_GetUserLabels_Call) RunAndReturn(run func(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error)) *MockAuthInfoService_GetUserLabels_Call { + _c.Call.Return(run) + return _c +} + +// SetAuthInfo provides a mock function for the type MockAuthInfoService +func (_mock *MockAuthInfoService) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { + ret := _mock.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for SetAuthInfo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.SetAuthInfoCommand) error); ok { + r0 = returnFunc(ctx, cmd) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockAuthInfoService_SetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAuthInfo' +type MockAuthInfoService_SetAuthInfo_Call struct { + *mock.Call +} + +// SetAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - cmd *login.SetAuthInfoCommand +func (_e *MockAuthInfoService_Expecter) SetAuthInfo(ctx interface{}, cmd interface{}) *MockAuthInfoService_SetAuthInfo_Call { + return &MockAuthInfoService_SetAuthInfo_Call{Call: _e.mock.On("SetAuthInfo", ctx, cmd)} +} + +func (_c *MockAuthInfoService_SetAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.SetAuthInfoCommand)) *MockAuthInfoService_SetAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *login.SetAuthInfoCommand + if args[1] != nil { + arg1 = args[1].(*login.SetAuthInfoCommand) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAuthInfoService_SetAuthInfo_Call) Return(err error) *MockAuthInfoService_SetAuthInfo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockAuthInfoService_SetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.SetAuthInfoCommand) error) *MockAuthInfoService_SetAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// UpdateAuthInfo provides a mock function for the type MockAuthInfoService +func (_mock *MockAuthInfoService) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { + ret := _mock.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for UpdateAuthInfo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.UpdateAuthInfoCommand) error); ok { + r0 = returnFunc(ctx, cmd) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockAuthInfoService_UpdateAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAuthInfo' +type MockAuthInfoService_UpdateAuthInfo_Call struct { + *mock.Call +} + +// UpdateAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - cmd *login.UpdateAuthInfoCommand +func (_e *MockAuthInfoService_Expecter) UpdateAuthInfo(ctx interface{}, cmd interface{}) *MockAuthInfoService_UpdateAuthInfo_Call { + return &MockAuthInfoService_UpdateAuthInfo_Call{Call: _e.mock.On("UpdateAuthInfo", ctx, cmd)} +} + +func (_c *MockAuthInfoService_UpdateAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand)) *MockAuthInfoService_UpdateAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *login.UpdateAuthInfoCommand + if args[1] != nil { + arg1 = args[1].(*login.UpdateAuthInfoCommand) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAuthInfoService_UpdateAuthInfo_Call) Return(err error) *MockAuthInfoService_UpdateAuthInfo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockAuthInfoService_UpdateAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error) *MockAuthInfoService_UpdateAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockStore(t interface { + mock.TestingT + Cleanup(func()) +}) *MockStore { + mock := &MockStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockStore is an autogenerated mock type for the Store type +type MockStore struct { + mock.Mock +} + +type MockStore_Expecter struct { + mock *mock.Mock +} + +func (_m *MockStore) EXPECT() *MockStore_Expecter { + return &MockStore_Expecter{mock: &_m.Mock} +} + +// DeleteUserAuthInfo provides a mock function for the type MockStore +func (_mock *MockStore) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + ret := _mock.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for DeleteUserAuthInfo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = returnFunc(ctx, userID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStore_DeleteUserAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUserAuthInfo' +type MockStore_DeleteUserAuthInfo_Call struct { + *mock.Call +} + +// DeleteUserAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - userID int64 +func (_e *MockStore_Expecter) DeleteUserAuthInfo(ctx interface{}, userID interface{}) *MockStore_DeleteUserAuthInfo_Call { + return &MockStore_DeleteUserAuthInfo_Call{Call: _e.mock.On("DeleteUserAuthInfo", ctx, userID)} +} + +func (_c *MockStore_DeleteUserAuthInfo_Call) Run(run func(ctx context.Context, userID int64)) *MockStore_DeleteUserAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int64 + if args[1] != nil { + arg1 = args[1].(int64) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_DeleteUserAuthInfo_Call) Return(err error) *MockStore_DeleteUserAuthInfo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStore_DeleteUserAuthInfo_Call) RunAndReturn(run func(ctx context.Context, userID int64) error) *MockStore_DeleteUserAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetAuthInfo provides a mock function for the type MockStore +func (_mock *MockStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { + ret := _mock.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetAuthInfo") + } + + var r0 *login.UserAuth + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) (*login.UserAuth, error)); ok { + return returnFunc(ctx, query) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) *login.UserAuth); ok { + r0 = returnFunc(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*login.UserAuth) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *login.GetAuthInfoQuery) error); ok { + r1 = returnFunc(ctx, query) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStore_GetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthInfo' +type MockStore_GetAuthInfo_Call struct { + *mock.Call +} + +// GetAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - query *login.GetAuthInfoQuery +func (_e *MockStore_Expecter) GetAuthInfo(ctx interface{}, query interface{}) *MockStore_GetAuthInfo_Call { + return &MockStore_GetAuthInfo_Call{Call: _e.mock.On("GetAuthInfo", ctx, query)} +} + +func (_c *MockStore_GetAuthInfo_Call) Run(run func(ctx context.Context, query *login.GetAuthInfoQuery)) *MockStore_GetAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *login.GetAuthInfoQuery + if args[1] != nil { + arg1 = args[1].(*login.GetAuthInfoQuery) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_GetAuthInfo_Call) Return(userAuth *login.UserAuth, err error) *MockStore_GetAuthInfo_Call { + _c.Call.Return(userAuth, err) + return _c +} + +func (_c *MockStore_GetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error)) *MockStore_GetAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetUserLabels provides a mock function for the type MockStore +func (_mock *MockStore) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { + ret := _mock.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetUserLabels") + } + + var r0 map[int64]string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) (map[int64]string, error)); ok { + return returnFunc(ctx, query) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) map[int64]string); ok { + r0 = returnFunc(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[int64]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, login.GetUserLabelsQuery) error); ok { + r1 = returnFunc(ctx, query) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStore_GetUserLabels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserLabels' +type MockStore_GetUserLabels_Call struct { + *mock.Call +} + +// GetUserLabels is a helper method to define mock.On call +// - ctx context.Context +// - query login.GetUserLabelsQuery +func (_e *MockStore_Expecter) GetUserLabels(ctx interface{}, query interface{}) *MockStore_GetUserLabels_Call { + return &MockStore_GetUserLabels_Call{Call: _e.mock.On("GetUserLabels", ctx, query)} +} + +func (_c *MockStore_GetUserLabels_Call) Run(run func(ctx context.Context, query login.GetUserLabelsQuery)) *MockStore_GetUserLabels_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 login.GetUserLabelsQuery + if args[1] != nil { + arg1 = args[1].(login.GetUserLabelsQuery) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_GetUserLabels_Call) Return(int64ToString map[int64]string, err error) *MockStore_GetUserLabels_Call { + _c.Call.Return(int64ToString, err) + return _c +} + +func (_c *MockStore_GetUserLabels_Call) RunAndReturn(run func(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error)) *MockStore_GetUserLabels_Call { + _c.Call.Return(run) + return _c +} + +// SetAuthInfo provides a mock function for the type MockStore +func (_mock *MockStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { + ret := _mock.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for SetAuthInfo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.SetAuthInfoCommand) error); ok { + r0 = returnFunc(ctx, cmd) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStore_SetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAuthInfo' +type MockStore_SetAuthInfo_Call struct { + *mock.Call +} + +// SetAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - cmd *login.SetAuthInfoCommand +func (_e *MockStore_Expecter) SetAuthInfo(ctx interface{}, cmd interface{}) *MockStore_SetAuthInfo_Call { + return &MockStore_SetAuthInfo_Call{Call: _e.mock.On("SetAuthInfo", ctx, cmd)} +} + +func (_c *MockStore_SetAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.SetAuthInfoCommand)) *MockStore_SetAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *login.SetAuthInfoCommand + if args[1] != nil { + arg1 = args[1].(*login.SetAuthInfoCommand) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_SetAuthInfo_Call) Return(err error) *MockStore_SetAuthInfo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStore_SetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.SetAuthInfoCommand) error) *MockStore_SetAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// UpdateAuthInfo provides a mock function for the type MockStore +func (_mock *MockStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { + ret := _mock.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for UpdateAuthInfo") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *login.UpdateAuthInfoCommand) error); ok { + r0 = returnFunc(ctx, cmd) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStore_UpdateAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAuthInfo' +type MockStore_UpdateAuthInfo_Call struct { + *mock.Call +} + +// UpdateAuthInfo is a helper method to define mock.On call +// - ctx context.Context +// - cmd *login.UpdateAuthInfoCommand +func (_e *MockStore_Expecter) UpdateAuthInfo(ctx interface{}, cmd interface{}) *MockStore_UpdateAuthInfo_Call { + return &MockStore_UpdateAuthInfo_Call{Call: _e.mock.On("UpdateAuthInfo", ctx, cmd)} +} + +func (_c *MockStore_UpdateAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand)) *MockStore_UpdateAuthInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *login.UpdateAuthInfoCommand + if args[1] != nil { + arg1 = args[1].(*login.UpdateAuthInfoCommand) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_UpdateAuthInfo_Call) Return(err error) *MockStore_UpdateAuthInfo_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStore_UpdateAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error) *MockStore_UpdateAuthInfo_Call { + _c.Call.Return(run) + return _c +} + +// NewMockUserProtectionService creates a new instance of MockUserProtectionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockUserProtectionService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockUserProtectionService { + mock := &MockUserProtectionService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockUserProtectionService is an autogenerated mock type for the UserProtectionService type +type MockUserProtectionService struct { + mock.Mock +} + +type MockUserProtectionService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockUserProtectionService) EXPECT() *MockUserProtectionService_Expecter { + return &MockUserProtectionService_Expecter{mock: &_m.Mock} +} + +// AllowUserMapping provides a mock function for the type MockUserProtectionService +func (_mock *MockUserProtectionService) AllowUserMapping(user1 *user.User, authModule string) error { + ret := _mock.Called(user1, authModule) + + if len(ret) == 0 { + panic("no return value specified for AllowUserMapping") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*user.User, string) error); ok { + r0 = returnFunc(user1, authModule) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockUserProtectionService_AllowUserMapping_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllowUserMapping' +type MockUserProtectionService_AllowUserMapping_Call struct { + *mock.Call +} + +// AllowUserMapping is a helper method to define mock.On call +// - user1 *user.User +// - authModule string +func (_e *MockUserProtectionService_Expecter) AllowUserMapping(user1 interface{}, authModule interface{}) *MockUserProtectionService_AllowUserMapping_Call { + return &MockUserProtectionService_AllowUserMapping_Call{Call: _e.mock.On("AllowUserMapping", user1, authModule)} +} + +func (_c *MockUserProtectionService_AllowUserMapping_Call) Run(run func(user1 *user.User, authModule string)) *MockUserProtectionService_AllowUserMapping_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *user.User + if args[0] != nil { + arg0 = args[0].(*user.User) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockUserProtectionService_AllowUserMapping_Call) Return(err error) *MockUserProtectionService_AllowUserMapping_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockUserProtectionService_AllowUserMapping_Call) RunAndReturn(run func(user1 *user.User, authModule string) error) *MockUserProtectionService_AllowUserMapping_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 29f3ca6679b..dc8863e1fb6 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -525,7 +525,7 @@ func determineProvenance(ctx *contextmodel.ReqContext) definitions.Provenance { } func extractExportRequest(c *contextmodel.ReqContext) definitions.ExportQueryParams { - var format = "yaml" + format := "yaml" acceptHeader := c.Req.Header.Get("Accept") if strings.Contains(acceptHeader, "yaml") { @@ -673,11 +673,22 @@ func escapeRuleGroup(group definitions.AlertRuleGroupExport) definitions.AlertRu func escapeRuleNotificationSettings(ns definitions.AlertRuleNotificationSettingsExport) definitions.AlertRuleNotificationSettingsExport { ns.Receiver = addEscapeCharactersToString(ns.Receiver) - for j := range ns.GroupBy { - ns.GroupBy[j] = addEscapeCharactersToString(ns.GroupBy[j]) + if ns.GroupBy != nil { + for j := range *ns.GroupBy { + (*ns.GroupBy)[j] = addEscapeCharactersToString((*ns.GroupBy)[j]) + } } - for k := range ns.MuteTimeIntervals { - ns.MuteTimeIntervals[k] = addEscapeCharactersToString(ns.MuteTimeIntervals[k]) + + if ns.MuteTimeIntervals != nil { + for k := range *ns.MuteTimeIntervals { + (*ns.MuteTimeIntervals)[k] = addEscapeCharactersToString((*ns.MuteTimeIntervals)[k]) + } + } + + if ns.ActiveTimeIntervals != nil { + for k := range *ns.ActiveTimeIntervals { + (*ns.ActiveTimeIntervals)[k] = addEscapeCharactersToString((*ns.ActiveTimeIntervals)[k]) + } } return ns } diff --git a/pkg/services/ngalert/api/api_ruler_export_test.go b/pkg/services/ngalert/api/api_ruler_export_test.go index d9b69d9e1d3..dcdeaf0c436 100644 --- a/pkg/services/ngalert/api/api_ruler_export_test.go +++ b/pkg/services/ngalert/api/api_ruler_export_test.go @@ -201,6 +201,33 @@ func TestExportFromPayload(t *testing.T) { require.Equal(t, `attachment;filename=export.tf`, rc.Resp.Header().Get("Content-Disposition")) }) }) + + t.Run("hcl body with simplified routing is as expected", func(t *testing.T) { + requestFile := "post-rulegroup-simplified-routing.json" + + rawBody, err := testData.ReadFile(path.Join("test-data", requestFile)) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, json.Compact(&buf, rawBody)) + + var body apimodels.PostableRuleGroupConfig + require.NoError(t, json.Unmarshal(buf.Bytes(), &body)) + + expectedResponse, err := testData.ReadFile(path.Join("test-data", strings.Replace(requestFile, ".json", "-export.hcl", 1))) + require.NoError(t, err) + + rc := createRequest() + rc.Req.Form.Set("format", "hcl") + rc.Req.Form.Set("download", "false") + + response := srv.ExportFromPayload(rc, body, folder.UID) + response.WriteTo(rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, string(expectedResponse), string(response.Body())) + require.Equal(t, "text/hcl", rc.Resp.Header().Get("Content-Type")) + }) } func TestExportRules(t *testing.T) { diff --git a/pkg/services/ngalert/api/compat/compat.go b/pkg/services/ngalert/api/compat/compat.go index 9d1796f78f5..eadf40217af 100644 --- a/pkg/services/ngalert/api/compat/compat.go +++ b/pkg/services/ngalert/api/compat/compat.go @@ -483,12 +483,12 @@ func AlertRuleNotificationSettingsExportFromNotificationSettings(ns []models.Not return &definitions.AlertRuleNotificationSettingsExport{ Receiver: m.Receiver, - GroupBy: m.GroupBy, + GroupBy: NilIfEmpty(util.Pointer(m.GroupBy)), GroupWait: toStringIfNotNil(m.GroupWait), GroupInterval: toStringIfNotNil(m.GroupInterval), RepeatInterval: toStringIfNotNil(m.RepeatInterval), - MuteTimeIntervals: m.MuteTimeIntervals, - ActiveTimeIntervals: m.ActiveTimeIntervals, + MuteTimeIntervals: NilIfEmpty(util.Pointer(m.MuteTimeIntervals)), + ActiveTimeIntervals: NilIfEmpty(util.Pointer(m.ActiveTimeIntervals)), } } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-simplified-routing-export.hcl b/pkg/services/ngalert/api/test-data/post-rulegroup-simplified-routing-export.hcl new file mode 100644 index 00000000000..ff3c07ee97e --- /dev/null +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-simplified-routing-export.hcl @@ -0,0 +1,45 @@ +resource "grafana_rule_group" "rule_group_2b12784d0e1454cd" { + org_id = 1 + name = "group_simplified_routing" + folder_uid = "e4584834-1a87-4dff-8913-8a4748dfca79" + interval_seconds = 10 + + rule { + name = "test" + condition = "C" + + data { + ref_id = "A" + + relative_time_range { + from = 600 + to = 0 + } + + datasource_uid = "grafanacloud-prom" + model = "{\"editorMode\":\"code\",\"expr\":\"vector(1)\",\"instant\":true,\"intervalMs\":1000,\"legendFormat\":\"__auto\",\"maxDataPoints\":43200,\"range\":false,\"refId\":\"A\"}" + } + data { + ref_id = "C" + + relative_time_range { + from = 0 + to = 0 + } + + datasource_uid = "__expr__" + model = "{\"conditions\":[{\"evaluator\":{\"params\":[1],\"type\":\"gt\"},\"operator\":{\"type\":\"and\"},\"query\":{\"params\":[\"C\"]},\"reducer\":{\"params\":[],\"type\":\"last\"},\"type\":\"query\"}],\"datasource\":{\"type\":\"__expr__\",\"uid\":\"__expr__\"},\"expression\":\"A\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"C\",\"type\":\"threshold\"}" + } + + no_data_state = "NoData" + exec_err_state = "Error" + for = "1m" + annotations = {} + labels = {} + is_paused = false + + notification_settings { + contact_point = "email" + } + } +} diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-simplified-routing.json b/pkg/services/ngalert/api/test-data/post-rulegroup-simplified-routing.json new file mode 100644 index 00000000000..7cd163d4d35 --- /dev/null +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-simplified-routing.json @@ -0,0 +1,94 @@ +{ + "name": "group_simplified_routing", + "interval": "10s", + "rules": [ + { + "grafana_alert": { + "title": "test", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "grafanacloud-prom", + "model": { + "editorMode": "code", + "expr": "vector(1)", + "instant": true, + "intervalMs": 1000, + "legendFormat": "__auto", + "maxDataPoints": 43200, + "range": false, + "refId": "A" + } + }, + { + "refId": "C", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 1 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "C" + ] + }, + "reducer": { + "params": [], + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "A", + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "C", + "type": "threshold" + } + } + ], + "is_paused": false, + "no_data_state": "NoData", + "exec_err_state": "Error", + "notification_settings": { + "receiver": "email" + }, + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": true + } + }, + "missing_series_evals_to_resolve": 0, + "uid": "alert-with-simplified-routing" + }, + "annotations": {}, + "labels": {}, + "for": "1m", + "keep_firing_for": "0s" + } + ] +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index 0d91080064b..cbfb7adfd24 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -306,13 +306,13 @@ type RelativeTimeRangeExport struct { type AlertRuleNotificationSettingsExport struct { // Field name mismatches with Terraform provider schema are noted where applicable. - Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty" hcl:"contact_point"` // TF -> `contact_point` - GroupBy []string `yaml:"group_by,omitempty" json:"group_by,omitempty" hcl:"group_by"` - GroupWait *string `yaml:"group_wait,omitempty" json:"group_wait,omitempty" hcl:"group_wait,optional"` - GroupInterval *string `yaml:"group_interval,omitempty" json:"group_interval,omitempty" hcl:"group_interval,optional"` - RepeatInterval *string `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty" hcl:"repeat_interval,optional"` - MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty" hcl:"mute_timings"` // TF -> `mute_timings` - ActiveTimeIntervals []string `yaml:"active_time_intervals,omitempty" json:"active_time_intervals,omitempty" hcl:"active_timings"` // TF -> `active_timings` + Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty" hcl:"contact_point"` // TF -> `contact_point` + GroupBy *[]string `yaml:"group_by,omitempty" json:"group_by,omitempty" hcl:"group_by,optional"` + GroupWait *string `yaml:"group_wait,omitempty" json:"group_wait,omitempty" hcl:"group_wait,optional"` + GroupInterval *string `yaml:"group_interval,omitempty" json:"group_interval,omitempty" hcl:"group_interval,optional"` + RepeatInterval *string `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty" hcl:"repeat_interval,optional"` + MuteTimeIntervals *[]string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty" hcl:"mute_timings,optional"` // TF -> `mute_timings` + ActiveTimeIntervals *[]string `yaml:"active_time_intervals,omitempty" json:"active_time_intervals,omitempty" hcl:"active_timings,optional"` // TF -> `active_timings` } // Record is the provisioned export of models.Record. diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 9f101621ef8..514da9be686 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -324,6 +324,15 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte } cleanPermissionsErr := err + if previousConfig != nil { + // If there is a previous configuration, we need to copy its extra configs to the new one. + extraConfigs, err := extractExtraConfigs(previousConfig.AlertmanagerConfiguration) + if err != nil { + return fmt.Errorf("failed to extract extra configs from previous configuration: %w", err) + } + config.ExtraConfigs = extraConfigs + } + if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil { return fmt.Errorf("failed to post process Alertmanager configuration: %w", err) } @@ -572,3 +581,18 @@ func extractReceiverNames(rawConfig string) (sets.Set[string], error) { return receiverNames, nil } + +// extractExtraConfigs extracts encrypted (does not decrypt) extra configurations from the raw Alertmanager config. +func extractExtraConfigs(rawConfig string) ([]definitions.ExtraConfiguration, error) { + // Slimmed down version of the Alertmanager configuration to extract extra configs. + type extraConfigUserConfig struct { + ExtraConfigs []definitions.ExtraConfiguration `yaml:"extra_config,omitempty" json:"extra_config,omitempty"` + } + + cfg := &extraConfigUserConfig{} + if err := json.Unmarshal([]byte(rawConfig), cfg); err != nil { + return nil, fmt.Errorf("unable to parse Alertmanager configuration: %w", err) + } + + return cfg.ExtraConfigs, nil +} diff --git a/pkg/services/ngalert/notifier/alertmanager_config_test.go b/pkg/services/ngalert/notifier/alertmanager_config_test.go index 3b12153da98..f4b06285db5 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_config_test.go @@ -150,6 +150,264 @@ receivers: }) } +func TestMultiOrgAlertmanager_SaveAndApplyAlertmanagerConfiguration(t *testing.T) { + orgID := int64(1) + ctx := context.Background() + + t.Run("SaveAndApplyAlertmanagerConfiguration preserves existing extra configs", func(t *testing.T) { + mam := setupMam(t, nil) + require.NoError(t, mam.LoadAndSyncAlertmanagersForOrgs(ctx)) + + extraConfig := definitions.ExtraConfiguration{ + Identifier: "test-extra-config", + MergeMatchers: amconfig.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "env", Value: "test"}}, + TemplateFiles: map[string]string{"test.tmpl": "{{ define \"test\" }}Test{{ end }}"}, + AlertmanagerConfig: `route: + receiver: extra-receiver +receivers: + - name: extra-receiver`, + } + + err := mam.SaveAndApplyExtraConfiguration(ctx, orgID, extraConfig) + require.NoError(t, err) + + // Verify extra config was saved + gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) + require.NoError(t, err) + require.Len(t, gettableConfig.ExtraConfigs, 1) + require.Equal(t, extraConfig.Identifier, gettableConfig.ExtraConfigs[0].Identifier) + + // Apply a new main configuration + newMainConfig := definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "main-receiver", + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: amconfig.Receiver{ + Name: "main-receiver", + }, + PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{ + { + Name: "main-receiver", + Type: "email", + Settings: definitions.RawMessage(`{"addresses": "me@grafana.com"}`), + }, + }, + }, + }, + }, + }, + } + + err = mam.SaveAndApplyAlertmanagerConfiguration(ctx, orgID, newMainConfig) + require.NoError(t, err) + + // Verify that the extra config is still present after applying the new main config + updatedConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) + require.NoError(t, err) + require.Len(t, updatedConfig.ExtraConfigs, 1) + require.Equal(t, extraConfig.Identifier, updatedConfig.ExtraConfigs[0].Identifier) + require.Equal(t, extraConfig.TemplateFiles, updatedConfig.ExtraConfigs[0].TemplateFiles) + + // Verify the main config was updated + require.Equal(t, "main-receiver", updatedConfig.AlertmanagerConfig.Route.Receiver) + require.Len(t, updatedConfig.AlertmanagerConfig.Receivers, 1) + require.Equal(t, "main-receiver", updatedConfig.AlertmanagerConfig.Receivers[0].Name) + }) + + t.Run("SaveAndApplyAlertmanagerConfiguration handles missing extra_configs field", func(t *testing.T) { + mam := setupMam(t, nil) + require.NoError(t, mam.LoadAndSyncAlertmanagersForOrgs(ctx)) + + // Apply initial config without extra_configs field + initialConfig := definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "initial-receiver", + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: amconfig.Receiver{ + Name: "initial-receiver", + }, + PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{ + { + Name: "initial-receiver", + Type: "email", + Settings: definitions.RawMessage(`{"addresses": "initial@grafana.com"}`), + }, + }, + }, + }, + }, + }, + } + + err := mam.SaveAndApplyAlertmanagerConfiguration(ctx, orgID, initialConfig) + require.NoError(t, err) + + // Apply a new main configuration + newMainConfig := definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "main-receiver", + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: amconfig.Receiver{ + Name: "main-receiver", + }, + PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{ + { + Name: "main-receiver", + Type: "email", + Settings: definitions.RawMessage(`{"addresses": "me@grafana.com"}`), + }, + }, + }, + }, + }, + }, + } + + err = mam.SaveAndApplyAlertmanagerConfiguration(ctx, orgID, newMainConfig) + require.NoError(t, err) + + // Verify that no extra configs are present and main config was updated + updatedConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) + require.NoError(t, err) + require.Len(t, updatedConfig.ExtraConfigs, 0) + require.Equal(t, "main-receiver", updatedConfig.AlertmanagerConfig.Route.Receiver) + }) + + t.Run("SaveAndApplyAlertmanagerConfiguration handles empty extra_configs array", func(t *testing.T) { + mam := setupMam(t, nil) + require.NoError(t, mam.LoadAndSyncAlertmanagersForOrgs(ctx)) + + // Apply initial config with empty extra_configs + initialConfig := definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "initial-receiver", + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: amconfig.Receiver{ + Name: "initial-receiver", + }, + PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{ + { + Name: "initial-receiver", + Type: "email", + Settings: definitions.RawMessage(`{"addresses": "initial@grafana.com"}`), + }, + }, + }, + }, + }, + }, + ExtraConfigs: []definitions.ExtraConfiguration{}, // Empty array + } + + err := mam.SaveAndApplyAlertmanagerConfiguration(ctx, orgID, initialConfig) + require.NoError(t, err) + + // Apply a new main configuration + newMainConfig := definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Receiver: "main-receiver", + }, + }, + Receivers: []*definitions.PostableApiReceiver{ + { + Receiver: amconfig.Receiver{ + Name: "main-receiver", + }, + PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{ + { + Name: "main-receiver", + Type: "email", + Settings: definitions.RawMessage(`{"addresses": "me@grafana.com"}`), + }, + }, + }, + }, + }, + }, + } + + err = mam.SaveAndApplyAlertmanagerConfiguration(ctx, orgID, newMainConfig) + require.NoError(t, err) + + // Verify that no extra configs are present and main config was updated + updatedConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) + require.NoError(t, err) + require.Len(t, updatedConfig.ExtraConfigs, 0) + require.Equal(t, "main-receiver", updatedConfig.AlertmanagerConfig.Route.Receiver) + }) +} + +func TestExtractExtraConfigs(t *testing.T) { + t.Run("extracts extra configs from JSON", func(t *testing.T) { + jsonConfig := `{ + "extra_config": [ + { + "identifier": "test-config", + "merge_matchers": [], + "template_files": {"test.tmpl": "test"}, + "alertmanager_config": "route:\n receiver: test" + } + ] + }` + + extraConfigs, err := extractExtraConfigs(jsonConfig) + require.NoError(t, err) + require.Len(t, extraConfigs, 1) + require.Equal(t, "test-config", extraConfigs[0].Identifier) + }) + + t.Run("handles missing extra_config field", func(t *testing.T) { + jsonConfig := `{"alertmanager_config": {"route": {"receiver": "test"}}}` + + extraConfigs, err := extractExtraConfigs(jsonConfig) + require.NoError(t, err) + require.Len(t, extraConfigs, 0) + }) + + t.Run("handles empty extra_config array", func(t *testing.T) { + jsonConfig := `{"extra_config": []}` + + extraConfigs, err := extractExtraConfigs(jsonConfig) + require.NoError(t, err) + require.Len(t, extraConfigs, 0) + }) + + t.Run("handles null extra_config", func(t *testing.T) { + jsonConfig := `{"extra_config": null}` + + extraConfigs, err := extractExtraConfigs(jsonConfig) + require.NoError(t, err) + require.Len(t, extraConfigs, 0) + }) +} + func TestMultiOrgAlertmanager_DeleteExtraConfiguration(t *testing.T) { orgID := int64(1) diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index 0304eca46f1..a3503e06522 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -11,6 +11,7 @@ import ( "github.com/go-jose/go-jose/v4/jwt" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" @@ -57,8 +58,14 @@ var _ OAuthTokenService = (*Service)(nil) type OAuthTokenService interface { GetCurrentOAuthToken(context.Context, identity.Requester, *auth.UserToken) *oauth2.Token IsOAuthPassThruEnabled(*datasources.DataSource) bool - TryTokenRefresh(context.Context, identity.Requester, *auth.UserToken) (*oauth2.Token, error) - InvalidateOAuthTokens(context.Context, identity.Requester, *auth.UserToken) error + TryTokenRefresh(context.Context, identity.Requester, *TokenRefreshMetadata) (*oauth2.Token, error) + InvalidateOAuthTokens(context.Context, identity.Requester, *TokenRefreshMetadata) error +} + +type TokenRefreshMetadata struct { + ExternalSessionID int64 + AuthModule string + AuthID string } func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg, registerer prometheus.Registerer, @@ -102,51 +109,71 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Request ctxLogger = ctxLogger.New("userID", userID) - if !strings.HasPrefix(usr.GetAuthenticatedBy(), "oauth_") { + tokenRefreshMetadata := &TokenRefreshMetadata{ + ExternalSessionID: 0, + } + var persistedToken *oauth2.Token + // Find the external session associated with the user and session token + // regardless of the improvedExternalSessionHandling feature toggle, + // because Grafana writes and updates both tables to make the switch + // to the new session handling smoother. + externalSession, err := o.getExternalSession(ctx, usr, userID, sessionToken) + if err != nil && !errors.Is(err, auth.ErrExternalSessionNotFound) { + ctxLogger.Error("Failed to get external session", "error", err) + return nil + } + + // If the feature toggle is enabled, an external session is required. + if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) && (externalSession == nil || errors.Is(err, auth.ErrExternalSessionNotFound)) { + ctxLogger.Error("No external session found for user", "userID", userID) + return nil + } + + // externalSession can be nil if Grafana was updated from a version where the + // external session table was not used yet (did not exist) and the user has not logged in since + // the version update (therefore no external session was created for the user yet). + if externalSession != nil { + tokenRefreshMetadata.ExternalSessionID = externalSession.ID + } + + authInfo, err := o.AuthInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{ + UserId: userID, + }) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + ctxLogger.Warn("No AuthInfo found for user", "userID", userID) + return nil + } + + ctxLogger.Error("Failed to fetch AuthInfo for user", "userID", userID, "error", err) + return nil + } + + tokenRefreshMetadata.AuthID = authInfo.AuthId + tokenRefreshMetadata.AuthModule = authInfo.AuthModule + + if !strings.HasPrefix(tokenRefreshMetadata.AuthModule, "oauth_") { ctxLogger.Warn("The specified user's auth provider is not oauth", - "authmodule", usr.GetAuthenticatedBy()) + "authmodule", tokenRefreshMetadata.AuthModule) return nil } - var persistedToken *oauth2.Token if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { - externalSession, err := o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) - if err != nil { - if errors.Is(err, auth.ErrExternalSessionNotFound) { - return nil - } - ctxLogger.Error("Failed to fetch external session", "error", err) - return nil - } - persistedToken = buildOAuthTokenFromExternalSession(externalSession) - - if persistedToken.RefreshToken == "" { - return persistedToken - } } else { - authInfo, ok, _ := o.hasOAuthEntry(ctx, usr) - if !ok { - return nil - } - - if err := checkOAuthRefreshToken(authInfo); err != nil { - if errors.Is(err, ErrNoRefreshTokenFound) { - return buildOAuthTokenFromAuthInfo(authInfo) - } - - return nil - } - persistedToken = buildOAuthTokenFromAuthInfo(authInfo) } + if persistedToken.RefreshToken == "" { + return persistedToken + } + refreshNeeded := needTokenRefresh(ctx, persistedToken) if !refreshNeeded { return persistedToken } - token, err := o.TryTokenRefresh(ctx, usr, sessionToken) + token, err := o.TryTokenRefresh(ctx, usr, tokenRefreshMetadata) if err != nil { if errors.Is(err, ErrNoRefreshTokenFound) { return persistedToken @@ -214,7 +241,7 @@ func (o *Service) hasOAuthEntry(ctx context.Context, usr identity.Requester) (*l // TryTokenRefresh returns an error in case the OAuth token refresh was unsuccessful // It uses a server lock to prevent getting the Refresh Token multiple times for a given User -func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) { +func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, tokenRefreshMetadata *TokenRefreshMetadata) (*oauth2.Token, error) { ctx, span := o.tracer.Start(ctx, "oauthtoken.TryTokenRefresh") defer span.End() @@ -239,14 +266,13 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s ctxLogger = ctxLogger.New("userID", userID) - // get the token's auth provider (f.e. azuread) - currAuthenticator := usr.GetAuthenticatedBy() - if !strings.HasPrefix(currAuthenticator, "oauth") { - ctxLogger.Warn("The specified user's auth provider is not OAuth", "authmodule", currAuthenticator) + if !strings.HasPrefix(tokenRefreshMetadata.AuthModule, "oauth_") { + ctxLogger.Warn("The specified user's auth provider is not oauth", + "authmodule", tokenRefreshMetadata.AuthModule) return nil, nil } - provider := strings.TrimPrefix(currAuthenticator, "oauth_") + provider := strings.TrimPrefix(tokenRefreshMetadata.AuthModule, "oauth_") currentOAuthInfo := o.SocialService.GetOAuthInfoProvider(provider) if currentOAuthInfo == nil { ctxLogger.Warn("OAuth provider not found", "provider", provider) @@ -261,7 +287,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s lockKey := fmt.Sprintf("oauth-refresh-token-%d", userID) if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { - lockKey = fmt.Sprintf("oauth-refresh-token-%d-%d", userID, sessionToken.ExternalSessionId) + lockKey = fmt.Sprintf("oauth-refresh-token-%d-%d", userID, tokenRefreshMetadata.ExternalSessionID) } lockTimeConfig := serverlock.LockTimeConfig{ @@ -290,7 +316,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s var persistedToken *oauth2.Token var externalSession *auth.ExternalSession if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { - externalSession, err = o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) + externalSession, err = o.sessionService.GetExternalSession(ctx, tokenRefreshMetadata.ExternalSessionID) if err != nil { if errors.Is(err, auth.ErrExternalSessionNotFound) { ctxLogger.Error("External session was not found for user", "error", err) @@ -321,7 +347,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s return } - newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr, sessionToken) + newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr, tokenRefreshMetadata) }, retryOpt) if lockErr != nil { ctxLogger.Error("Failed to obtain token refresh lock", "error", lockErr) @@ -330,14 +356,14 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s // Silence ErrNoRefreshTokenFound if errors.Is(cmdErr, ErrNoRefreshTokenFound) { - return nil, nil + return nil, ErrNoRefreshTokenFound } return newToken, cmdErr } // InvalidateOAuthTokens invalidates the OAuth tokens (access_token, refresh_token) and sets the Expiry to default/zero -func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error { +func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, tokenRefreshMetadata *TokenRefreshMetadata) error { userID, err := usr.GetInternalID() if err != nil { logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err) @@ -347,7 +373,7 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques ctxLogger := logger.FromContext(ctx).New("userID", userID) if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { - err := o.sessionService.UpdateExternalSession(ctx, sessionToken.ExternalSessionId, &auth.UpdateExternalSessionCommand{ + err := o.sessionService.UpdateExternalSession(ctx, tokenRefreshMetadata.ExternalSessionID, &auth.UpdateExternalSessionCommand{ Token: &oauth2.Token{}, }) if err != nil { @@ -358,8 +384,8 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques return o.AuthInfoService.UpdateAuthInfo(ctx, &login.UpdateAuthInfoCommand{ UserId: userID, - AuthModule: usr.GetAuthenticatedBy(), - AuthId: usr.GetAuthID(), + AuthModule: tokenRefreshMetadata.AuthModule, + AuthId: tokenRefreshMetadata.AuthID, OAuthToken: &oauth2.Token{ AccessToken: "", RefreshToken: "", @@ -368,13 +394,14 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques }) } -func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) { +func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester, tokenRefreshMetadata *TokenRefreshMetadata) (*oauth2.Token, error) { ctx, span := o.tracer.Start(ctx, "oauthtoken.tryGetOrRefreshOAuthToken") defer span.End() userID, err := usr.GetInternalID() if err != nil { logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err) + span.SetStatus(codes.Error, "Failed to convert user id to int") return nil, err } @@ -382,8 +409,11 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken ctxLogger := logger.FromContext(ctx).New("userID", userID) + // tryGetOrRefreshOAuthToken assumes that the AuthModule has RefreshToken enabled + // which is checked by the caller (TryTokenRefresh) if persistedToken.RefreshToken == "" { - ctxLogger.Warn("No refresh token available", "authmodule", usr.GetAuthenticatedBy()) + ctxLogger.Error("No refresh token available", "authmodule", tokenRefreshMetadata.AuthModule) + span.SetStatus(codes.Error, ErrNoRefreshTokenFound.Error()) return nil, ErrNoRefreshTokenFound } @@ -392,50 +422,44 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken return persistedToken, nil } - authProvider := usr.GetAuthenticatedBy() - connect, err := o.SocialService.GetConnector(authProvider) + connect, err := o.SocialService.GetConnector(tokenRefreshMetadata.AuthModule) if err != nil { - ctxLogger.Error("Failed to get oauth connector", "provider", authProvider, "error", err) + ctxLogger.Error("Failed to get oauth connector", "provider", tokenRefreshMetadata.AuthModule, "error", err) + span.SetStatus(codes.Error, "Failed to get oauth connector: "+err.Error()) return nil, err } - client, err := o.SocialService.GetOAuthHttpClient(authProvider) + client, err := o.SocialService.GetOAuthHttpClient(tokenRefreshMetadata.AuthModule) if err != nil { - ctxLogger.Error("Failed to get oauth http client", "provider", authProvider, "error", err) + ctxLogger.Error("Failed to get oauth http client", "provider", tokenRefreshMetadata.AuthModule, "error", err) + span.SetStatus(codes.Error, "Failed to get oauth http client") return nil, err } ctx = context.WithValue(ctx, oauth2.HTTPClient, client) start := time.Now() // TokenSource handles refreshing the token if it has expired - token, err := connect.TokenSource(ctx, persistedToken).Token() + token, refreshErr := connect.TokenSource(ctx, persistedToken).Token() duration := time.Since(start) - o.tokenRefreshDuration.WithLabelValues(authProvider, fmt.Sprintf("%t", err == nil)).Observe(duration.Seconds()) + o.tokenRefreshDuration.WithLabelValues(tokenRefreshMetadata.AuthModule, fmt.Sprintf("%t", err == nil)).Observe(duration.Seconds()) - if err != nil { + if refreshErr != nil { span.SetAttributes(attribute.Bool("token_refreshed", false)) ctxLogger.Error("Failed to retrieve oauth access token", - "provider", usr.GetAuthenticatedBy(), "error", err) + "provider", tokenRefreshMetadata.AuthModule, "error", refreshErr) // token refresh failed, invalidate the old token - if err := o.InvalidateOAuthTokens(ctx, usr, sessionToken); err != nil { - ctxLogger.Warn("Failed to invalidate OAuth tokens", "authID", usr.GetAuthID(), "error", err) + if err := o.InvalidateOAuthTokens(ctx, usr, tokenRefreshMetadata); err != nil { + ctxLogger.Warn("Failed to invalidate OAuth tokens", "authID", tokenRefreshMetadata.AuthID, "error", err) } - return nil, err + return nil, refreshErr } span.SetAttributes(attribute.Bool("token_refreshed", true)) // If the tokens are not the same, update the entry in the DB if !tokensEq(persistedToken, token) { - updateAuthCommand := &login.UpdateAuthInfoCommand{ - UserId: userID, - AuthModule: usr.GetAuthenticatedBy(), - AuthId: usr.GetAuthID(), - OAuthToken: token, - } - if o.Cfg.Env == setting.Dev { ctxLogger.Debug("Oauth got token", "auth_module", usr.GetAuthenticatedBy(), @@ -446,17 +470,32 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken } if !o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { + updateAuthCommand := &login.UpdateAuthInfoCommand{ + UserId: userID, + AuthModule: tokenRefreshMetadata.AuthModule, + AuthId: tokenRefreshMetadata.AuthID, + OAuthToken: token, + } if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil { - ctxLogger.Error("Failed to update auth info during token refresh", "authID", usr.GetAuthID(), "error", err) + ctxLogger.Error("Failed to update auth info during token refresh", "authID", tokenRefreshMetadata.AuthID, "error", err) + span.SetStatus(codes.Error, "Failed to update auth info during token refresh") return nil, err } } - if err := o.sessionService.UpdateExternalSession(ctx, sessionToken.ExternalSessionId, &auth.UpdateExternalSessionCommand{ - Token: token, - }); err != nil { - ctxLogger.Error("Failed to update external session during token refresh", "error", err) - return nil, err + // Update the external session with the new token if we the user has an external session, + // regardless of the feature flag state to keep the `user_external_session` table in sync. + // ExternalSessionID should always be set except for some edge cases: + // - when Grafana was updated to a version where the `improvedExternalSessionHandling` feature flag + // was enabled after the user logged in + if tokenRefreshMetadata.ExternalSessionID != 0 { + if err := o.sessionService.UpdateExternalSession(ctx, tokenRefreshMetadata.ExternalSessionID, &auth.UpdateExternalSessionCommand{ + Token: token, + }); err != nil { + ctxLogger.Error("Failed to update external session during token refresh", "error", err) + span.SetStatus(codes.Error, "Failed to update external session during token refresh") + return nil, err + } } ctxLogger.Debug("Updated oauth info for user") @@ -502,6 +541,11 @@ func needTokenRefresh(ctx context.Context, persistedToken *oauth2.Token) bool { ctxLogger := logger.FromContext(ctx) + if persistedToken.AccessToken == "" { + ctxLogger.Debug("Access token has been cleared, need to refresh") + return true + } + idTokenExp, err := GetIDTokenExpiry(persistedToken) if err != nil { ctxLogger.Warn("Could not get ID Token expiry", "error", err) @@ -552,22 +596,6 @@ func buildOAuthTokenFromExternalSession(externalSession *auth.ExternalSession) * return token } -func checkOAuthRefreshToken(authInfo *login.UserAuth) error { - if !strings.Contains(authInfo.AuthModule, "oauth") { - logger.Warn("The specified user's auth provider is not oauth", - "authmodule", authInfo.AuthModule, "userid", authInfo.UserId) - return ErrNotAnOAuthProvider - } - - if authInfo.OAuthRefreshToken == "" { - logger.Warn("No refresh token available", - "authmodule", authInfo.AuthModule, "userid", authInfo.UserId) - return ErrNoRefreshTokenFound - } - - return nil -} - // GetIDTokenExpiry extracts the expiry time from the ID token func GetIDTokenExpiry(token *oauth2.Token) (time.Time, error) { idToken, ok := token.Extra("id_token").(string) @@ -601,3 +629,28 @@ func getExpiryWithSkew(expiry time.Time) (adjustedExpiry time.Time, hasTokenExpi hasTokenExpired = adjustedExpiry.Before(time.Now()) return } + +// getExternalSession fetches the external session based on the user and session token. +// When using the render module, it fetches the most recent external session for the user +// since the session token ID is not available. +// For regular users, it uses the session token ID to fetch the external session. +func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester, userID int64, sessionToken *auth.UserToken) (*auth.ExternalSession, error) { + if usr.GetAuthenticatedBy() == login.RenderModule { + // When using render module, we don't have the session token ID, so we need to fetch the most recent session + // entry for the user (as it is done with the old flow). + // In the future, we might want to consider passing the session token ID to the render module to make this more robust. + externalSessions, err := o.sessionService.FindExternalSessions(ctx, &auth.ListExternalSessionQuery{UserID: userID}) + if err != nil { + return nil, err + } + + if len(externalSessions) == 0 || externalSessions[0] == nil { + return nil, auth.ErrExternalSessionNotFound + } + + return externalSessions[0], nil + } + + // For regular users, we use the session token ID to fetch the external session + return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) +} diff --git a/pkg/services/oauthtoken/oauth_token_test.go b/pkg/services/oauthtoken/oauth_token_test.go index 487a4f6426e..2dfc3e6e68e 100644 --- a/pkg/services/oauthtoken/oauth_token_test.go +++ b/pkg/services/oauthtoken/oauth_token_test.go @@ -2,7 +2,6 @@ package oauthtoken import ( "context" - "errors" "testing" "time" @@ -18,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" - "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/authn" @@ -38,69 +36,57 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -type FakeAuthInfoStore struct { - login.Store - ExpectedError error - ExpectedOAuth *login.UserAuth -} +var ( + unexpiredTokenWithoutRefresh = &oauth2.Token{ + AccessToken: "testaccess", + Expiry: time.Now().Add(time.Hour), + TokenType: "Bearer", + } -func (f *FakeAuthInfoStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { - return f.ExpectedOAuth, f.ExpectedError -} + unexpiredTokenWithoutRefreshWithIDToken = unexpiredTokenWithoutRefresh.WithExtra(map[string]interface{}{ + "id_token": UNEXPIRED_ID_TOKEN, + }) -func (f *FakeAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { - return f.ExpectedError -} - -func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { - f.ExpectedOAuth.OAuthAccessToken = cmd.OAuthToken.AccessToken - f.ExpectedOAuth.OAuthExpiry = cmd.OAuthToken.Expiry - f.ExpectedOAuth.OAuthTokenType = cmd.OAuthToken.TokenType - f.ExpectedOAuth.OAuthRefreshToken = cmd.OAuthToken.RefreshToken - return f.ExpectedError -} - -func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.DeleteAuthInfoCommand) error { - return f.ExpectedError -} - -func TestIntegration_TryTokenRefresh(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - unexpiredToken := &oauth2.Token{ + unexpiredToken = &oauth2.Token{ AccessToken: "testaccess", RefreshToken: "testrefresh", Expiry: time.Now().Add(time.Hour), TokenType: "Bearer", } - unexpiredTokenWithIDToken := unexpiredToken.WithExtra(map[string]interface{}{ + + unexpiredTokenWithIDToken = unexpiredToken.WithExtra(map[string]interface{}{ "id_token": UNEXPIRED_ID_TOKEN, }) - expiredToken := &oauth2.Token{ + expiredToken = &oauth2.Token{ AccessToken: "testaccess", RefreshToken: "testrefresh", Expiry: time.Now().Add(-time.Hour), TokenType: "Bearer", } +) - type environment struct { - sessionService *authtest.MockUserAuthTokenService - authInfoService *authinfotest.FakeService - serverLock *serverlock.ServerLockService - socialConnector *socialtest.MockSocialConnector - socialService *socialtest.FakeSocialService +type environment struct { + sessionService *authtest.MockUserAuthTokenService + authInfoService *authinfotest.MockAuthInfoService + serverLock *serverlock.ServerLockService + socialConnector *socialtest.MockSocialConnector + socialService *socialtest.FakeSocialService - store db.DB - service *Service - } + store db.DB + service *Service +} + +func TestIntegration_TryTokenRefresh(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) type testCase struct { - desc string - identity identity.Requester - setup func(env *environment) - expectedToken *oauth2.Token - expectedErr error + desc string + identity identity.Requester + refreshMetadata *TokenRefreshMetadata + setup func(env *environment) + expectedToken *oauth2.Token + expectedErr error } userIdentity := &authn.Identity{ @@ -122,53 +108,74 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { identity: &authn.Identity{ID: "invalid", Type: claims.TypeUser}, }, { - desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedError = errors.New("some error") - }, + desc: "should skip token refresh when no oauth provider was found", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.SAMLAuthModule}, }, { - desc: "should skip token refresh if the user doesn't have an oauth entry", - identity: userIdentity, + desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.SAMLAuthModule, - } - }, - }, - { - desc: "should skip token refresh when no oauth provider was found", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - } - }, - }, - { - desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - } env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: false, } }, }, { - desc: "should skip token refresh when the token is still valid and no id token is present", - identity: userIdentity, + desc: "should skip token refresh if there's an unexpected error while looking up the user auth entry, additionally, no error should be returned", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + }, + { + desc: "should skip token refresh when there is no refresh token and the provider does not require one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: nil, + }, + { + desc: "should return error when there is no refresh token and provider requires one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: "", + OAuthExpiry: expiredToken.Expiry, + }, nil) + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: nil, + expectedErr: ErrNoRefreshTokenFound, + }, + { + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, OAuthExpiry: unexpiredTokenWithIDToken.Expiry, OAuthTokenType: unexpiredTokenWithIDToken.TokenType, - } + }, nil) env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, @@ -177,17 +184,18 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredToken, }, { - desc: "should not refresh the tokens if access token or id token have not expired yet", - identity: userIdentity, + desc: "should not refresh the tokens if access token or id token have not expired yet", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, OAuthIdToken: UNEXPIRED_ID_TOKEN, OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, OAuthExpiry: unexpiredTokenWithIDToken.Expiry, OAuthTokenType: unexpiredTokenWithIDToken.TokenType, - } + }, nil) env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, @@ -196,33 +204,14 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should skip token refresh when there is no refresh token", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, - OAuthRefreshToken: "", - OAuthExpiry: unexpiredTokenWithIDToken.Expiry, - } - env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ - UseRefreshToken: true, - } - }, - expectedToken: &oauth2.Token{ - AccessToken: unexpiredTokenWithIDToken.AccessToken, - RefreshToken: "", - Expiry: unexpiredTokenWithIDToken.Expiry, - }, - }, - { - desc: "should do token refresh when the token is expired", - identity: userIdentity, + desc: "should do token refresh when the token is expired", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, } - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, AuthId: "subject", UserId: 1, @@ -231,7 +220,16 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { OAuthExpiry: expiredToken.Expiry, OAuthTokenType: expiredToken.TokenType, OAuthIdToken: EXPIRED_ID_TOKEN, - } + }, nil) + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() @@ -239,13 +237,14 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should refresh token when the id token is expired", - identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + desc: "should refresh token when the id token is expired", + identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, } - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, AuthId: "subject", UserId: 1, @@ -254,7 +253,16 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { OAuthExpiry: unexpiredTokenWithIDToken.Expiry, OAuthTokenType: unexpiredTokenWithIDToken.TokenType, OAuthIdToken: EXPIRED_ID_TOKEN, - } + }, nil) + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() @@ -262,22 +270,14 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should return ErrRetriesExhausted when lock cannot be acquired", - identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + desc: "should return ErrRetriesExhausted when lock cannot be acquired", + identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, } - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - AuthId: "subject", - UserId: 1234, - OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, - OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, - OAuthExpiry: unexpiredTokenWithIDToken.Expiry, - OAuthTokenType: unexpiredTokenWithIDToken.TokenType, - OAuthIdToken: EXPIRED_ID_TOKEN, - } + _ = env.store.WithDbSession(context.Background(), func(sess *db.Session) error { _, err := sess.Exec(`INSERT INTO server_lock (operation_uid, last_execution, version) VALUES (?, ?, ?)`, "oauth-refresh-token-1234", time.Now().Add(2*time.Second).Unix(), 0) return err @@ -285,6 +285,42 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { }, expectedErr: ErrRetriesExhausted, }, + { + desc: "should be able to refresh token when the caller is render service and the access token is expired", + identity: &authn.Identity{ + AuthenticatedBy: login.RenderModule, + ID: "1", + Type: claims.TypeUser, + }, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(query *login.GetAuthInfoQuery) bool { + return query.UserId == 1 + })).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil).Once() + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { @@ -294,7 +330,7 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { env := environment{ sessionService: authtest.NewMockUserAuthTokenService(t), - authInfoService: &authinfotest.FakeService{}, + authInfoService: authinfotest.NewMockAuthInfoService(t), serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), socialConnector: socialConnector, socialService: &socialtest.FakeSocialService{ @@ -319,7 +355,7 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { ) // token refresh - actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, &usertoken.UserToken{ExternalSessionId: 1}) + actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, tt.refreshMetadata) if tt.expectedErr != nil { assert.ErrorIs(t, err, tt.expectedErr) @@ -347,45 +383,19 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) - unexpiredToken := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "testrefresh", - Expiry: time.Now().Add(time.Hour), - TokenType: "Bearer", - } - unexpiredTokenWithIDToken := unexpiredToken.WithExtra(map[string]interface{}{ - "id_token": UNEXPIRED_ID_TOKEN, - }) - - expiredToken := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "testrefresh", - Expiry: time.Now().Add(-time.Hour), - TokenType: "Bearer", - } - userIdentity := &authn.Identity{ AuthenticatedBy: login.GenericOAuthModule, ID: "1234", Type: claims.TypeUser, } - type environment struct { - sessionService *authtest.MockUserAuthTokenService - serverLock *serverlock.ServerLockService - socialConnector *socialtest.MockSocialConnector - socialService *socialtest.FakeSocialService - - store db.DB - service *Service - } - type testCase struct { - desc string - identity identity.Requester - setup func(env *environment) - expectedToken *oauth2.Token - expectedErr error + desc string + identity identity.Requester + refreshMetadata *TokenRefreshMetadata + setup func(env *environment) + expectedToken *oauth2.Token + expectedErr error } tests := []testCase{ @@ -401,8 +411,14 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { identity: &authn.Identity{ID: "invalid", Type: claims.TypeUser}, }, { - desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned", - identity: userIdentity, + desc: "should skip token refresh when no oauth provider was found", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.SAMLAuthModule}, + }, + { + desc: "should skip token refresh if there's an unexpected error while looking up the external session entry, additionally, no error should be returned", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, assert.AnError).Once() @@ -411,10 +427,11 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { } }, }, - // Kinda impossible to happen, can only happen after the feature is enabled and logged in users don't have their external sessions set + // Edge case, can only happen after the feature is enabled and logged in users don't have their external sessions set { - desc: "should skip token refresh if the user doesn't have an external session", - identity: userIdentity, + desc: "should skip token refresh if the user doesn't have an external session", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, auth.ErrExternalSessionNotFound).Once() @@ -424,15 +441,17 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { }, }, { - desc: "should skip token refresh when no oauth provider was found", - identity: userIdentity, + desc: "should skip token refresh when no oauth provider was found", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = nil }, }, { - desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", - identity: userIdentity, + desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: false, @@ -440,8 +459,9 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { }, }, { - desc: "should skip token refresh when the token is still valid and no id token is present", - identity: userIdentity, + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, @@ -458,8 +478,40 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredToken, }, { - desc: "should not do token refresh if access token or id token have not expired yet", - identity: userIdentity, + desc: "should skip token refresh when there is no refresh token and the provider does not require one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: nil, + }, + { + desc: "should return error when there is no refresh token and provider requires one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1, + AccessToken: expiredToken.AccessToken, + RefreshToken: "", + ExpiresAt: expiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: nil, + expectedErr: ErrNoRefreshTokenFound, + }, + { + desc: "should not do token refresh if access token or id token have not expired yet", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, @@ -477,42 +529,17 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should skip token refresh when there is no refresh token", - identity: userIdentity, - setup: func(env *environment) { - env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ - ID: 1, - UserID: 1, - AccessToken: unexpiredTokenWithIDToken.AccessToken, - RefreshToken: "", - ExpiresAt: unexpiredTokenWithIDToken.Expiry, - }, nil).Once() - - env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ - UseRefreshToken: true, - } - }, - expectedToken: &oauth2.Token{ - AccessToken: unexpiredTokenWithIDToken.AccessToken, - RefreshToken: "", - Expiry: unexpiredTokenWithIDToken.Expiry, - }, - }, - { - desc: "should refresh token when the access token is expired", - identity: &authn.Identity{ - AuthenticatedBy: login.GenericOAuthModule, - ID: "1", - Type: claims.TypeUser, - }, + desc: "should refresh token when the access token is expired", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, UserID: 1, AccessToken: expiredToken.AccessToken, - IDToken: UNEXPIRED_ID_TOKEN, RefreshToken: expiredToken.RefreshToken, ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, }, nil).Once() env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() @@ -526,12 +553,13 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should refresh token when the id token is expired", - identity: userIdentity, + desc: "should refresh token when the id token is expired", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, - UserID: 1, + UserID: 1234, AccessToken: unexpiredTokenWithIDToken.AccessToken, RefreshToken: unexpiredTokenWithIDToken.RefreshToken, ExpiresAt: unexpiredTokenWithIDToken.Expiry, @@ -549,8 +577,38 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should return ErrRetriesExhausted when lock cannot be acquired", - identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + desc: "should be able to refresh token when the caller is render service and the access token is expired", + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + identity: &authn.Identity{ + AuthenticatedBy: login.RenderModule, + ID: "1", + Type: claims.TypeUser, + }, + setup: func(env *environment) { + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1, + AuthModule: login.RenderModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should return ErrRetriesExhausted when lock cannot be acquired", + identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, @@ -572,6 +630,7 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { env := environment{ sessionService: authtest.NewMockUserAuthTokenService(t), + authInfoService: authinfotest.NewMockAuthInfoService(t), serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), socialConnector: socialConnector, socialService: &socialtest.FakeSocialService{ @@ -586,7 +645,7 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { env.service = ProvideService( env.socialService, - nil, + env.authInfoService, setting.NewCfg(), prometheus.NewRegistry(), env.serverLock, @@ -596,7 +655,7 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { ) // token refresh - actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, &usertoken.UserToken{ExternalSessionId: 1}) + actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, tt.refreshMetadata) if tt.expectedErr != nil { assert.ErrorIs(t, err, tt.expectedErr) @@ -635,34 +694,44 @@ func verifyUpdateExternalSessionCommand(token *oauth2.Token) func(*auth.UpdateEx func TestOAuthTokenSync_needTokenRefresh(t *testing.T) { tests := []struct { name string - usr *login.UserAuth + token *oauth2.Token expectedTokenRefreshFlag bool expectedTokenDuration time.Duration }{ { - name: "should not need token refresh when token has no expiration date", - usr: &login.UserAuth{}, + name: "should not need token refresh when token has no expiration date", + token: &oauth2.Token{ + AccessToken: "some_access_token", + Expiry: time.Time{}, + }, expectedTokenRefreshFlag: false, }, { name: "should not need token refresh with an invalid jwt token that might result in an error when parsing", - usr: &login.UserAuth{ - OAuthIdToken: "invalid_jwt_format", - }, + token: (&oauth2.Token{ + AccessToken: "some_access_token", + }).WithExtra(map[string]any{"id_token": "invalid_jwt_format"}), expectedTokenRefreshFlag: false, }, { - name: "should flag token refresh with id token is expired", - usr: &login.UserAuth{ - OAuthIdToken: EXPIRED_ID_TOKEN, + name: "should flag token refresh when access token is empty", + token: &oauth2.Token{ + AccessToken: "", }, expectedTokenRefreshFlag: true, + }, + { + name: "should flag token refresh with id token is expired", + token: (&oauth2.Token{ + AccessToken: "some_access_token"}).WithExtra(map[string]any{"id_token": EXPIRED_ID_TOKEN}), + expectedTokenRefreshFlag: true, expectedTokenDuration: time.Second, }, { name: "should flag token refresh when expiry date is zero", - usr: &login.UserAuth{ - OAuthExpiry: time.Unix(0, 0), + token: &oauth2.Token{ + AccessToken: "some_access_token", + Expiry: time.Unix(0, 0), }, expectedTokenRefreshFlag: true, expectedTokenDuration: time.Second, @@ -670,10 +739,686 @@ func TestOAuthTokenSync_needTokenRefresh(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - token := buildOAuthTokenFromAuthInfo(tt.usr) - needsTokenRefresh := needTokenRefresh(context.Background(), token) + needsTokenRefresh := needTokenRefresh(context.Background(), tt.token) assert.Equal(t, tt.expectedTokenRefreshFlag, needsTokenRefresh) }) } } + +func TestIntegration_GetCurrentOAuthToken(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + type testCase struct { + desc string + identity identity.Requester + sessionToken *auth.UserToken + setup func(env *environment) + expectedToken *oauth2.Token + } + + userIdentity := &authn.Identity{ + AuthenticatedBy: login.GenericOAuthModule, + ID: "1234", + Type: claims.TypeUser, + } + + tests := []testCase{ + { + desc: "should return nil when identity is nil", + identity: nil, + expectedToken: nil, + }, + { + desc: "should return nil when identity is not a user", + identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount}, + expectedToken: nil, + }, + { + desc: "should refresh token for render service user", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 1, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + }, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token for render service user with multiple external sessions", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + // Return multiple external sessions, the most recent one is returned first by the query + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 2, // newer session + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + { + ID: 1, // older session + UserID: 1, + AuthModule: login.GenericOAuthModule, + }}, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(2), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: unexpiredToken.AccessToken, + OAuthRefreshToken: unexpiredToken.RefreshToken, + OAuthExpiry: unexpiredToken.Expiry, + OAuthTokenType: unexpiredToken.TokenType, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredToken, + }, + { + desc: "should not do token refresh if access token or id token have not expired yet", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: UNEXPIRED_ID_TOKEN, + OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, + OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, + OAuthExpiry: unexpiredTokenWithIDToken.Expiry, + OAuthTokenType: unexpiredTokenWithIDToken.TokenType, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should return the unexpired access and id token when token refresh is disabled", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: UNEXPIRED_ID_TOKEN, + OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, + OAuthExpiry: unexpiredTokenWithIDToken.Expiry, + OAuthTokenType: unexpiredTokenWithIDToken.TokenType, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: unexpiredTokenWithoutRefreshWithIDToken, + }, + // Edge case, can only happen after the feature is enabled and logged in users don't have their external sessions set, + { + desc: "should refresh token when the access token is expired and the external session was not found", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1234, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, auth.ErrExternalSessionNotFound).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the access token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1234, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the id token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1234, + OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, + OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, + OAuthExpiry: unexpiredTokenWithIDToken.Expiry, + OAuthTokenType: unexpiredTokenWithIDToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + socialConnector := socialtest.NewMockSocialConnector(t) + store := db.InitTestDB(t) + features := featuremgmt.WithFeatures() + + env := environment{ + sessionService: authtest.NewMockUserAuthTokenService(t), + authInfoService: authinfotest.NewMockAuthInfoService(t), + serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), + socialConnector: socialConnector, + socialService: &socialtest.FakeSocialService{ + ExpectedConnector: socialConnector, + }, + store: store, + } + + if tt.setup != nil { + tt.setup(&env) + } + + env.service = ProvideService( + env.socialService, + env.authInfoService, + setting.NewCfg(), + prometheus.NewRegistry(), + env.serverLock, + tracing.InitializeTracerForTest(), + env.sessionService, + features, + ) + + actualToken := env.service.GetCurrentOAuthToken(context.Background(), tt.identity, tt.sessionToken) + + if tt.expectedToken == nil { + assert.Nil(t, actualToken) + return + } + + assert.NotNil(t, actualToken) + assert.Equal(t, tt.expectedToken.AccessToken, actualToken.AccessToken) + assert.Equal(t, tt.expectedToken.RefreshToken, actualToken.RefreshToken) + assert.WithinDuration(t, tt.expectedToken.Expiry, actualToken.Expiry, time.Second) + assert.Equal(t, tt.expectedToken.TokenType, actualToken.TokenType) + if tt.expectedToken.Extra("id_token") != nil { + assert.Equal(t, tt.expectedToken.Extra("id_token"), actualToken.Extra("id_token")) + } else { + assert.Nil(t, actualToken.Extra("id_token")) + } + }) + } +} + +func TestIntegration_GetCurrentOAuthToken_WithExternalSessions(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + type testCase struct { + desc string + identity identity.Requester + sessionToken *auth.UserToken + setup func(env *environment) + expectedToken *oauth2.Token + } + + userIdentity := &authn.Identity{ + AuthenticatedBy: login.GenericOAuthModule, + ID: "1234", + Type: claims.TypeUser, + } + + tests := []testCase{ + { + desc: "should return nil when identity is nil", + identity: nil, + expectedToken: nil, + }, + { + desc: "should return nil when identity is not a user", + identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount}, + expectedToken: nil, + }, + { + desc: "should refresh token for render service user", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(3)).Return(&auth.ExternalSession{ + ID: 3, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Once() + + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 3, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + }, nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(3), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token for render service user with multiple external sessions", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + // Return multiple external sessions, the most recent one is returned first by the query + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 2, // newer session + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + { + ID: 1, // older session + UserID: 1, + AuthModule: login.GenericOAuthModule, + }}, nil).Once() + + env.sessionService.On("GetExternalSession", mock.Anything, int64(2)).Return(&auth.ExternalSession{ + ID: 2, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(2), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredToken, + }, + { + desc: "should return the unexpired access and id token when token refresh is disabled", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredTokenWithIDToken.AccessToken, + ExpiresAt: unexpiredTokenWithIDToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: unexpiredTokenWithoutRefreshWithIDToken, + }, + { + desc: "should not do token refresh if access token or id token have not expired yet", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredTokenWithIDToken.AccessToken, + RefreshToken: unexpiredTokenWithIDToken.RefreshToken, + ExpiresAt: unexpiredTokenWithIDToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the access token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Twice() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the id token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Twice() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + socialConnector := socialtest.NewMockSocialConnector(t) + store := db.InitTestDB(t) + features := featuremgmt.WithFeatures(featuremgmt.FlagImprovedExternalSessionHandling) + + env := environment{ + sessionService: authtest.NewMockUserAuthTokenService(t), + authInfoService: authinfotest.NewMockAuthInfoService(t), + serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), + socialConnector: socialConnector, + socialService: &socialtest.FakeSocialService{ + ExpectedConnector: socialConnector, + }, + store: store, + } + + if tt.setup != nil { + tt.setup(&env) + } + + env.service = ProvideService( + env.socialService, + env.authInfoService, + setting.NewCfg(), + prometheus.NewRegistry(), + env.serverLock, + tracing.InitializeTracerForTest(), + env.sessionService, + features, + ) + + actualToken := env.service.GetCurrentOAuthToken(context.Background(), tt.identity, tt.sessionToken) + + if tt.expectedToken == nil { + assert.Nil(t, actualToken) + return + } + + assert.NotNil(t, actualToken) + assert.Equal(t, tt.expectedToken.AccessToken, actualToken.AccessToken) + assert.Equal(t, tt.expectedToken.RefreshToken, actualToken.RefreshToken) + assert.WithinDuration(t, tt.expectedToken.Expiry, actualToken.Expiry, time.Second) + if tt.expectedToken.Extra("id_token") != nil { + assert.Equal(t, tt.expectedToken.Extra("id_token"), actualToken.Extra("id_token")) + } else { + assert.Nil(t, actualToken.Extra("id_token")) + } + }) + } +} diff --git a/pkg/services/oauthtoken/oauthtokentest/mock.go b/pkg/services/oauthtoken/oauthtokentest/mock.go index 39e2f6d8fd9..b9319461480 100644 --- a/pkg/services/oauthtoken/oauthtokentest/mock.go +++ b/pkg/services/oauthtoken/oauthtokentest/mock.go @@ -8,13 +8,14 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/oauthtoken" ) type MockOauthTokenService struct { GetCurrentOauthTokenFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token IsOAuthPassThruEnabledFunc func(ds *datasources.DataSource) bool - InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error - TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) + InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) error + TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) } func (m *MockOauthTokenService) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token { @@ -31,16 +32,16 @@ func (m *MockOauthTokenService) IsOAuthPassThruEnabled(ds *datasources.DataSourc return false } -func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error { +func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) error { if m.InvalidateOAuthTokensFunc != nil { - return m.InvalidateOAuthTokensFunc(ctx, usr, sessionToken) + return m.InvalidateOAuthTokensFunc(ctx, usr, metadata) } return nil } -func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) { +func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) { if m.TryTokenRefreshFunc != nil { - return m.TryTokenRefreshFunc(ctx, usr, sessionToken) + return m.TryTokenRefreshFunc(ctx, usr, metadata) } return nil, nil } diff --git a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go index 8c58b43d232..a8a6eeafeae 100644 --- a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go +++ b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go @@ -29,10 +29,10 @@ func (s *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool { return oauthtoken.IsOAuthPassThruEnabled(ds) } -func (s *Service) TryTokenRefresh(context.Context, identity.Requester, *auth.UserToken) (*oauth2.Token, error) { +func (s *Service) TryTokenRefresh(context.Context, identity.Requester, *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) { return s.Token, nil } -func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester, *auth.UserToken) error { +func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester, *oauthtoken.TokenRefreshMetadata) error { return nil } diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go index 0b39e4c9f30..0d26718170e 100644 --- a/pkg/services/queryhistory/api.go +++ b/pkg/services/queryhistory/api.go @@ -168,6 +168,16 @@ func (s *QueryHistoryService) starHandler(c *contextmodel.ReqContext) response.R if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } + if s.k8sClients != nil { + if err := s.k8sClients.AddStar(c, queryUID); err != nil { + return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) + } + return response.JSON(http.StatusOK, QueryHistoryResponse{ + Result: QueryHistoryDTO{ + UID: queryUID, + Starred: true, + }}) + } query, err := s.StarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) if err != nil { @@ -192,6 +202,16 @@ func (s *QueryHistoryService) unstarHandler(c *contextmodel.ReqContext) response if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } + if s.k8sClients != nil { + if err := s.k8sClients.RemoveStar(c, queryUID); err != nil { + return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) + } + return response.JSON(http.StatusOK, QueryHistoryResponse{ + Result: QueryHistoryDTO{ + UID: queryUID, + Starred: true, + }}) + } query, err := s.UnstarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) if err != nil { diff --git a/pkg/services/queryhistory/client.go b/pkg/services/queryhistory/client.go new file mode 100644 index 00000000000..a134ef9e186 --- /dev/null +++ b/pkg/services/queryhistory/client.go @@ -0,0 +1,103 @@ +package queryhistory + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + authlib "github.com/grafana/authlib/types" + preferencesV1 "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" +) + +type k8sClients struct { + namespacer authlib.NamespaceFormatter + configProvider apiserver.DirectRestConfigProvider +} + +// GetStars implements K8sClients. +func (k *k8sClients) GetStars(c *contextmodel.ReqContext) ([]string, error) { + dyn, err := dynamic.NewForConfig(k.configProvider.GetDirectRestConfig(c)) + if err != nil { + return nil, err + } + client := dyn.Resource(preferencesV1.StarsResourceInfo.GroupVersionResource()).Namespace(k.namespacer(c.OrgID)) + + ctx := c.Req.Context() + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + obj, _ := client.Get(ctx, "user-"+user.GetIdentifier(), v1.GetOptions{}) + if obj != nil { + resources, ok, _ := unstructured.NestedSlice(obj.Object, "spec", "resource") + if ok && resources != nil { + for _, r := range resources { + tmp, ok := r.(map[string]any) + if ok { + g, _, _ := unstructured.NestedString(tmp, "group") + k, _, _ := unstructured.NestedString(tmp, "kind") + if k == "Query" && g == "history.grafana.app" { + names, _, _ := unstructured.NestedStringSlice(tmp, "names") + return names, nil + } + } + } + } + } + return []string{}, nil +} + +// AddStar implements K8sClients. +func (k *k8sClients) AddStar(c *contextmodel.ReqContext, uid string) error { + dyn, err := kubernetes.NewForConfig(k.configProvider.GetDirectRestConfig(c)) + if err != nil { + return err + } + + ctx := c.Req.Context() + user, err := identity.GetRequester(ctx) + if err != nil { + return err + } + + ns := k.namespacer(c.OrgID) + + client := dyn.RESTClient() + rsp := client.Put().AbsPath( + "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, + "stars", "user-"+user.GetIdentifier(), + "update", "history.grafana.app", "Query", uid, + ).Do(ctx) + + return rsp.Error() +} + +// RemoveStar implements K8sClients. +func (k *k8sClients) RemoveStar(c *contextmodel.ReqContext, uid string) error { + dyn, err := kubernetes.NewForConfig(k.configProvider.GetDirectRestConfig(c)) + if err != nil { + return err + } + + ctx := c.Req.Context() + user, err := identity.GetRequester(ctx) + if err != nil { + return err + } + + ns := k.namespacer(c.OrgID) + + client := dyn.RESTClient() + rsp := client.Delete().AbsPath( + "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, + "stars", "user-"+user.GetIdentifier(), + "update", "history.grafana.app", "Query", uid, + ).Do(ctx) + + return rsp.Error() +} diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go index d2f1e90e8bc..6e7cff4ad85 100644 --- a/pkg/services/queryhistory/queryhistory.go +++ b/pkg/services/queryhistory/queryhistory.go @@ -8,11 +8,20 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, accessControl ac.AccessControl) *QueryHistoryService { +func ProvideService(cfg *setting.Cfg, + sqlStore db.DB, + routeRegister routing.RouteRegister, + accessControl ac.AccessControl, + features featuremgmt.FeatureToggles, + configProvider apiserver.DirectRestConfigProvider, +) *QueryHistoryService { s := &QueryHistoryService{ store: sqlStore, Cfg: cfg, @@ -24,6 +33,12 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout // Register routes only when query history is enabled if s.Cfg.QueryHistoryEnabled { + if features.IsEnabledGlobally(featuremgmt.FlagKubernetesStars) { + s.k8sClients = &k8sClients{ + namespacer: request.GetNamespaceMapper(s.Cfg), + configProvider: configProvider, + } + } s.registerAPIEndpoints() } @@ -48,6 +63,7 @@ type QueryHistoryService struct { log log.Logger now func() time.Time accessControl ac.AccessControl + k8sClients *k8sClients } func (s QueryHistoryService) CreateQueryInQueryHistory(ctx context.Context, user *user.SignedInUser, cmd CreateQueryInQueryHistoryCommand) (QueryHistoryDTO, error) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 11071c2a9fc..89a42e9d56d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -582,6 +582,7 @@ type Cfg struct { IndexMinCount int IndexRebuildInterval time.Duration IndexCacheTTL time.Duration + IndexMinUpdateInterval time.Duration // Don't update index if it was updated less than this interval ago. MaxFileIndexAge time.Duration // Max age of file-based indexes. Index older than this will be rebuilt asynchronously. MinFileIndexBuildVersion string // Minimum version of Grafana that built the file-based index. If index was built with older Grafana, it will be rebuilt asynchronously. EnableSharding bool diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 0ebb6c93830..914d5f789d7 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -73,6 +73,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // default to 24 hours because usage insights summarizes the data every 24 hours cfg.IndexRebuildInterval = section.Key("index_rebuild_interval").MustDuration(24 * time.Hour) cfg.IndexCacheTTL = section.Key("index_cache_ttl").MustDuration(10 * time.Minute) + cfg.IndexMinUpdateInterval = section.Key("index_min_update_interval").MustDuration(0) cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String() cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(10000) cfg.CACertPath = section.Key("ca_cert_path").String() diff --git a/pkg/storage/unified/resource/bulk.go b/pkg/storage/unified/resource/bulk.go index 842d6638f5c..370f1be71ef 100644 --- a/pkg/storage/unified/resource/bulk.go +++ b/pkg/storage/unified/resource/bulk.go @@ -170,9 +170,9 @@ func (s *server) BulkProcess(stream resourcepb.BulkStore_BulkProcessServer) erro }) } - // Verify all request keys are valid + // Verify all collection request keys are valid for _, k := range settings.Collection { - if r := verifyRequestKey(k); r != nil { + if r := verifyRequestKeyCollection(k); r != nil { return sendAndClose(&resourcepb.BulkResponse{ Error: &resourcepb.ErrorResult{ Message: fmt.Sprintf("invalid request key: %s", r.Message), diff --git a/pkg/storage/unified/resource/keys.go b/pkg/storage/unified/resource/keys.go index a9a5c4cabeb..324a2128633 100644 --- a/pkg/storage/unified/resource/keys.go +++ b/pkg/storage/unified/resource/keys.go @@ -8,7 +8,24 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) +// verifyRequestKey verifies that the key is valid for a request (all fields set and valid, including name) func verifyRequestKey(key *resourcepb.ResourceKey) *resourcepb.ErrorResult { + if err := verifyRequestKeyNamespaceGroupResource(key); err != nil { + return NewBadRequestError(err.Message) + } + if err := validation.IsValidGrafanaName(key.Name); err != nil { + return NewBadRequestError(err[0]) + } + return nil +} + +// verifyRequestKeyCollection verifies that the key is valid for a collection (namespace/group/resource set and valid) +func verifyRequestKeyCollection(key *resourcepb.ResourceKey) *resourcepb.ErrorResult { + return verifyRequestKeyNamespaceGroupResource(key) +} + +// verifyRequestKeyNamespaceGroupResource verifies that the key has namespace/group/resource set and valid +func verifyRequestKeyNamespaceGroupResource(key *resourcepb.ResourceKey) *resourcepb.ErrorResult { if key == nil { return NewBadRequestError("missing resource key") } @@ -27,9 +44,6 @@ func verifyRequestKey(key *resourcepb.ResourceKey) *resourcepb.ErrorResult { if err := validation.IsValidateResource(key.Resource); err != nil { return NewBadRequestError(err[0]) } - if err := validation.IsValidGrafanaName(key.Name); err != nil { - return NewBadRequestError(err[0]) - } return nil } diff --git a/pkg/storage/unified/resource/keys_test.go b/pkg/storage/unified/resource/keys_test.go index c53678cdcb6..c7e70dfe4b2 100644 --- a/pkg/storage/unified/resource/keys_test.go +++ b/pkg/storage/unified/resource/keys_test.go @@ -181,3 +181,88 @@ func TestVerifyRequestKey(t *testing.T) { }) } } + +func TestVerifyRequestKeyCollection(t *testing.T) { + validGroup := "group.grafana.app" + validResource := "resource" + validNamespace := "default" + invalidName := " " // only spaces + + invalidGroup := "group.~~~~~grafana.app" + invalidResource := "##resource" + invalidNamespace := "(((((default" + + namespaceTooLong := strings.Repeat("a", 61) + + tests := []struct { + name string + input *resourcepb.ResourceKey + expectedCode int32 + }{ + { + name: "no error when all fields are set and valid", + input: &resourcepb.ResourceKey{ + Namespace: validNamespace, + Group: validGroup, + Resource: validResource, + }, + }, + { + name: "invalid namespace returns error", + input: &resourcepb.ResourceKey{ + Namespace: invalidNamespace, + Group: validGroup, + Resource: validResource, + }, + expectedCode: http.StatusBadRequest, + }, + { + name: "invalid group returns error", + input: &resourcepb.ResourceKey{ + Namespace: validNamespace, + Group: invalidGroup, + Resource: validResource, + }, + expectedCode: http.StatusBadRequest, + }, + { + name: "invalid resource returns error", + input: &resourcepb.ResourceKey{ + Namespace: validNamespace, + Group: validGroup, + Resource: invalidResource, + }, + expectedCode: http.StatusBadRequest, + }, + { + name: "invalid name returns no error", + input: &resourcepb.ResourceKey{ + Namespace: validNamespace, + Group: validGroup, + Resource: validResource, + Name: invalidName, + }, + }, + { + name: "namespace too long returns error", + input: &resourcepb.ResourceKey{ + Namespace: namespaceTooLong, + Group: validGroup, + Resource: validResource, + }, + expectedCode: http.StatusBadRequest, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyRequestKeyCollection(test.input) + if test.expectedCode == 0 { + require.Nil(t, err) + return + } + + require.Equal(t, test.expectedCode, err.Code) + }) + } +} diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index bc85a0a5a8a..50541be922f 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -88,7 +88,7 @@ type ResourceIndex interface { // UpdateIndex updates the index with the latest data (using update function provided when index was built) to guarantee strong consistency during the search. // Returns RV to which index was updated. - UpdateIndex(ctx context.Context, reason string) (int64, error) + UpdateIndex(ctx context.Context) (int64, error) // BuildInfo returns build information about the index. BuildInfo() (IndexBuildInfo, error) @@ -102,7 +102,7 @@ type UpdateFn func(context context.Context, index ResourceIndex, sinceRV int64) // SearchBackend contains the technology specific logic to support search type SearchBackend interface { // GetIndex returns existing index, or nil. - GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) + GetIndex(key NamespacedResource) ResourceIndex // BuildIndex builds an index from scratch. // Depending on the size, the backend may choose different options (eg: memory vs disk). @@ -540,23 +540,18 @@ func (s *searchSupport) runPeriodicScanForIndexesToRebuild(ctx context.Context) s.log.Info("stopping periodic index rebuild due to context cancellation") return case <-ticker.C: - s.findIndexesToRebuild(ctx, time.Now()) + s.findIndexesToRebuild(time.Now()) } } } -func (s *searchSupport) findIndexesToRebuild(ctx context.Context, now time.Time) { +func (s *searchSupport) findIndexesToRebuild(now time.Time) { // Check all open indexes and see if any of them need to be rebuilt. // This is done periodically to make sure that the indexes are up to date. keys := s.search.GetOpenIndexes() for _, key := range keys { - idx, err := s.search.GetIndex(ctx, key) - if err != nil { - s.log.Error("failed to check index to rebuild", "key", key, "error", err) - continue - } - + idx := s.search.GetIndex(key) if idx == nil { // This can happen if index was closed in the meantime. continue @@ -618,13 +613,7 @@ func (s *searchSupport) rebuildIndex(ctx context.Context, req rebuildRequest) { l := s.log.With("namespace", req.Namespace, "group", req.Group, "resource", req.Resource) - idx, err := s.search.GetIndex(ctx, req.NamespacedResource) - if err != nil { - span.RecordError(err) - l.Error("failed to get index to rebuild", "error", err) - return - } - + idx := s.search.GetIndex(req.NamespacedResource) if idx == nil { span.AddEvent("index not found") l.Error("index not found") @@ -716,11 +705,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso attribute.String("namespace", key.Namespace), ) - idx, err := s.search.GetIndex(ctx, key) - if err != nil { - return nil, tracing.Error(span, err) - } - + idx := s.search.GetIndex(key) if idx == nil { span.AddEvent("Building index") ch := s.buildIndex.DoChan(key.String(), func() (interface{}, error) { @@ -730,8 +715,8 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso // Recheck if some other goroutine managed to build an index in the meantime. // (That is, it finished running this function and stored the index into the cache) - idx, err := s.search.GetIndex(ctx, key) - if err == nil && idx != nil { + idx := s.search.GetIndex(key) + if idx != nil { return idx, nil } @@ -773,7 +758,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso span.AddEvent("Updating index") start := time.Now() - rv, err := idx.UpdateIndex(ctx, reason) + rv, err := idx.UpdateIndex(ctx) if err != nil { return nil, tracing.Error(span, fmt.Errorf("failed to update index to guarantee strong consistency: %w", err)) } diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index 270719af2a7..34c9ee6927c 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -31,7 +31,7 @@ type MockResourceIndex struct { updateIndexError error updateIndexMu sync.Mutex - updateIndexCalls []string + updateIndexCalls int buildInfo IndexBuildInfo } @@ -65,11 +65,11 @@ func (m *MockResourceIndex) ListManagedObjects(ctx context.Context, req *resourc return args.Get(0).(*resourcepb.ListManagedObjectsResponse), args.Error(1) } -func (m *MockResourceIndex) UpdateIndex(ctx context.Context, reason string) (int64, error) { +func (m *MockResourceIndex) UpdateIndex(_ context.Context) (int64, error) { m.updateIndexMu.Lock() defer m.updateIndexMu.Unlock() - m.updateIndexCalls = append(m.updateIndexCalls, reason) + m.updateIndexCalls++ return 0, m.updateIndexError } @@ -144,10 +144,10 @@ type buildIndexCall struct { fields SearchableDocumentFields } -func (m *mockSearchBackend) GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { +func (m *mockSearchBackend) GetIndex(key NamespacedResource) ResourceIndex { m.mu.Lock() defer m.mu.Unlock() - return m.cache[key], nil + return m.cache[key] } func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, fields SearchableDocumentFields, reason string, builder BuildFn, updater UpdateFn, rebuild bool) (ResourceIndex, error) { @@ -271,24 +271,24 @@ func TestSearchGetOrCreateIndexWithIndexUpdate(t *testing.T) { idx, err := support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "initial call") require.NoError(t, err) require.NotNil(t, idx) - checkMockIndexUpdateCalls(t, idx, []string{"initial call"}) + checkMockIndexUpdateCalls(t, idx, 1) idx, err = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "second call") require.NoError(t, err) require.NotNil(t, idx) - checkMockIndexUpdateCalls(t, idx, []string{"initial call", "second call"}) + checkMockIndexUpdateCalls(t, idx, 2) idx, err = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "bad"}, "call to bad index") require.ErrorIs(t, err, failedErr) require.Nil(t, idx) } -func checkMockIndexUpdateCalls(t *testing.T, idx ResourceIndex, strings []string) { +func checkMockIndexUpdateCalls(t *testing.T, idx ResourceIndex, calls int) { mi, ok := idx.(*MockResourceIndex) require.True(t, ok) mi.updateIndexMu.Lock() defer mi.updateIndexMu.Unlock() - require.Equal(t, strings, mi.updateIndexCalls) + require.Equal(t, calls, mi.updateIndexCalls) } func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { @@ -333,8 +333,8 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { // Wait until new index is put into cache. require.Eventually(t, func() bool { - idx, err := support.search.GetIndex(ctx, key) - return err == nil && idx != nil + idx := support.search.GetIndex(key) + return idx != nil }, 1*time.Second, 100*time.Millisecond, "Indexing finishes despite context cancellation") // Second call to getOrCreateIndex returns index immediately, even if context is canceled, as the index is now ready and cached. @@ -347,10 +347,10 @@ type slowSearchBackendWithCache struct { wg sync.WaitGroup } -func (m *slowSearchBackendWithCache) GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { +func (m *slowSearchBackendWithCache) GetIndex(key NamespacedResource) ResourceIndex { m.mu.Lock() defer m.mu.Unlock() - return m.cache[key], nil + return m.cache[key] } func (m *slowSearchBackendWithCache) BuildIndex(ctx context.Context, key NamespacedResource, size int64, fields SearchableDocumentFields, reason string, builder BuildFn, updater UpdateFn, rebuild bool) (ResourceIndex, error) { @@ -573,14 +573,14 @@ func TestFindIndexesForRebuild(t *testing.T) { require.NoError(t, err) require.NotNil(t, support) - support.findIndexesToRebuild(context.Background(), now) + support.findIndexesToRebuild(now) require.Equal(t, 6, support.rebuildQueue.Len()) now5m := now.Add(5 * time.Minute) // Running findIndexesToRebuild again should not add any new indexes to the rebuild queue, and all existing // ones should be "combined" with new ones (this will "bump" minBuildTime) - support.findIndexesToRebuild(context.Background(), now5m) + support.findIndexesToRebuild(now5m) require.Equal(t, 6, support.rebuildQueue.Len()) // Values that we expect to find in rebuild requests. @@ -692,8 +692,7 @@ func TestRebuildIndexes(t *testing.T) { func checkRebuildIndex(t *testing.T, support *searchSupport, req rebuildRequest, indexExists, expectedRebuild bool) { ctx := context.Background() - idxBefore, err := support.search.GetIndex(ctx, req.NamespacedResource) - require.NoError(t, err) + idxBefore := support.search.GetIndex(req.NamespacedResource) if indexExists { require.NotNil(t, idxBefore, "index should exist before rebuildIndex") } else { @@ -702,8 +701,7 @@ func checkRebuildIndex(t *testing.T, support *searchSupport, req rebuildRequest, support.rebuildIndex(ctx, req) - idxAfter, err := support.search.GetIndex(ctx, req.NamespacedResource) - require.NoError(t, err) + idxAfter := support.search.GetIndex(req.NamespacedResource) if indexExists { require.NotNil(t, idxAfter, "index should exist after rebuildIndex") diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 17c36996ad9..bedea3987c2 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -22,6 +22,7 @@ import ( claims "github.com/grafana/authlib/types" "github.com/grafana/dskit/backoff" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/validation" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" @@ -194,6 +195,10 @@ type SearchOptions struct { // Number of workers to use for index rebuilds. IndexRebuildWorkers int + + // Minimum time between index updates. This is also used as a delay after a successful write operation, to guarantee + // that subsequent search will observe the effect of the writing. + IndexMinUpdateInterval time.Duration } type ResourceServerOptions struct { @@ -336,6 +341,8 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { reg: opts.Reg, queue: opts.QOSQueue, queueConfig: opts.QOSConfig, + + artificialSuccessfulWriteDelay: opts.Search.IndexMinUpdateInterval, } if opts.Search.Resources != nil { @@ -386,6 +393,11 @@ type server struct { reg prometheus.Registerer queue QOSEnqueuer queueConfig QueueConfig + + // This value is used by storage server to artificially delay returning response after successful + // write operations to make sure that subsequent search by the same client will return up-to-date results. + // Set from SearchOptions.IndexMinUpdateInterval. + artificialSuccessfulWriteDelay time.Duration } // Init implements ResourceServer. @@ -661,6 +673,8 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re }) } + s.sleepAfterSuccessfulWriteOperation(res, err) + return res, err } @@ -684,6 +698,37 @@ func (s *server) create(ctx context.Context, user claims.AuthInfo, req *resource return rsp, nil } +type responseWithErrorResult interface { + GetError() *resourcepb.ErrorResult +} + +// sleepAfterSuccessfulWriteOperation will sleep for a specified time if the operation was successful. +// Returns boolean indicating whether the sleep was performed or not (used in testing). +// +// This sleep is performed to guarantee search-after-write consistency, when rate-limiting updates to search index. +func (s *server) sleepAfterSuccessfulWriteOperation(res responseWithErrorResult, err error) bool { + if s.artificialSuccessfulWriteDelay <= 0 { + return false + } + + if err != nil { + // No sleep necessary if operation failed. + return false + } + + // We expect that non-nil interface values with typed nils can still handle GetError() call. + if res != nil { + errRes := res.GetError() + if errRes != nil { + // No sleep necessary if operation failed. + return false + } + } + + time.Sleep(s.artificialSuccessfulWriteDelay) + return true +} + func (s *server) Update(ctx context.Context, req *resourcepb.UpdateRequest) (*resourcepb.UpdateResponse, error) { ctx, span := s.tracer.Start(ctx, "storage_server.Update") defer span.End() @@ -715,6 +760,8 @@ func (s *server) Update(ctx context.Context, req *resourcepb.UpdateRequest) (*re }) } + s.sleepAfterSuccessfulWriteOperation(res, err) + return res, err } @@ -787,6 +834,8 @@ func (s *server) Delete(ctx context.Context, req *resourcepb.DeleteRequest) (*re }) } + s.sleepAfterSuccessfulWriteOperation(res, err) + return res, err } diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index 7e09184d7ad..66536b4d5d8 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -3,6 +3,7 @@ package resource import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -21,6 +22,7 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/dskit/services" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" @@ -587,3 +589,30 @@ func newTestServerWithQueue(t *testing.T, maxSizePerTenant int, numWorkers int) } return s, q } + +func TestArtificialDelayAfterSuccessfulOperation(t *testing.T) { + s := &server{artificialSuccessfulWriteDelay: 1 * time.Millisecond} + + check := func(t *testing.T, expectedSleep bool, res responseWithErrorResult, err error) { + slept := s.sleepAfterSuccessfulWriteOperation(res, err) + require.Equal(t, expectedSleep, slept) + } + + // Successful responses should sleep + check(t, true, nil, nil) + + check(t, true, (responseWithErrorResult)((*resourcepb.CreateResponse)(nil)), nil) + check(t, true, &resourcepb.CreateResponse{}, nil) + + check(t, true, (responseWithErrorResult)((*resourcepb.UpdateResponse)(nil)), nil) + check(t, true, &resourcepb.UpdateResponse{}, nil) + + check(t, true, (responseWithErrorResult)((*resourcepb.DeleteResponse)(nil)), nil) + check(t, true, &resourcepb.DeleteResponse{}, nil) + + // Failed responses should return without sleeping + check(t, false, nil, errors.New("some error")) + check(t, false, &resourcepb.CreateResponse{Error: AsErrorResult(errors.New("some error"))}, nil) + check(t, false, &resourcepb.UpdateResponse{Error: AsErrorResult(errors.New("some error"))}, nil) + check(t, false, &resourcepb.DeleteResponse{Error: AsErrorResult(errors.New("some error"))}, nil) +} diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 211bef376cc..190322bab7c 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -79,6 +79,9 @@ type BleveOptions struct { UseFullNgram bool + // Minimum time between index updates. + IndexMinUpdateInterval time.Duration + // This function is called to check whether the index is owned by the current instance. // Indexes that are not owned by current instance are eligible for cleanup. // If nil, all indexes are owned by the current instance. @@ -167,13 +170,13 @@ func NewBleveBackend(opts BleveOptions, tracer trace.Tracer, indexMetrics *resou } // GetIndex will return nil if the key does not exist -func (b *bleveBackend) GetIndex(_ context.Context, key resource.NamespacedResource) (resource.ResourceIndex, error) { +func (b *bleveBackend) GetIndex(key resource.NamespacedResource) resource.ResourceIndex { idx := b.getCachedIndex(key, time.Now()) // Avoid returning typed nils. if idx == nil { - return nil, nil + return nil } - return idx, nil + return idx } func (b *bleveBackend) GetOpenIndexes() []resource.NamespacedResource { @@ -689,8 +692,8 @@ func (b *bleveBackend) closeAllIndexes() { } type updateRequest struct { - reason string - callback chan updateResult + requestTime time.Time + callback chan updateResult } type updateResult struct { @@ -705,6 +708,10 @@ type bleveIndex struct { // RV returned by last List/ListModifiedSince operation. Updated when updating index. resourceVersion int64 + // Timestamp when the last update to the index was done (started). + // Subsequent update requests only trigger new update if minUpdateInterval has elapsed. + nextUpdateTime time.Time + standard resource.SearchableDocumentFields fields resource.SearchableDocumentFields @@ -719,7 +726,8 @@ type bleveIndex struct { tracing trace.Tracer logger *slog.Logger - updaterFn resource.UpdateFn + updaterFn resource.UpdateFn + minUpdateInterval time.Duration updaterMu sync.Mutex updaterCond *sync.Cond // Used to signal the updater goroutine that there is work to do, or updater is no longer enabled and should stop. Also used by updater itself to stop early if there's no work to be done. @@ -746,15 +754,16 @@ func (b *bleveBackend) newBleveIndex( logger *slog.Logger, ) *bleveIndex { bi := &bleveIndex{ - key: key, - index: index, - indexStorage: newIndexType, - fields: fields, - allFields: allFields, - standard: standardSearchFields, - tracing: b.tracer, - logger: logger, - updaterFn: updaterFn, + key: key, + index: index, + indexStorage: newIndexType, + fields: fields, + allFields: allFields, + standard: standardSearchFields, + tracing: b.tracer, + logger: logger, + updaterFn: updaterFn, + minUpdateInterval: b.opts.IndexMinUpdateInterval, } bi.updaterCond = sync.NewCond(&bi.updaterMu) if b.indexMetrics != nil { @@ -1349,14 +1358,14 @@ func (b *bleveIndex) stopUpdaterAndCloseIndex() error { return b.index.Close() } -func (b *bleveIndex) UpdateIndex(ctx context.Context, reason string) (int64, error) { +func (b *bleveIndex) UpdateIndex(ctx context.Context) (int64, error) { // We don't have to do anything if the index cannot be updated (typically in tests). if b.updaterFn == nil { return 0, nil } // Use chan with buffer size 1 to ensure that we can always send the result back, even if there's no reader anymore. - req := updateRequest{reason: reason, callback: make(chan updateResult, 1)} + req := updateRequest{requestTime: time.Now(), callback: make(chan updateResult, 1)} // Make sure that the updater goroutine is running. b.updaterMu.Lock() @@ -1413,7 +1422,7 @@ func (b *bleveIndex) runUpdater(ctx context.Context) { b.updaterMu.Lock() for !b.updaterShutdown && ctx.Err() == nil && len(b.updaterQueue) == 0 && time.Since(start) < maxWait { - // Cond is signalled when updaterShutdown changes, updaterQueue gets new element or when timeout occurs. + // Cond is signaled when updaterShutdown changes, updaterQueue gets new element or when timeout occurs. b.updaterCond.Wait() } @@ -1436,6 +1445,26 @@ func (b *bleveIndex) runUpdater(ctx context.Context) { return } + // Check if requests arrived before minUpdateInterval since the last update has elapsed, and remove such requests. + for ix := 0; ix < len(batch); { + req := batch[ix] + if req.requestTime.Before(b.nextUpdateTime) { + req.callback <- updateResult{rv: b.resourceVersion} + batch = append(batch[:ix], batch[ix+1:]...) + } else { + // Keep in the batch + ix++ + } + } + + // If all requests are now handled, don't perform update. + if len(batch) == 0 { + continue + } + + // Bump next update time + b.nextUpdateTime = time.Now().Add(b.minUpdateInterval) + var rv int64 var err = ctx.Err() if err == nil { diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index d57196b842d..c0ad9c68fc0 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -833,6 +833,12 @@ func withOwnsIndexFn(fn func(key resource.NamespacedResource) (bool, error)) set } } +func withIndexMinUpdateInterval(d time.Duration) setupOption { + return func(options *BleveOptions) { + options.IndexMinUpdateInterval = d + } +} + func TestBuildIndexExpiration(t *testing.T) { ns := resource.NamespacedResource{ Namespace: "test", @@ -897,8 +903,7 @@ func TestBuildIndexExpiration(t *testing.T) { backend.runEvictExpiredOrUnownedIndexes(time.Now().Add(5 * time.Minute)) if tc.expectedEviction { - idx, err := backend.GetIndex(context.Background(), ns) - require.NoError(t, err) + idx := backend.GetIndex(ns) require.Nil(t, idx) _, err = builtIndex.DocCount(context.Background(), "") @@ -907,8 +912,7 @@ func TestBuildIndexExpiration(t *testing.T) { // Verify that there are no open indexes. checkOpenIndexes(t, reg, 0, 0) } else { - idx, err := backend.GetIndex(context.Background(), ns) - require.NoError(t, err) + idx := backend.GetIndex(ns) require.NotNil(t, idx) cnt, err := builtIndex.DocCount(context.Background(), "") @@ -1132,6 +1136,37 @@ func updateTestDocs(ns resource.NamespacedResource, docs int) resource.UpdateFn } } +func updateTestDocsReturningMillisTimestamp(ns resource.NamespacedResource, docs int) (resource.UpdateFn, *atomic.Int64) { + cnt := 0 + updateCalls := atomic.NewInt64(0) + + return func(context context.Context, index resource.ResourceIndex, sinceRV int64) (newRV int64, updatedDocs int, _ error) { + now := time.Now() + updateCalls.Inc() + + cnt++ + + var items []*resource.BulkIndexItem + for i := 0; i < docs; i++ { + items = append(items, &resource.BulkIndexItem{ + Action: resource.ActionIndex, + Doc: &resource.IndexableDocument{ + Key: &resourcepb.ResourceKey{ + Namespace: ns.Namespace, + Group: ns.Group, + Resource: ns.Resource, + Name: fmt.Sprintf("doc%d", i), + }, + Title: fmt.Sprintf("Document %d (gen_%d)", i, cnt), + }, + }) + } + + err := index.BulkIndex(&resource.BulkIndexRequest{Items: items}) + return now.UnixMilli(), docs, err + }, updateCalls +} + func TestCleanOldIndexes(t *testing.T) { dir := t.TempDir() @@ -1209,7 +1244,7 @@ func TestIndexUpdate(t *testing.T) { require.Equal(t, int64(0), resp.TotalHits) // Update index. - _, err = idx.UpdateIndex(context.Background(), "test") + _, err = idx.UpdateIndex(context.Background()) require.NoError(t, err) // Verify that index was updated -- number of docs didn't change, but we can search "gen_1" documents now. @@ -1217,7 +1252,7 @@ func TestIndexUpdate(t *testing.T) { require.Equal(t, int64(5), searchTitle(t, idx, "gen_1", 10, ns).TotalHits) // Update index again. - _, err = idx.UpdateIndex(context.Background(), "test") + _, err = idx.UpdateIndex(context.Background()) require.NoError(t, err) // Verify that index was updated again -- we can search "gen_2" now. "gen_1" documents are gone. require.Equal(t, 10, docCount(t, idx)) @@ -1261,13 +1296,13 @@ func TestConcurrentIndexUpdateAndBuildIndex(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err = idx.UpdateIndex(ctx, "test") + _, err = idx.UpdateIndex(ctx) require.NoError(t, err) _, err = be.BuildIndex(t.Context(), ns, 10 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), updaterFn, false) require.NoError(t, err) - _, err = idx.UpdateIndex(ctx, "test") + _, err = idx.UpdateIndex(ctx) require.Contains(t, err.Error(), bleve.ErrorIndexClosed.Error()) } @@ -1303,10 +1338,8 @@ func TestConcurrentIndexUpdateSearchAndRebuild(t *testing.T) { case <-time.After(time.Duration(i) * time.Millisecond): // introduce small jitter } - idx, err := be.GetIndex(ctx, ns) - require.NoError(t, err) // GetIndex doesn't really return error. - - _, err = idx.UpdateIndex(ctx, "test") + idx := be.GetIndex(ns) + _, err = idx.UpdateIndex(ctx) if err != nil { if errors.Is(err, bleve.ErrorIndexClosed) || errors.Is(err, context.Canceled) { continue @@ -1353,7 +1386,7 @@ func TestConcurrentIndexUpdateSearchAndRebuild(t *testing.T) { cancel() wg.Wait() - fmt.Println("Updates:", updates.Load(), "searches:", searches.Load(), "rebuilds:", rebuilds.Load()) + t.Log("Updates:", updates.Load(), "searches:", searches.Load(), "rebuilds:", rebuilds.Load()) } // Verify concurrent updates and searches work as expected. @@ -1387,7 +1420,7 @@ func TestConcurrentIndexUpdateAndSearch(t *testing.T) { prevRV := int64(0) for ctx.Err() == nil { // We use t.Context() here to avoid getting errors from context cancellation. - rv, err := idx.UpdateIndex(t.Context(), "test") + rv, err := idx.UpdateIndex(t.Context()) require.NoError(t, err) require.Greater(t, rv, prevRV) // Each update should return new RV (that's how our update function works) require.Equal(t, int64(10), searchTitle(t, idx, "Document", 10, ns).TotalHits) @@ -1415,7 +1448,72 @@ func TestConcurrentIndexUpdateAndSearch(t *testing.T) { require.Greater(t, rvUpdatedByMultipleGoroutines, int64(0)) } -// Verify concurrent updates and searches work as expected. +func TestConcurrentIndexUpdateAndSearchWithIndexMinUpdateInterval(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + const minInterval = 100 * time.Millisecond + be, _ := setupBleveBackend(t, withIndexMinUpdateInterval(minInterval)) + + updateFn, updateCalls := updateTestDocsReturningMillisTimestamp(ns, 5) + idx, err := be.BuildIndex(t.Context(), ns, 10 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), updateFn, false) + require.NoError(t, err) + + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + attemptedUpdates := atomic.NewInt64(0) + + // Verify that each returned RV (unix timestamp in millis) is either the same as before, or at least minInterval later. + const searchConcurrency = 25 + for i := 0; i < searchConcurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + prevRV := int64(0) + for ctx.Err() == nil { + attemptedUpdates.Inc() + + // We use t.Context() here to avoid getting errors from context cancellation. + rv, err := idx.UpdateIndex(t.Context()) + require.NoError(t, err) + + // Our update function returns unix timestamp in millis. We expect it to not change at all, or change by minInterval. + if prevRV > 0 { + rvDiff := rv - prevRV + if rvDiff == 0 { + // OK + } else { + // Allow returned RV to be within 10% of minInterval. + require.InDelta(t, minInterval.Milliseconds(), rvDiff, float64(minInterval.Milliseconds())*0.10) + } + } + + prevRV = rv + require.Equal(t, int64(10), searchTitle(t, idx, "Document", 10, ns).TotalHits) + } + }() + } + + // Run updates and searches for this time. + testTime := 1 * time.Second + + time.Sleep(testTime) + cancel() + wg.Wait() + + expectedUpdateCalls := int64(testTime / minInterval) + require.InDelta(t, expectedUpdateCalls, updateCalls.Load(), float64(expectedUpdateCalls/2)) + require.Greater(t, attemptedUpdates.Load(), updateCalls.Load()) + + t.Log("Attempted updates:", attemptedUpdates.Load(), "update calls:", updateCalls.Load()) +} + func TestIndexUpdateWithErrors(t *testing.T) { ns := resource.NamespacedResource{ Namespace: "test", @@ -1434,7 +1532,7 @@ func TestIndexUpdateWithErrors(t *testing.T) { require.NoError(t, err) t.Run("update fail", func(t *testing.T) { - _, err = idx.UpdateIndex(t.Context(), "test") + _, err = idx.UpdateIndex(t.Context()) require.ErrorIs(t, err, updateErr) }) @@ -1442,7 +1540,7 @@ func TestIndexUpdateWithErrors(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) defer cancel() - _, err = idx.UpdateIndex(ctx, "test") + _, err = idx.UpdateIndex(ctx) require.ErrorIs(t, err, context.DeadlineExceeded) }) @@ -1451,7 +1549,7 @@ func TestIndexUpdateWithErrors(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err = idx.UpdateIndex(ctx, "test") + _, err = idx.UpdateIndex(ctx) require.ErrorIs(t, err, context.Canceled) }) } diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index a719bebf32c..0d856b586c3 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -41,13 +41,14 @@ func NewSearchOptions( } bleve, err := NewBleveBackend(BleveOptions{ - Root: root, - FileThreshold: int64(cfg.IndexFileThreshold), // fewer than X items will use a memory index - BatchSize: cfg.IndexMaxBatchSize, // This is the batch size for how many objects to add to the index at once - IndexCacheTTL: cfg.IndexCacheTTL, // How long to keep the index cache in memory - BuildVersion: cfg.BuildVersion, - UseFullNgram: features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageUseFullNgram), - OwnsIndex: ownsIndexFn, + Root: root, + FileThreshold: int64(cfg.IndexFileThreshold), // fewer than X items will use a memory index + BatchSize: cfg.IndexMaxBatchSize, // This is the batch size for how many objects to add to the index at once + IndexCacheTTL: cfg.IndexCacheTTL, // How long to keep the index cache in memory + BuildVersion: cfg.BuildVersion, + UseFullNgram: features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageUseFullNgram), + OwnsIndex: ownsIndexFn, + IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, }, tracer, indexMetrics) if err != nil { @@ -55,14 +56,15 @@ func NewSearchOptions( } return resource.SearchOptions{ - Backend: bleve, - Resources: docs, - InitWorkerThreads: cfg.IndexWorkers, - IndexRebuildWorkers: cfg.IndexRebuildWorkers, - InitMinCount: cfg.IndexMinCount, - DashboardIndexMaxAge: cfg.IndexRebuildInterval, - MaxIndexAge: cfg.MaxFileIndexAge, - MinBuildVersion: minVersion, + Backend: bleve, + Resources: docs, + InitWorkerThreads: cfg.IndexWorkers, + IndexRebuildWorkers: cfg.IndexRebuildWorkers, + InitMinCount: cfg.IndexMinCount, + DashboardIndexMaxAge: cfg.IndexRebuildInterval, + MaxIndexAge: cfg.MaxFileIndexAge, + MinBuildVersion: minVersion, + IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, }, nil } return resource.SearchOptions{}, nil diff --git a/pkg/storage/unified/testing/search_backend.go b/pkg/storage/unified/testing/search_backend.go index ed1cffd5194..4ef4b6d2753 100644 --- a/pkg/storage/unified/testing/search_backend.go +++ b/pkg/storage/unified/testing/search_backend.go @@ -59,12 +59,11 @@ func runTestSearchBackendBuildIndex(t *testing.T, backend resource.SearchBackend } // Get the index should return nil if the index does not exist - index, err := backend.GetIndex(ctx, ns) - require.NoError(t, err) + index := backend.GetIndex(ns) require.Nil(t, index) // Build the index - index, err = backend.BuildIndex(ctx, ns, 0, nil, "test", func(index resource.ResourceIndex) (int64, error) { + index, err := backend.BuildIndex(ctx, ns, 0, nil, "test", func(index resource.ResourceIndex) (int64, error) { // Write a test document err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ @@ -91,8 +90,7 @@ func runTestSearchBackendBuildIndex(t *testing.T, backend resource.SearchBackend require.NotNil(t, index) // Get the index should now return the index - index, err = backend.GetIndex(ctx, ns) - require.NoError(t, err) + index = backend.GetIndex(ns) require.NotNil(t, index) } diff --git a/pkg/tests/apis/preferences/stars_test.go b/pkg/tests/apis/preferences/stars_test.go index 3efc2469119..f1050593926 100644 --- a/pkg/tests/apis/preferences/stars_test.go +++ b/pkg/tests/apis/preferences/stars_test.go @@ -15,6 +15,7 @@ import ( preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -70,6 +71,17 @@ func TestIntegrationStars(t *testing.T) { GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(), }) + history := &queryhistory.QueryHistoryResponse{} + legacyHistoryResponse := apis.DoRequest(helper, apis.RequestParams{ + User: starsClient.Args.User, + Method: http.MethodPost, + Path: "/api/query-history", + Body: []byte(`{"dataSourceUid":"eez1ebbdn3pq8b","queries":[{"scenarioId":"random_walk","seriesCount":1,"refId":"A","datasource":{"type":"grafana-testdata-datasource","uid":"eez1ebbdn3pq8b","apiVersion":"v0alpha1"}}]}`), + }, &history) + require.Equal(t, http.StatusOK, legacyHistoryResponse.Response.StatusCode, "add query history") + queryHistoryStarUID := history.Result.UID + require.NotEmpty(t, queryHistoryStarUID, "expect a query history UID") + // Create 5 dashboards for i := range 5 { _, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{ @@ -145,7 +157,7 @@ func TestIntegrationStars(t *testing.T) { // Change stars via k8s update rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "metadata": map[string]any{ "name": "user-" + starsClient.Args.User.Identity.GetIdentifier(), "namespace": "default", @@ -169,9 +181,45 @@ func TestIntegrationStars(t *testing.T) { require.Equal(t, "dashboard.grafana.app", resources[0].Group) require.Equal(t, "Dashboard", resources[0].Kind) require.ElementsMatch(t, - []string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb + []string{"aaa", "bbb", "test-2"}, // NOTE 2 stays, 3 removed, added aaa+bbb (and sorted!) resources[0].Names) + // Query history stars + legacyHistoryResponse = apis.DoRequest(helper, apis.RequestParams{ + User: starsClient.Args.User, + Method: http.MethodPost, + Path: "/api/query-history/star/" + queryHistoryStarUID, + }, &history) + require.Equal(t, http.StatusOK, legacyHistoryResponse.Response.StatusCode, "add query history") + require.True(t, history.Result.Starred, "expect the value to be starred") + + rspObj, err = starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) + require.NoError(t, err) + + after = typed(t, rspObj, &preferences.Stars{}) + jj, err := json.MarshalIndent(after.Spec, "", " ") + require.NoError(t, err) + require.JSONEq(t, `{ + "resource": [ + { + "group": "dashboard.grafana.app", + "kind": "Dashboard", + "names": [ + "aaa", + "bbb", + "test-2" + ] + }, + { + "group": "history.grafana.app", + "kind": "Query", + "names": [ + "`+queryHistoryStarUID+`" + ] + } + ] + }`, string(jj)) + // Viewer does not have any stars rsp, err = starsClientViewer.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index ca4b4eb5565..0a56b17f0f7 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/jackc/pgx/v5/pgxpool" "github.com/lib/pq" @@ -129,7 +128,7 @@ func newPostgresPGX(ctx context.Context, userFacingDefaultError string, rowLimit return p, handler, nil } -func NewInstanceSettings(logger log.Logger, features featuremgmt.FeatureToggles, dataPath string) datasource.InstanceFactoryFunc { +func NewInstanceSettings(logger log.Logger, usePGX bool, dataPath string) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { cfg := backend.GrafanaConfigFromContext(ctx) sqlCfg, err := cfg.SQL() @@ -167,14 +166,12 @@ func NewInstanceSettings(logger log.Logger, features featuremgmt.FeatureToggles, DecryptedSecureJSONData: settings.DecryptedSecureJSONData, } - isPGX := features.IsEnabled(ctx, featuremgmt.FlagPostgresDSUsePGX) - userFacingDefaultError, err := cfg.UserFacingDefaultError() if err != nil { return nil, err } - if isPGX { + if usePGX { pgxlogger := logger.FromContext(ctx).With("driver", "pgx") pgxTlsManager := newPgxTlsManager(pgxlogger) pgxTlsSettings, err := pgxTlsManager.getTLSSettings(dsInfo) @@ -184,7 +181,7 @@ func NewInstanceSettings(logger log.Logger, features featuremgmt.FeatureToggles, // Ensure cleanupCertFiles is called after the connection is opened defer pgxTlsManager.cleanupCertFiles(pgxTlsSettings) - cnnstr, err := generateConnectionString(dsInfo, pgxTlsSettings, isPGX, pgxlogger) + cnnstr, err := generateConnectionString(dsInfo, pgxTlsSettings, usePGX, pgxlogger) if err != nil { return "", err } @@ -202,7 +199,7 @@ func NewInstanceSettings(logger log.Logger, features featuremgmt.FeatureToggles, if err != nil { return "", err } - cnnstr, err := generateConnectionString(dsInfo, tlsSettings, isPGX, pqlogger) + cnnstr, err := generateConnectionString(dsInfo, tlsSettings, usePGX, pqlogger) if err != nil { return nil, err } diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go index 5a1050ba3a5..06418e92f78 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_service.go @@ -20,8 +20,9 @@ type Service struct { func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Service { logger := backend.NewLoggerWith("logger", "tsdb.postgres") + usePGX := features.IsEnabled(context.Background(), featuremgmt.FlagPostgresDSUsePGX) s := &Service{ - im: datasource.NewInstanceManager(NewInstanceSettings(logger, features, cfg.DataPath)), + im: datasource.NewInstanceManager(NewInstanceSettings(logger, usePGX, cfg.DataPath)), features: features, } return s diff --git a/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go b/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go index c13adbb7825..9c821941e71 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go +++ b/pkg/tsdb/grafana-postgresql-datasource/standalone/main.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" postgres "github.com/grafana/grafana/pkg/tsdb/grafana-postgresql-datasource" ) @@ -14,11 +13,9 @@ import ( func main() { // No need to pass logger name, it will be set by the plugin SDK logger := backend.NewLoggerWith() - // TODO: get rid of setting.NewCfg() and featuremgmt.FeatureToggles once PostgresDSUsePGX is removed + // TODO: get rid of setting.NewCfg() once PostgresDSUsePGX is removed cfg := setting.NewCfg() - // We want to enable the feature toggle for api server - features := featuremgmt.WithFeatures(featuremgmt.FlagPostgresDSUsePGX) - if err := datasource.Manage("grafana-postgresql-datasource", postgres.NewInstanceSettings(logger, features, cfg.DataPath), datasource.ManageOpts{}); err != nil { + if err := datasource.Manage("grafana-postgresql-datasource", postgres.NewInstanceSettings(logger, true, cfg.DataPath), datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index 715f7ef12ae..65ee1b30dae 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -166,23 +166,14 @@ describe('useDeleteMultipleFoldersMutationFacade', () => { it('deletes multiple folders and publishes success alert', async () => { config.featureToggles.foldersAppPlatformAPI = true; + // Same test as for legacy as right now we always use legacy API for deletes. const folderUIDs = ['uid1', 'uid2']; const deleteFolders = useDeleteMultipleFoldersMutationFacade(); await deleteFolders({ folderUIDs }); // Should call deleteFolder for each UID - expect(mockDeleteFolder).toHaveBeenCalledTimes(folderUIDs.length); - expect(mockDeleteFolder).toHaveBeenCalledWith({ name: 'uid1' }); - expect(mockDeleteFolder).toHaveBeenCalledWith({ name: 'uid2' }); - - // Should publish success alert - expect(publishMockFn).toHaveBeenCalledWith({ - type: AppEvents.alertSuccess.name, - payload: ['Folder deleted'], - }); - - // Should dispatch refreshParents - expect(dispatchMockFn).toHaveBeenCalled(); + expect(mockDeleteFolderLegacy).toHaveBeenCalledTimes(1); + expect(mockDeleteFolderLegacy).toHaveBeenCalledWith({ folderUIDs }); }); it('uses legacy call when flag is false', async () => { diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index 90b47bc708c..3ee45cde1ae 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -172,37 +172,41 @@ export function useGetFolderQueryFacade(uid?: string) { } export function useDeleteFolderMutationFacade() { - const [deleteFolder] = useDeleteFolderMutation(); + const [deleteFolderMutation] = useDeleteFolderMutation(); const [deleteFolderLegacy] = useDeleteFolderMutationLegacy(); const refresh = useRefreshFolders(); const notify = useAppNotification(); - return async (folder: FolderDTO) => { - if (config.featureToggles.foldersAppPlatformAPI) { - const result = await deleteFolder({ name: folder.uid }); - if (!result.error) { - // we could do this in the enhanceEndpoint method, but we would also need to change the args as we need parentUID - // here and so it seemed easier to do it here. - refresh({ childrenOf: folder.parentUid }); - // Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see - // public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here. - notify.success(t('folders.api.folder-deleted-success', 'Folder deleted')); - } - return result; - } else { - return deleteFolderLegacy(folder); + // TODO right now the app platform backend does not support cascading delete of children so we cannot use it. + const isBackendSupport = false; + if (!(config.featureToggles.foldersAppPlatformAPI && isBackendSupport)) { + return deleteFolderLegacy; + } + + return async function deleteFolder(folder: FolderDTO) { + const result = await deleteFolderMutation({ name: folder.uid }); + if (!result.error) { + // we could do this in the enhanceEndpoint method, but we would also need to change the args as we need parentUID + // here and so it seemed easier to do it here. + refresh({ childrenOf: folder.parentUid }); + // Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see + // public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here. + notify.success(t('folders.api.folder-deleted-success', 'Folder deleted')); } + return result; }; } export function useDeleteMultipleFoldersMutationFacade() { - const [deleteFolders] = useDeleteFoldersMutationLegacy(); + const [deleteFoldersLegacy] = useDeleteFoldersMutationLegacy(); const [deleteFolder] = useDeleteFolderMutation(); const dispatch = useDispatch(); const refresh = useRefreshFolders(); - if (!config.featureToggles.foldersAppPlatformAPI) { - return deleteFolders; + // TODO right now the app platform backend does not support cascading delete of children so we cannot use it. + const isBackendSupport = false; + if (!(config.featureToggles.foldersAppPlatformAPI && isBackendSupport)) { + return deleteFoldersLegacy; } return async function deleteFolders({ folderUIDs }: DeleteFoldersArgs) { diff --git a/public/app/app.ts b/public/app/app.ts index 25c730a2d34..7896117c1e7 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -43,6 +43,7 @@ import { setMegaMenuOpenHook, } from '@grafana/runtime'; import { + initOpenFeature, setGetObservablePluginComponents, setGetObservablePluginLinks, setPanelDataErrorView, @@ -129,8 +130,20 @@ export class GrafanaApp { async init() { try { await preInitTasks(); + // Let iframe container know grafana has started loading window.parent.postMessage('GrafanaAppInit', '*'); + + // Currently the OpenFeature API requires a signed in user. This means feature flags cannot be used + // on the login page. + if (contextSrv.user.isSignedIn) { + try { + await initOpenFeature(); + } catch (err) { + console.error('Failed to initialize OpenFeature provider', err); + } + } + const regionalFormat = config.featureToggles.localeFormatPreference ? config.regionalFormat : contextSrv.user.language; diff --git a/public/app/core/components/Branding/OrangeBadge.tsx b/public/app/core/components/Branding/OrangeBadge.tsx index be9d080df79..3088c63cec5 100644 --- a/public/app/core/components/Branding/OrangeBadge.tsx +++ b/public/app/core/components/Branding/OrangeBadge.tsx @@ -1,19 +1,25 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; +import { HTMLAttributes } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, useStyles2 } from '@grafana/ui'; -export function OrangeBadge({ text }: { text: string }) { - const styles = useStyles2(getStyles); +interface Props extends HTMLAttributes { + text?: string; + className?: string; +} + +export function OrangeBadge({ text, className, ...htmlProps }: Props) { + const styles = useStyles2(getStyles, text); return ( -
+
{text}
); } -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, text: string | undefined) => { return { wrapper: css({ display: 'inline-flex', @@ -26,6 +32,11 @@ const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.bodySmall.fontSize, lineHeight: theme.typography.bodySmall.lineHeight, alignItems: 'center', + ...(text === undefined && { + svg: { + marginRight: 0, + }, + }), }), }; }; diff --git a/public/app/core/components/FolderFilter/FolderFilter.tsx b/public/app/core/components/FolderFilter/FolderFilter.tsx index 2d310a6609b..e668c7b6157 100644 --- a/public/app/core/components/FolderFilter/FolderFilter.tsx +++ b/public/app/core/components/FolderFilter/FolderFilter.tsx @@ -9,7 +9,6 @@ import { config } from 'app/core/config'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { getGrafanaSearcher } from 'app/features/search/service/searcher'; import { DashboardSearchItemType } from 'app/features/search/types'; -import { PermissionLevelString } from 'app/types/acl'; import { FolderInfo } from 'app/types/folders'; export interface FolderFilterProps { @@ -69,7 +68,7 @@ async function getFoldersAsOptions( query: searchString, kind: ['folder'], limit: 100, - permission: PermissionLevelString.View, + permission: 'view', }); const options = queryResponse.view.map((item) => ({ @@ -89,7 +88,7 @@ async function getFoldersAsOptions( const params = { query: searchString, type: DashboardSearchItemType.DashFolder, - permission: PermissionLevelString.View, + permission: 'view', }; const searchHits = await getBackendSrv().search(params); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 67c97879c19..7967f867ad5 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -14,7 +14,7 @@ import { getGrafanaSearcher } from 'app/features/search/service/searcher'; import { QueryResponse } from 'app/features/search/service/types'; import { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; -import { PermissionLevelString } from 'app/types/acl'; +import { PermissionLevel } from 'app/types/acl'; import { FolderRepo } from './FolderRepo'; import { getDOMId, NestedFolderList } from './NestedFolderList'; @@ -57,7 +57,7 @@ export interface NestedFolderPickerProps { const debouncedSearch = debounce(getSearchResults, 300); -async function getSearchResults(searchQuery: string, permission?: PermissionLevelString) { +async function getSearchResults(searchQuery: string, permission?: PermissionLevel) { const queryResponse = await getGrafanaSearcher().search({ query: searchQuery, kind: ['folder'], @@ -98,17 +98,6 @@ export function NestedFolderPicker({ const [error] = useState(undefined); // TODO: error not populated anymore const lastSearchTimestamp = useRef(0); - // Map the permission string union to enum value for compatibility - const permissionLevel = useMemo(() => { - if (permission === 'view') { - return PermissionLevelString.View; - } else if (permission === 'edit') { - return PermissionLevelString.Edit; - } - - throw new Error('Invalid permission'); - }, [permission]); - const isBrowsing = Boolean(overlayOpen && !(search && searchResults)); const { emptyFolders, @@ -118,7 +107,7 @@ export function NestedFolderPicker({ } = useFoldersQuery({ isBrowsing, openFolders: foldersOpenState, - permission: permissionLevel, + permission, rootFolderUID, rootFolderItem, }); @@ -132,7 +121,7 @@ export function NestedFolderPicker({ const timestamp = Date.now(); setIsFetchingSearchResults(true); - debouncedSearch(search, permissionLevel).then((queryResponse) => { + debouncedSearch(search, permission).then((queryResponse) => { // Only keep the results if it's was issued after the most recently resolved search. // This prevents results showing out of order if first request is slower than later ones. // We don't need to worry about clearing the isFetching state either - if there's a later @@ -144,7 +133,7 @@ export function NestedFolderPicker({ lastSearchTimestamp.current = timestamp; } }); - }, [search, permissionLevel]); + }, [search, permission]); // the order of middleware is important! const middleware = [ diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts index a85bbf09d8a..d852d9c241f 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts +++ b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts @@ -1,6 +1,6 @@ import { config } from '@grafana/runtime'; import { DashboardsTreeItem } from 'app/features/browse-dashboards/types'; -import { PermissionLevelString } from 'app/types/acl'; +import { PermissionLevel } from 'app/types/acl'; import { useFoldersQueryAppPlatform } from './useFoldersQueryAppPlatform'; import { useFoldersQueryLegacy } from './useFoldersQueryLegacy'; @@ -8,7 +8,7 @@ import { useFoldersQueryLegacy } from './useFoldersQueryLegacy'; export interface UseFoldersQueryProps { isBrowsing: boolean; openFolders: Record; - permission?: PermissionLevelString; + permission?: PermissionLevel; rootFolderUID?: string; rootFolderItem?: DashboardsTreeItem; } diff --git a/public/app/core/components/Upgrade/ProBadge.tsx b/public/app/core/components/Upgrade/ProBadge.tsx index a7e9eec4b50..dc5aadf5d13 100644 --- a/public/app/core/components/Upgrade/ProBadge.tsx +++ b/public/app/core/components/Upgrade/ProBadge.tsx @@ -5,13 +5,14 @@ import { GrafanaTheme2 } from '@grafana/data'; import { reportExperimentView } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; +import { OrangeBadge } from '../Branding/OrangeBadge'; + export interface Props extends HTMLAttributes { - text?: string; experimentId?: string; eventVariant?: string; } -export const ProBadge = ({ text = 'PRO', className, experimentId, eventVariant = '', ...htmlProps }: Props) => { +export const ProBadge = ({ className, experimentId, eventVariant = '', ...htmlProps }: Props) => { const styles = useStyles2(getStyles); useEffect(() => { @@ -20,23 +21,13 @@ export const ProBadge = ({ text = 'PRO', className, experimentId, eventVariant = } }, [experimentId, eventVariant]); - return ( - - {text} - - ); + return ; }; const getStyles = (theme: GrafanaTheme2) => { return { badge: css({ marginLeft: theme.spacing(1.25), - borderRadius: theme.shape.borderRadius(5), - backgroundColor: theme.colors.success.main, - padding: theme.spacing(0.25, 0.75), - color: 'white', // use the same color for both themes - fontWeight: theme.typography.fontWeightMedium, - fontSize: theme.typography.pxToRem(10), }), }; }; diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index b7c025868a3..da4c513388a 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom-v5-compat'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; -import { AlertingPageWrapper } from 'app/features/alerting/unified/components/AlertingPageWrapper'; import { AccessControlAction } from 'app/types/accessControl'; import { PERMISSIONS_CONTACT_POINTS } from './unified/components/contact-points/permissions'; @@ -338,7 +337,9 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { routes.push({ path: '/alerting/triage', roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), - component: () => , + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingTriage" */ 'app/features/alerting/unified/triage/Triage') + ), }); } diff --git a/public/app/features/alerting/unified/components/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/EditorColumnHeader.tsx new file mode 100644 index 00000000000..f7c4113bab2 --- /dev/null +++ b/public/app/features/alerting/unified/components/EditorColumnHeader.tsx @@ -0,0 +1,59 @@ +import { css } from '@emotion/css'; +import * as React from 'react'; +import { type MergeExclusive } from 'type-fest'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Label, Stack, useStyles2 } from '@grafana/ui'; + +interface BaseProps { + id?: string; +} + +interface ChildrenProps extends BaseProps { + children: React.ReactNode; +} + +interface LabelActionsProps extends BaseProps { + label: string; + actions?: React.ReactNode; +} + +type Props = MergeExclusive; + +export function EditorColumnHeader({ label, actions, id, children }: Props) { + const styles = useStyles2(editorColumnStyles); + + if (children) { + return
{children}
; + } + + return ( +
+ + {actions && ( + + {actions} + + )} +
+ ); +} + +const editorColumnStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(1, 2), + backgroundColor: theme.colors.background.secondary, + border: `1px solid ${theme.colors.border.medium}`, + borderTopLeftRadius: theme.shape.radius.default, + borderTopRightRadius: theme.shape.radius.default, + }), + label: css({ + margin: 0, + }), +}); diff --git a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx deleted file mode 100644 index bd0d77a2a15..00000000000 --- a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { css } from '@emotion/css'; -import * as React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Label, Stack, useStyles2 } from '@grafana/ui'; - -type Props = { label: string; actions?: React.ReactNode; id?: string }; - -export function EditorColumnHeader({ label, actions, id }: Props) { - const styles = useStyles2(editorColumnStyles); - - return ( -
- - - {actions} - -
- ); -} - -const editorColumnStyles = (theme: GrafanaTheme2) => ({ - container: css({ - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: theme.spacing(1, 2), - backgroundColor: theme.colors.background.secondary, - borderBottom: `1px solid ${theme.colors.border.medium}`, - }), - label: css({ - margin: 0, - }), -}); diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx index a93285844d1..34ccbc5715c 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx @@ -8,7 +8,7 @@ import { Trans, t } from '@grafana/i18n'; import { Button, CodeEditor, Dropdown, Menu, Stack, Toggletip, useStyles2 } from '@grafana/ui'; import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types'; -import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; +import { EditorColumnHeader } from '../EditorColumnHeader'; import { AlertInstanceModalSelector } from './AlertInstanceModalSelector'; import { AlertTemplatePreviewData } from './TemplateData'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index aa5c347464a..bb7708a5226 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -32,9 +32,9 @@ import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types'; import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { makeAMLink, stringifyErrorLike } from '../../utils/misc'; +import { EditorColumnHeader } from '../EditorColumnHeader'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; import { Spacer } from '../Spacer'; -import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; import { NotificationTemplate, useCreateNotificationTemplate, diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx index fc6014a0e0e..5e5ecc1e08b 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -10,7 +10,7 @@ import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui'; import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi'; import { AIFeedbackButtonComponent } from '../../enterprise-components/AI/addAIFeedbackButton'; import { stringifyErrorLike } from '../../utils/misc'; -import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; +import { EditorColumnHeader } from '../EditorColumnHeader'; import { usePreviewTemplate } from './usePreviewTemplate'; diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx index 075a58b4a95..7a5c016a126 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx @@ -8,7 +8,7 @@ import { Box, useStyles2 } from '@grafana/ui'; import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader'; +import { EditorColumnHeader } from '../../../EditorColumnHeader'; import { TemplateEditor } from '../../TemplateEditor'; import { TemplatePreview } from '../../TemplatePreview'; diff --git a/public/app/features/alerting/unified/triage/Timeline.tsx b/public/app/features/alerting/unified/triage/Timeline.tsx new file mode 100644 index 00000000000..3bde44c91b9 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Timeline.tsx @@ -0,0 +1,37 @@ +import { scaleTime } from 'd3-scale'; +import { useMemo } from 'react'; +import { useMeasure } from 'react-use'; + +import { Stack, Text } from '@grafana/ui'; + +import { Domain } from './types'; + +interface TimelineProps { + domain: Domain; +} + +export const TimelineHeader = ({ domain }: TimelineProps) => { + const [ref, { width }] = useMeasure(); + + const ticks = useMemo(() => { + const xScale = scaleTime().domain(domain).range([0, width]).nice(0); + const tickFormatter = xScale.tickFormat(); + + return xScale.ticks(5).map((value) => ({ + value: tickFormatter(value), + xOffset: xScale(value), + })); + }, [domain, width]); + + return ( +
+ + {ticks.map((tick) => ( + + {tick.value} + + ))} + +
+ ); +}; diff --git a/public/app/features/alerting/unified/triage/Triage.md b/public/app/features/alerting/unified/triage/Triage.md new file mode 100644 index 00000000000..3bb802e91ab --- /dev/null +++ b/public/app/features/alerting/unified/triage/Triage.md @@ -0,0 +1,19 @@ +# Triage view + +The triage view should serve several purposes and be a central place for users to manage their alert instances. + +## Goals + +- Observe the current state of their system +- Help correlate alerts with each other +- Be a launchpad for further investigation + +## Non-goals + +- Managing alert rules + +## Technical goals + +- Build re-usable components that can be used in other parts of Grafana and plugins +- These should be a mix of presentation components and data components +- Eventually most of this should live in the Grafana Alerting package diff --git a/public/app/features/alerting/unified/triage/Triage.tsx b/public/app/features/alerting/unified/triage/Triage.tsx new file mode 100644 index 00000000000..284be52fa41 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Triage.tsx @@ -0,0 +1,25 @@ +import { t } from '@grafana/i18n'; +import { UrlSyncContextProvider } from '@grafana/scenes'; +import { withErrorBoundary } from '@grafana/ui'; + +import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; + +import { TriageScene, triageScene } from './scene/TriageScene'; + +export const TriagePage = () => { + return ( + + + + + + ); +}; + +export default withErrorBoundary(TriagePage); diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx new file mode 100644 index 00000000000..98c07cc8de6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -0,0 +1,211 @@ +import { css, cx } from '@emotion/css'; +import { take } from 'lodash'; +import { useState } from 'react'; +import { useMeasure } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { SceneQueryRunner } from '@grafana/scenes'; +import { ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui'; +import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; + +import { EditorColumnHeader } from '../components/EditorColumnHeader'; +import LoadMoreHelper from '../rule-list/LoadMoreHelper'; + +import { TimelineHeader } from './Timeline'; +import { WorkbenchProvider } from './WorkbenchContext'; +import { AlertRuleRow } from './rows/AlertRuleRow'; +import { FolderGroupRow } from './rows/FolderGroupRow'; +import { GroupRow } from './rows/GroupRow'; +import { generateRowKey } from './rows/utils'; +import { GenericRowSkeleton } from './scene/AlertRuleInstances'; +import { SummaryChartReact } from './scene/SummaryChart'; +import { SummaryStatsReact } from './scene/SummaryStats'; +import { Domain, Filter, WorkbenchRow } from './types'; + +type WorkbenchProps = { + domain: Domain; + data: WorkbenchRow[]; + groupBy?: string[]; // @TODO proper type + filterBy?: Filter[]; + queryRunner: SceneQueryRunner; +}; + +const initialSize = 1 / 3; + +// Helper function to recursively render WorkbenchRow items with children pattern +function renderWorkbenchRow( + row: WorkbenchRow, + leftColumnWidth: number, + domain: Domain, + key: React.Key, + depth = 0 +): React.ReactElement { + if (row.type === 'alertRule') { + return ; + } else { + const children = row.rows.map((childRow, childIndex) => + renderWorkbenchRow(childRow, leftColumnWidth, domain, `${key}-${generateRowKey(childRow, childIndex)}`, depth + 1) + ); + + // Check if this is a grafana_folder group and use FolderGroupRow + if (row.metadata.label === 'grafana_folder') { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); + } +} + +/** + * The workbench displays groups of alerts, each group containing metadata and a chart. + * Alerts can be arbitrarily grouped by any number of labels. By default all instances are grouped by alertname. + * + * The page consist of a left column with metadata for the row and a right column with charts. + * Below is a rough layout of the page: + * + * The page is divided into two columns, the size of these columns is determined by the splitter. + * There is a useMeasure hook to measure the size of the left column, which is used to set the width of the group items. + * We do this because each row needs to be a flex container such that if the height of the left colorn changes, the + * right column will also change its height accordingly. This would not be possible if we used a simplified column layout. + * + * This also means we draw the rows _on top_ of the splitter, in other words the contents of the splitter are empty + * and we only use it to determine the width of the left column of the rows that are overlayed on top. + * + * Each group is a row with a left and a right column. Each row consists of two cells (the left and the right cell). + * The left cell contains the metadata for the group, the right cell contains the chart. + ┌─────────────────────────┐ ┌───────────────────────────────────┐ + │┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│ + │ │ + ││ Row ││ + │ │ + │└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│ + │┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│ + │ ┌──────────────────────┐ ┌───────────────────────────────┐ │ + │││ Cell │ │ Cell │││ + │ └──────────────────────┘ └───────────────────────────────┘ │ + │└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│ + │ │ │ │ + │ │││ │ + │ │││ │ + │ │││ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + └─────────────────────────┘ └───────────────────────────────────┘ + */ +export function Workbench({ domain, data, queryRunner }: WorkbenchProps) { + const styles = useStyles2(getStyles); + + const isLoading = !queryRunner.isDataReadyToDisplay(); + const [pageIndex, setPageIndex] = useState(1); + // splitter for template and payload editor + const splitter = useSplitter({ + direction: 'row', + // if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor + initialSize: initialSize, + dragPosition: 'middle', + }); + + // this will measure the size of the left most column of the splitter, so we can use it to set the width of the group items + const [ref, rect] = useMeasure(); + const leftColumnWidth = rect.width; + + const itemsToRender = pageIndex * DEFAULT_PER_PAGE_PAGINATION; + const dataSlice = take(data, itemsToRender); + const hasMore = data.length > itemsToRender; + + return ( +
+ {/* dummy splitter to handle flex width of group items */} +
+
+
+
+
+
+
+
+
+ {/* content goes here */} +
+
+ + +
+
+ + + + +
+ {/* Render actual data */} +
+ + + {isLoading ? ( + <> + + + + + ) : ( + dataSlice.map((row, index) => { + const rowKey = generateRowKey(row, index); + return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey); + }) + )} + {hasMore && setPageIndex((prevIndex) => prevIndex + 1)} />} + + +
+
+
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + const summaryHeight = 200; + return { + groupsContainer: css({ + position: 'absolute', + width: '100%', + height: '100%', + + display: 'flex', + flexDirection: 'column', + }), + groupItemWrapper: (width: number) => + css({ + display: 'grid', + gridTemplateColumns: `${width}px auto`, + gap: theme.spacing(2), + }), + virtualizedContainer: css({ + display: 'flex', + flex: 1, + overflow: 'hidden', // Let AutoSizer handle the overflow + }), + summaryContainer: css({ + gridTemplateRows: summaryHeight, + marginBottom: theme.spacing(2), + }), + headerContainer: css({ + top: summaryHeight, + }), + flexFull: css({ + flex: 1, + }), + minColumnWidth: css({ + minWidth: 300, + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/WorkbenchContext.tsx b/public/app/features/alerting/unified/triage/WorkbenchContext.tsx new file mode 100644 index 00000000000..620455d9af6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/WorkbenchContext.tsx @@ -0,0 +1,34 @@ +import React, { createContext, useContext } from 'react'; + +import { SceneQueryRunner } from '@grafana/scenes'; + +import { Domain } from './types'; + +interface WorkbenchContextValue { + leftColumnWidth: number; + domain: Domain; + queryRunner: SceneQueryRunner; +} + +const WorkbenchContext = createContext(undefined); + +export function useWorkbenchContext(): WorkbenchContextValue { + const context = useContext(WorkbenchContext); + if (!context) { + throw new Error('useWorkbenchContext must be used within a WorkbenchProvider'); + } + return context; +} + +interface WorkbenchProviderProps { + leftColumnWidth: number; + domain: Domain; + queryRunner: SceneQueryRunner; + children: React.ReactNode; +} + +export function WorkbenchProvider({ leftColumnWidth, domain, queryRunner, children }: WorkbenchProviderProps) { + return ( + {children} + ); +} diff --git a/public/app/features/alerting/unified/triage/constants.ts b/public/app/features/alerting/unified/triage/constants.ts new file mode 100644 index 00000000000..627ef4ebb7a --- /dev/null +++ b/public/app/features/alerting/unified/triage/constants.ts @@ -0,0 +1,10 @@ +import { config } from '@grafana/runtime'; + +export const VARIABLES = { + groupBy: 'groupBy', + filters: 'filters', +}; + +export const DATASOURCE_UID = config.unifiedAlerting.stateHistory?.prometheusTargetDatasourceUID; +export const METRIC_NAME = config.unifiedAlerting.stateHistory?.prometheusMetricName ?? 'GRAFANA_ALERTS'; +export const DEFAULT_FIELDS = ['alertname', 'grafana_folder', 'grafana_rule_uid', 'alertstate'] as const; diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx new file mode 100644 index 00000000000..fefd37258f9 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Stack, Text, TextLink } from '@grafana/ui'; + +import { MetaText } from '../../components/MetaText'; +import { WithReturnButton } from '../../components/WithReturnButton'; +import { rulesNav } from '../../utils/navigation'; +import { AlertRuleInstances } from '../scene/AlertRuleInstances'; +import { AlertRuleSummary } from '../scene/AlertRuleSummary'; +import { AlertRuleRow as AlertRuleRowType } from '../types'; + +import { GenericRow } from './GenericRow'; + +interface AlertRuleRowProps { + row: AlertRuleRowType; + leftColumnWidth: number; + rowKey: React.Key; + depth?: number; +} + +export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertRuleRowProps) => { + return ( + + {row.metadata.title} + + } + /> + } + metadata={ + + + + {row.metadata.folder} + + + } + content={} + depth={depth} + > + + + ); +}; diff --git a/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx b/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx new file mode 100644 index 00000000000..4809a44573a --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx @@ -0,0 +1,47 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Stack, Text, useStyles2 } from '@grafana/ui'; + +import { MetaText } from '../../components/MetaText'; +import { GenericGroupedRow } from '../types'; + +import { GenericRow } from './GenericRow'; + +interface FolderGroupRowProps { + row: GenericGroupedRow; + leftColumnWidth: number; + rowKey: React.Key; + depth?: number; + children?: React.ReactNode; +} + +export const FolderGroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: FolderGroupRowProps) => { + const styles = useStyles2(getStyles); + + return ( + + + {row.metadata.value} + + } + isOpenByDefault={true} + leftColumnClassName={styles.folderGroupRow} + rightColumnClassName={styles.folderGroupRow} + depth={depth} + > + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + folderGroupRow: css({ + backgroundColor: theme.colors.background.secondary, + }), +}); diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx new file mode 100644 index 00000000000..904687a4dbf --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -0,0 +1,132 @@ +import { css, cx } from '@emotion/css'; +import { ReactNode } from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { IconButton, Stack, useStyles2 } from '@grafana/ui'; + +import { Spacer } from '../../components/Spacer'; + +interface GenericRowProps { + width: number; + title: ReactNode; + metadata?: ReactNode; + actions?: ReactNode; + content?: ReactNode; + isOpenByDefault?: boolean; + children?: ReactNode; + // allow overriding / adding styles for the row + leftColumnClassName?: string; + rightColumnClassName?: string; + depth?: number; // for indentation of nested rows +} + +export const GenericRow = ({ + width, + title, + metadata, + actions, + content, + isOpenByDefault = false, + children, + leftColumnClassName, + rightColumnClassName, + depth = 0, +}: GenericRowProps) => { + const styles = useStyles2(getStyles); + const [isOpen, handleToggle] = useToggle(isOpenByDefault); + + const hasChildren = Boolean(children); + const showChildContent = isOpen && hasChildren; + + return ( + <> +
+
+
+ +
+
+
+ {content &&
{content}
} +
+
+ {showChildContent ? children : null} + + ); +}; + +interface LeftCellProps { + title: ReactNode; + metadata?: ReactNode; + actions?: ReactNode; + isOpen?: boolean; + onToggle?: () => void; +} + +const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onToggle }: LeftCellProps) => { + const styles = useStyles2(getStyles); + + return ( + + {onToggle && ( + onToggle()} + className={styles.dropdownIcon} + variant="secondary" + size="md" + aria-label={t('alerting.group-wrapper.toggle', 'Toggle group')} + /> + )} + + + {title} + {actions && } + {actions} + + {metadata} + + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + dropdownIcon: css({ + alignSelf: 'flex-start', + marginTop: theme.spacing(0.5), + }), + column: css({ + display: 'flex', + position: 'relative', + flexBasis: 0, + border: 'solid 1px transparent', + borderBottom: `1px solid ${theme.colors.border.medium}`, + borderLeft: `1px solid ${theme.colors.border.medium}`, + borderRight: `1px solid ${theme.colors.border.medium}`, + }), + leftColumn: css({ + overflow: 'hidden', + }), + columnContent: (depth?: number) => + css({ + padding: 5, + width: '100%', + paddingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5, + }), + groupItemWrapper: (width: number) => + css({ + display: 'grid', + gridTemplateColumns: `${width}px auto`, + gap: theme.spacing(2), + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/rows/GroupRow.tsx b/public/app/features/alerting/unified/triage/rows/GroupRow.tsx new file mode 100644 index 00000000000..4499672dd6e --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/GroupRow.tsx @@ -0,0 +1,42 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { AlertLabel } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { GenericGroupedRow } from '../types'; + +import { GenericRow } from './GenericRow'; + +interface GroupRowProps { + row: GenericGroupedRow; + leftColumnWidth: number; + rowKey: React.Key; + depth?: number; + children?: React.ReactNode; +} + +export const GroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: GroupRowProps) => { + const styles = useStyles2(getStyles); + + return ( + } + isOpenByDefault={true} + leftColumnClassName={styles.groupRow} + rightColumnClassName={styles.groupRow} + depth={depth} + > + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + groupRow: css({ + backgroundColor: theme.colors.background.secondary, + }), +}); diff --git a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx new file mode 100644 index 00000000000..7480d6f8a7e --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx @@ -0,0 +1,114 @@ +import { css } from '@emotion/css'; +import { isEmpty } from 'lodash'; +import { useMemo } from 'react'; + +import { AlertLabels } from '@grafana/alerting/unstable'; +import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { SceneDataNode, VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel } from '@grafana/scenes-react'; +import { GraphDrawStyle, VisibilityMode } from '@grafana/schema'; +import { + AxisPlacement, + BarAlignment, + LegendDisplayMode, + StackingMode, + Text, + TooltipDisplayMode, + useStyles2, +} from '@grafana/ui'; + +import { overrideToFixedColor } from '../../home/Insights'; + +import { GenericRow } from './GenericRow'; + +interface Instance { + labels: Labels; + series: DataFrame[]; +} + +interface InstanceRowProps { + instance: Instance; + commonLabels: Labels; + leftColumnWidth: number; + timeRange: TimeRange; + depth?: number; +} + +const chartConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars) + .setCustomFieldConfig('barWidthFactor', 1) + .setCustomFieldConfig('barAlignment', BarAlignment.After) + .setCustomFieldConfig('showPoints', VisibilityMode.Never) + .setCustomFieldConfig('fillOpacity', 60) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('stacking', { mode: StackingMode.None }) + .setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden) + .setCustomFieldConfig('axisGridShow', false) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setOption('legend', { + showLegend: false, + displayMode: LegendDisplayMode.Hidden, + }) + .setMin(0) + .setMax(1) + .setOverrides((builder) => + builder + .matchFieldsWithName('firing') + .overrideColor(overrideToFixedColor('firing')) + .matchFieldsWithName('pending') + .overrideColor(overrideToFixedColor('pending')) + ) + .build(); + +export function InstanceRow({ instance, commonLabels, leftColumnWidth, timeRange, depth = 0 }: InstanceRowProps) { + const styles = useStyles2(getStyles); + + const dataProvider = useMemo( + () => + new SceneDataNode({ + data: { + series: instance.series, + state: LoadingState.Done, + timeRange, + }, + }), + [instance, timeRange] + ); + + return ( + + + No labels + +
+ ) : ( + + ) + } + content={ + + } + depth={depth} + /> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + minHeight: theme.spacing(2.5), + display: 'flex', + alignItems: 'center', + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/rows/utils.ts b/public/app/features/alerting/unified/triage/rows/utils.ts new file mode 100644 index 00000000000..6a4d0731643 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/utils.ts @@ -0,0 +1,13 @@ +import { WorkbenchRow } from '../types'; + +// Generate unique keys for WorkbenchRow items +export function generateRowKey(row: WorkbenchRow, fallbackIndex: number): string { + if (row.type === 'alertRule') { + // Use ruleUID as primary key for AlertRuleRow + return `alert-${row.metadata.ruleUID}`; + } else { + // For GenericGroupedRow, create key from label and value + const groupedRow = row; + return `group-${groupedRow.metadata.label}-${groupedRow.metadata.value}`; + } +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx new file mode 100644 index 00000000000..9d2889d9cae --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx @@ -0,0 +1,113 @@ +import { omit } from 'lodash'; +import { useMemo } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { DataFrame, Labels, findCommonLabels } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { useQueryRunner, useTimeRange } from '@grafana/scenes-react'; +import { Box } from '@grafana/ui'; + +import { useWorkbenchContext } from '../WorkbenchContext'; +import { METRIC_NAME } from '../constants'; +import { GenericRow } from '../rows/GenericRow'; +import { InstanceRow } from '../rows/InstanceRow'; + +import { getDataQuery } from './utils'; + +function extractInstancesFromData(series: DataFrame[] | undefined) { + if (!series) { + return []; + } + + // 1. Group series by labels, ignoring alertstate + const groups = new Map(); + series.forEach((series) => { + const valueField = series.fields.find((f) => f.type !== 'time'); + if (!valueField) { + return; + } + + const keyLabels = omit(valueField.labels ?? {}, 'alertstate'); + const key = JSON.stringify(keyLabels); + + if (!groups.has(key)) { + groups.set(key, { labels: keyLabels, series: [] }); + } + groups.get(key)!.series.push(series); + }); + + return Array.from(groups.values()); +} + +type AlertRuleInstancesProps = { + ruleUID: string; + depth?: number; +}; + +export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesProps) { + const { leftColumnWidth } = useWorkbenchContext(); + const [timeRange] = useTimeRange(); + + const query = getDataQuery( + `count without (alertname, grafana_alertstate, grafana_folder, grafana_rule_uid) (${METRIC_NAME}{grafana_rule_uid="${ruleUID}"})`, + { format: 'timeseries', legendFormat: '{{alertstate}}' } + ); + + const queryRunner = useQueryRunner({ queries: [query] }); + + const isLoading = !queryRunner.isDataReadyToDisplay(); + const { data } = queryRunner.useState(); + + const instances = useMemo(() => extractInstancesFromData(data?.series), [data]); + + if (isLoading) { + return ; + } + + if (!instances.length && !isLoading) { + return ( + Alert instances} + depth={depth} + > +
+ No alert instances found for rule: {ruleUID} +
+
+ ); + } + + const allSeriesLabels: Labels[] = instances.map((instance) => instance.labels); + const commonLabels = allSeriesLabels.length === 1 ? {} : findCommonLabels(allSeriesLabels); + + return ( + <> + {instances.map((instance) => ( + + ))} + + ); +} + +export function GenericRowSkeleton({ width, depth }: { width: number; depth: number }) { + return ( + + + + } + depth={depth} + content={} + /> + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx new file mode 100644 index 00000000000..ac9cb28ef82 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx @@ -0,0 +1,93 @@ +import { VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel, useDataTransformer } from '@grafana/scenes-react'; +import { + AxisPlacement, + BarAlignment, + GraphDrawStyle, + LegendDisplayMode, + StackingMode, + TooltipDisplayMode, + VisibilityMode, +} from '@grafana/schema'; + +import { overrideToFixedColor } from '../../home/Insights'; +import { useWorkbenchContext } from '../WorkbenchContext'; + +/** + * Viz config for the alert rule summary chart - used by the React component + */ +export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars) + .setCustomFieldConfig('barWidthFactor', 1) + .setCustomFieldConfig('barAlignment', BarAlignment.After) + .setCustomFieldConfig('showPoints', VisibilityMode.Never) + .setCustomFieldConfig('fillOpacity', 60) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('stacking', { mode: StackingMode.None }) + .setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden) + .setCustomFieldConfig('axisGridShow', false) + .setMin(0) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setOption('legend', { + showLegend: false, + displayMode: LegendDisplayMode.Hidden, + }) + .setOverrides((builder) => + builder + .matchFieldsWithName('firing') + .overrideColor(overrideToFixedColor('firing')) + .matchFieldsWithName('pending') + .overrideColor(overrideToFixedColor('pending')) + ) + .build(); + +export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { + // Use WorkbenchContext to access the parent query runner and reuse its data + const { queryRunner } = useWorkbenchContext(); + + // Transform parent data to filter by this specific rule and partition by alert state + const transformedData = useDataTransformer({ + data: queryRunner, + transformations: [ + { + id: 'filterByValue', + options: { + filters: [ + { + config: { + id: 'equal', + options: { + value: ruleUID, + }, + }, + fieldName: 'grafana_rule_uid', + }, + ], + match: 'any', + type: 'include', + }, + }, + { + id: 'partitionByValues', + options: { + fields: ['alertstate'], + keepFields: false, + naming: { + asLabels: true, + }, + }, + }, + ], + }); + + return ( + + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx b/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx new file mode 100644 index 00000000000..9e8d24d152f --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx @@ -0,0 +1,54 @@ +import { SceneObjectBase, SceneObjectState, VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel, useQueryRunner } from '@grafana/scenes-react'; +import { BarAlignment, GraphDrawStyle, VisibilityMode } from '@grafana/schema'; +import { LegendDisplayMode, StackingMode, TooltipDisplayMode } from '@grafana/ui'; + +import { overrideToFixedColor } from '../../home/Insights'; +import { METRIC_NAME } from '../constants'; + +import { getDataQuery, useQueryFilter } from './utils'; + +/** + * Viz config for the summary chart - used by the React component + */ +export const summaryChartVizConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars) + .setCustomFieldConfig('barWidthFactor', 1) + .setCustomFieldConfig('barAlignment', BarAlignment.Center) + .setCustomFieldConfig('fillOpacity', 60) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('stacking', { mode: StackingMode.None }) + .setCustomFieldConfig('showPoints', VisibilityMode.Never) + .setOption('legend', { + showLegend: false, + displayMode: LegendDisplayMode.Hidden, + }) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setMin(0) + .setOverrides((builder) => + builder + .matchFieldsWithName('firing') + .overrideColor(overrideToFixedColor('firing')) + .matchFieldsWithName('pending') + .overrideColor(overrideToFixedColor('pending')) + ) + .build(); + +export function SummaryChartReact() { + const filter = useQueryFilter(); + + const dataProvider = useQueryRunner({ + queries: [ + getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { + legendFormat: '{{alertstate}}', // we need this so we can map states to the correct color in the vizConfig + }), + ], + }); + + return ; +} + +// simple wrapper so we can render the Chart using a Scene parent +export class SummaryChartScene extends SceneObjectBase { + static Component = SummaryChartReact; +} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx new file mode 100644 index 00000000000..add4f86934e --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx @@ -0,0 +1,65 @@ +import { DataFrameView } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { useQueryRunner } from '@grafana/scenes-react'; +import { Stack, Text } from '@grafana/ui'; + +import { Spacer } from '../../components/Spacer'; +import { METRIC_NAME } from '../constants'; + +import { getDataQuery, useQueryFilter } from './utils'; + +interface Frame { + alertstate: 'firing' | 'pending'; + Value: number; +} + +export function SummaryStatsReact() { + const filter = useQueryFilter(); + + const dataProvider = useQueryRunner({ + queries: [ + getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { + instant: true, + exemplar: false, + format: 'table', + }), + ], + }); + + const isLoading = !dataProvider.isDataReadyToDisplay; + const data = dataProvider.useState().data; + const firstFrame = data?.series?.at(0); + + if (isLoading || !firstFrame) { + return null; + } + + const dfv = new DataFrameView(firstFrame); + if (dfv.length === 0) { + return null; + } + + const firingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'firing'); + const firingCount = dfv.fields.Value.values[firingIndex] ?? 0; + + const pendingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'pending'); + const pendingCount = dfv.fields.Value.values[pendingIndex] ?? 0; + + return ( + + + + {{ firingCount }} firing instances + + + {{ pendingCount }} pending instances + + + ); +} + +// simple wrapper so we can render the Chart using a Scene parent +export class SummaryStatsScene extends SceneObjectBase { + static Component = SummaryStatsReact; +} diff --git a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx new file mode 100644 index 00000000000..a7a8a51157e --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx @@ -0,0 +1,69 @@ +import { DashboardCursorSync } from '@grafana/data'; +import { + AdHocFiltersVariable, + GroupByVariable, + SceneControlsSpacer, + SceneFlexLayout, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneVariableSet, + VariableValueSelectors, + behaviors, +} from '@grafana/scenes'; +import { EmbeddedSceneWithContext } from '@grafana/scenes-react'; + +import { DATASOURCE_UID } from '../constants'; + +import { WorkbenchSceneObject } from './Workbench'; +import { defaultTimeRange } from './utils'; + +const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair }); + +export const triageScene = new EmbeddedSceneWithContext({ + // this will allow us to share the cursor between all vizualizations + $behaviors: [cursorSync], + controls: [ + new VariableValueSelectors({}), + new SceneControlsSpacer(), + new SceneTimePicker({}), + new SceneRefreshPicker({}), + ], + $timeRange: new SceneTimeRange(defaultTimeRange), + $variables: new SceneVariableSet({ + variables: [ + new GroupByVariable({ + name: 'groupBy', + label: 'Group by', + datasource: { + type: 'prometheus', + uid: DATASOURCE_UID, + }, + allowCustomValue: true, + applyMode: 'manual', + value: 'grafana_folder', + }), + new AdHocFiltersVariable({ + name: 'filters', + label: 'Filters', + datasource: { + type: 'prometheus', + uid: DATASOURCE_UID, + }, + applyMode: 'manual', // we will construct the label matchers for the PromQL queries ourselves + allowCustomValue: true, + useQueriesAsFilterForOptions: true, + supportsMultiValueOperators: true, + filters: [], + baseFilters: [], + layout: 'combobox', + }), + ], + }), + body: new SceneFlexLayout({ + direction: 'column', + children: [new WorkbenchSceneObject({})], + }), +}); + +export const TriageScene = () => ; diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx new file mode 100644 index 00000000000..356a2924461 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -0,0 +1,133 @@ +import { ArrayValues } from 'type-fest'; + +import { DataFrame, PanelData } from '@grafana/data'; +import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { useQueryRunner, useTimeRange, useVariableValues } from '@grafana/scenes-react'; + +import { Workbench } from '../Workbench'; +import { DEFAULT_FIELDS, METRIC_NAME, VARIABLES } from '../constants'; +import { AlertRuleRow, GenericGroupedRow, WorkbenchRow } from '../types'; + +import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils'; + +export class WorkbenchSceneObject extends SceneObjectBase { + public static Component = WorkbenchRenderer; +} + +export function WorkbenchRenderer() { + const [timeRange] = useTimeRange(); + const domain = convertTimeRangeToDomain(timeRange); + + const [groupByKeys = []] = useVariableValues(VARIABLES.groupBy); + + const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(','); + const queryFilter = useQueryFilter(); + + const runner = useQueryRunner({ + queries: [ + getDataQuery(`count by (${countBy}) (${METRIC_NAME}{${queryFilter}})`, { + format: 'table', + }), + ], + }); + const { data } = runner.useState(); + const rows = data ? convertToWorkbenchRows(data, groupByKeys) : []; + + return ; +} + +type DataPoint = Record, string> & Record; + +function createAlertRuleRows(dataPoints: DataPoint[]): AlertRuleRow[] { + const rules = new Map< + string, + { + alertname: string; + folder: string; + ruleUID: string; + } + >(); + + for (const dp of dataPoints) { + const ruleUID = dp.grafana_rule_uid; + if (!rules.has(ruleUID)) { + rules.set(ruleUID, { + alertname: dp.alertname, + folder: dp.grafana_folder, + ruleUID: ruleUID, + }); + } + } + + const result: AlertRuleRow[] = []; + for (const rule of rules.values()) { + result.push({ + type: 'alertRule', + metadata: { + title: rule.alertname, + folder: rule.folder, + ruleUID: rule.ruleUID, + }, + }); + } + return result; +} + +function groupData(dataPoints: DataPoint[], groupBy: string[], depth: number): WorkbenchRow[] { + if (depth >= groupBy.length) { + return createAlertRuleRows(dataPoints); + } + + const groupByKey = groupBy[depth]; + const grouped = new Map(); + + for (const dp of dataPoints) { + const key = String(dp[groupByKey] ?? 'undefined'); + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key)?.push(dp); + } + + const result: GenericGroupedRow[] = []; + for (const [value, rows] of grouped.entries()) { + result.push({ + type: 'group', + metadata: { + label: groupByKey, + value: value, + }, + rows: groupData(rows, groupBy, depth + 1), + }); + } + + return result; +} + +// @TODO narrower types for PanelData! (if possible) +export function convertToWorkbenchRows(data: PanelData, groupBy: string[] = []): WorkbenchRow[] { + if (!data.series.at(0)?.fields.length) { + return []; + } + + const frame = data.series[0]; + if (!isValidFrame(frame)) { + return []; + } + + const allDataPoints = Array.from({ length: frame.length }, (_, i) => { + const dataPoint: DataPoint = Object.create(null); + frame.fields.forEach((field) => { + dataPoint[field.name] = field.values[i]; + }); + return dataPoint; + }); + + return groupData(allDataPoints, groupBy, 0); +} + +function isValidFrame(frame: DataFrame) { + const requiredFieldNames = ['Time', ...DEFAULT_FIELDS]; + const fieldNames = new Set(frame.fields.map((f) => f.name)); + return requiredFieldNames.every((name) => fieldNames.has(name)); +} diff --git a/public/app/features/alerting/unified/triage/scene/utils.ts b/public/app/features/alerting/unified/triage/scene/utils.ts new file mode 100644 index 00000000000..19886a760e0 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/utils.ts @@ -0,0 +1,54 @@ +import { TimeRange } from '@grafana/data'; +import { SceneDataQuery } from '@grafana/scenes'; +import { useVariableValue, useVariableValues } from '@grafana/scenes-react'; +import { DataSourceRef } from '@grafana/schema'; + +import { DATASOURCE_UID, VARIABLES } from '../constants'; +import { Domain } from '../types'; + +export function getDataQuery(expression: string, options?: Partial): SceneDataQuery { + const datasourceRef: DataSourceRef = { + type: 'prometheus', + uid: DATASOURCE_UID, + }; + + const query: SceneDataQuery = { + refId: 'query', + expr: expression, + instant: false, + datasource: datasourceRef, + ...options, + }; + + return query; +} + +/** + * Turns an array of "groupBy" keys into a Prometheus matcher such as key!="",key2!="" . + * This way we can show only instances that have a label that was grouped on. + */ +export function stringifyGroupFilter(groupBy: string[]) { + return groupBy.map((key) => `${key}!=""`).join(','); +} + +export const defaultTimeRange = { + from: 'now-4h', + to: 'now', +} as const; + +export function convertTimeRangeToDomain(timeRange: TimeRange): Domain { + return [timeRange.from.toDate(), timeRange.to.toDate()]; +} + +/** + * This hook will create a Prometheus label matcher string from the "groupBy" and "filters" variables + */ +export function useQueryFilter(): string { + const [groupBy = []] = useVariableValues(VARIABLES.groupBy); + const [filters = ''] = useVariableValue(VARIABLES.filters); + + const groupByFilter = stringifyGroupFilter(groupBy); + const queryFilter = [groupByFilter, filters].filter((s) => Boolean(s)).join(','); + + return queryFilter; +} diff --git a/public/app/features/alerting/unified/triage/types.ts b/public/app/features/alerting/unified/triage/types.ts new file mode 100644 index 00000000000..b599f46cb33 --- /dev/null +++ b/public/app/features/alerting/unified/triage/types.ts @@ -0,0 +1,24 @@ +export type Domain = [Date, Date]; +export type Filter = [key: string, operator: '=' | '=!', value: string]; + +export type WorkbenchRow = GenericGroupedRow | AlertRuleRow; + +export type TimelineEntry = [timestamp: number, state: 'firing' | 'pending']; + +export interface AlertRuleRow { + type: 'alertRule'; + metadata: { + title: string; + folder: string; + ruleUID: string; + }; +} + +export interface GenericGroupedRow { + type: 'group'; + metadata: { + label: string; + value: string; + }; + rows: WorkbenchRow[]; +} diff --git a/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx b/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx index f2e5e141297..c82c66e4b4a 100644 --- a/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx +++ b/public/app/features/annotations/components/AnnotationQueryEditorActionsWrapper.tsx @@ -1,6 +1,6 @@ import { ReactElement } from 'react'; -import { AnnotationQuery, CoreApp, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; +import { AnnotationQuery, CoreApp, DataSourceApi } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { Stack } from '@grafana/ui'; import { useQueryLibraryContext } from 'app/features/explore/QueryLibrary/QueryLibraryContext'; @@ -11,25 +11,17 @@ interface Props { children: ReactElement; annotation: AnnotationQuery; datasource: DataSourceApi; - datasourceInstanceSettings: DataSourceInstanceSettings; onQueryReplace: (query: DataQuery) => void; } -export function AnnotationQueryEditorActionsWrapper({ - children, - annotation, - datasource, - datasourceInstanceSettings, - onQueryReplace, -}: Props) { +export function AnnotationQueryEditorActionsWrapper({ children, annotation, datasource, onQueryReplace }: Props) { const { renderSavedQueryButtons } = useQueryLibraryContext(); const savedQueryButtons = renderSavedQueryButtons( getDataQueryFromAnnotationForSavedQueries(annotation, datasource), CoreApp.Dashboard, undefined, - onQueryReplace, - datasourceInstanceSettings?.name ? [datasourceInstanceSettings.name] : [] + onQueryReplace ); return ( diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx index 190622bccc4..6431c01f1e9 100644 --- a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -310,7 +310,6 @@ export default class StandardAnnotationQueryEditor extends PureComponent state.navIndex); const isAddNewConnectionPageOverridden = Boolean(navIndex['standalone-plugin-page-/connections/add-new-connection']); + const shouldEnableFeatureHighlights = isOpenSourceBuildOrUnlicenced(); return ( @@ -41,6 +47,27 @@ export default function Connections() { element={} /> } /> + + {shouldEnableFeatureHighlights && ( + <> + } + /> + } + /> + } + /> + + )} + (); + useInitDataSourceSettings(uid); + + const { navId, pageNav, dataSourceHeader } = useDataSourceTabNav(pageName); + const styles = useStyles2(getStyles); + + const info = useDataSourceInfo({ + dataSourcePluginName: pageNav.dataSourcePluginName, + alertingSupported: dataSourceHeader.alertingSupported, + }); + + return ( + } + info={info} + actions={} + > + +
+
+
+ +
+

{title}

+
{header}
+
+ {items.map((item) => ( +
+ + {item} +
+ ))} +
+
+ + Create a Grafana Cloud Free account to start using data source permissions. This feature is also + available with a Grafana Enterprise license. + +
+ + + Learn about Enterprise + +
+
+ + + Create account + +

+ + After creating an account, you can easily{' '} + + migrate this instance to Grafana Cloud + {' '} + with our Migration Assistant. + +

+
+
+ {`${pageName} +
+
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + gap: theme.spacing(4), + alignItems: 'flex-start', + [theme.breakpoints.down('lg')]: { + flexDirection: 'column', + }, + }), + content: css({ + flex: '0 0 40%', + }), + imageContainer: css({ + flex: '0 0 60%', + display: 'flex', + [theme.breakpoints.down('lg')]: { + flex: '1 1 auto', + }, + padding: `${theme.spacing(5)} 10% 0 ${theme.spacing(5)}`, + }), + image: css({ + width: '100%', + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + }), + buttonIcon: css({ + marginRight: theme.spacing(1), + }), + badge: css({ + marginBottom: theme.spacing(1), + }), + title: css({ + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + }), + header: css({ + color: theme.colors.text.primary, + }), + + itemsList: css({ + marginBottom: theme.spacing(3), + marginTop: theme.spacing(3), + }), + + listItem: css({ + display: 'flex', + alignItems: 'flex-start', + color: theme.colors.text.primary, + lineHeight: theme.typography.bodySmall.lineHeight, + marginBottom: theme.spacing(2), + }), + + linkButton: css({ + marginBottom: theme.spacing(2), + }), + + footer: css({ + marginBottom: theme.spacing(3), + marginTop: theme.spacing(3), + }), + + icon: css({ + marginRight: theme.spacing(1), + color: theme.colors.success.main, + }), + footNote: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + }), +}); diff --git a/public/app/features/connections/hooks/useDataSourceTabNav.ts b/public/app/features/connections/hooks/useDataSourceTabNav.ts new file mode 100644 index 00000000000..2c7231330b5 --- /dev/null +++ b/public/app/features/connections/hooks/useDataSourceTabNav.ts @@ -0,0 +1,95 @@ +import { useLocation, useParams } from 'react-router-dom-v5-compat'; + +import { NavModel, NavModelItem } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { useDataSource, useDataSourceMeta, useDataSourceSettings } from 'app/features/datasources/state/hooks'; +import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from 'app/features/datasources/state/navModel'; +import { useGetSingle } from 'app/features/plugins/admin/state/hooks'; +import { useSelector } from 'app/types/store'; + +export function useDataSourceTabNav(pageName: string, pageIdParam?: string) { + const { uid = '' } = useParams<{ uid: string }>(); + const location = useLocation(); + const datasource = useDataSource(uid); + const dataSourceMeta = useDataSourceMeta(datasource.type); + const datasourcePlugin = useGetSingle(datasource.type); + const params = new URLSearchParams(location.search); + const pageId = pageIdParam || params.get('page'); + + const { plugin, loadError, loading } = useDataSourceSettings(); + const dsi = getDataSourceSrv()?.getInstanceSettings(uid); + const hasAlertingEnabled = Boolean(dsi?.meta?.alerting ?? false); + const isAlertManagerDatasource = dsi?.type === 'alertmanager'; + const alertingSupported = hasAlertingEnabled || isAlertManagerDatasource; + + const navIndex = useSelector((state) => state.navIndex); + const navIndexId = pageId ? `datasource-${pageId}-${uid}` : `datasource-${pageName}-${uid}`; + + let pageNav: NavModel = { + node: { + text: t('connections.use-data-source-settings-nav.page-nav.text.data-source-nav-node', 'Data Source Nav Node'), + }, + main: { + text: t('connections.use-data-source-settings-nav.page-nav.text.data-source-nav-node', 'Data Source Nav Node'), + }, + }; + + if (loadError) { + const node: NavModelItem = { + text: loadError, + subTitle: t('connections.use-data-source-settings-nav.node.subTitle.data-source-error', 'Data Source Error'), + icon: 'exclamation-triangle', + }; + + pageNav = { + node: node, + main: node, + }; + } + + if (loading || !plugin) { + pageNav = getNavModel(navIndex, navIndexId, getDataSourceLoadingNav(pageName)); + } + + if (!datasource.uid) { + const node: NavModelItem = { + text: t('connections.use-data-source-settings-nav.node.subTitle.data-source-error', 'Data Source Error'), + icon: 'exclamation-triangle', + }; + + pageNav = { + node: node, + main: node, + }; + } + + if (plugin) { + pageNav = getNavModel( + navIndex, + navIndexId, + getDataSourceNav(buildNavModel(datasource, plugin), pageId || pageName) + ); + } + + const connectionsPageNav = { + ...pageNav.main, + dataSourcePluginName: datasourcePlugin?.name || plugin?.meta.name || '', + active: true, + text: datasource.name || '', + subTitle: dataSourceMeta.name ? `Type: ${dataSourceMeta.name}` : '', + children: (pageNav.main.children || []).map((navModelItem) => ({ + ...navModelItem, + url: navModelItem.url?.replace('datasources/edit/', '/connections/datasources/edit/'), + })), + }; + + return { + navId: 'connections-datasources', + pageNav: connectionsPageNav, + dataSourceHeader: { + alertingSupported, + }, + }; +} diff --git a/public/app/features/connections/pages/CacheFeatureHighlightPage.tsx b/public/app/features/connections/pages/CacheFeatureHighlightPage.tsx new file mode 100644 index 00000000000..bb63af4e189 --- /dev/null +++ b/public/app/features/connections/pages/CacheFeatureHighlightPage.tsx @@ -0,0 +1,33 @@ +import { t } from '@grafana/i18n'; +import cacheScreenshot from 'img/cache-screenshot.png'; + +import { FeatureHighlightsTabPage } from '../components/FeatureHighlightsTabPage'; + +export function CacheFeatureHighlightPage() { + return ( + + ); +} diff --git a/public/app/features/connections/pages/InsightsFeatureHighlightPage.tsx b/public/app/features/connections/pages/InsightsFeatureHighlightPage.tsx new file mode 100644 index 00000000000..78f24a5026c --- /dev/null +++ b/public/app/features/connections/pages/InsightsFeatureHighlightPage.tsx @@ -0,0 +1,40 @@ +import { t } from '@grafana/i18n'; +import insightsScreenshot from 'img/insights-screenshot.png'; + +import { FeatureHighlightsTabPage } from '../components/FeatureHighlightsTabPage'; + +export function InsightsFeatureHighlightPage() { + return ( + + ); +} diff --git a/public/app/features/connections/pages/PermissionsFeatureHighlightPage.tsx b/public/app/features/connections/pages/PermissionsFeatureHighlightPage.tsx new file mode 100644 index 00000000000..4d5b8884b34 --- /dev/null +++ b/public/app/features/connections/pages/PermissionsFeatureHighlightPage.tsx @@ -0,0 +1,36 @@ +import { t } from '@grafana/i18n'; +import permissionsScreenshot from 'img/permissions-screenshot.png'; + +import { FeatureHighlightsTabPage } from '../components/FeatureHighlightsTabPage'; + +export function PermissionsFeatureHighlightPage() { + return ( + + ); +} diff --git a/public/app/features/connections/tabs/ConnectData/DataSourceTabs.test.tsx b/public/app/features/connections/tabs/ConnectData/DataSourceTabs.test.tsx new file mode 100644 index 00000000000..6a772be530b --- /dev/null +++ b/public/app/features/connections/tabs/ConnectData/DataSourceTabs.test.tsx @@ -0,0 +1,150 @@ +import { RenderResult, screen } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom-v5-compat'; +import { render } from 'test/test-utils'; + +import { LayoutModes, PluginType } from '@grafana/data'; +import { setPluginLinksHook, setPluginComponentsHook } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import * as api from 'app/features/datasources/api'; +import { getMockDataSources } from 'app/features/datasources/mocks/dataSourcesMocks'; +import { configureStore } from 'app/store/configureStore'; + +import { getPluginsStateMock } from '../../../plugins/admin/mocks/mockHelpers'; +import Connections from '../../Connections'; +import { ROUTES } from '../../constants'; +import { navIndex } from '../../mocks/store.navIndex.mock'; + +setPluginLinksHook(() => ({ links: [], isLoading: false })); +setPluginComponentsHook(() => ({ components: [], isLoading: false })); + +const mockDatasources = getMockDataSources(3); + +const renderPage = ( + path: string = ROUTES.Base, + store = configureStore({ + navIndex, + plugins: getPluginsStateMock([]), + dataSources: { + dataSources: mockDatasources, + dataSourcesCount: mockDatasources.length, + isLoadingDataSources: false, + searchQuery: '', + dataSourceTypeSearchQuery: '', + layoutMode: LayoutModes.List, + dataSource: mockDatasources[0], + dataSourceMeta: { + id: '', + name: '', + type: PluginType.panel, + info: { + author: { + name: '', + url: undefined, + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + module: '', + baseUrl: '', + backend: true, + isBackend: true, + }, + isLoadingDataSourcePlugins: false, + plugins: [], + categories: [], + isSortAscending: true, + }, + }) +): RenderResult => { + return render( + + } /> + , + { + store, + historyOptions: { initialEntries: [path] }, + } + ); +}; + +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + return { + ...original, + config: { + ...original.config, + bootData: { + user: { + orgId: 1, + timezone: 'UTC', + }, + navTree: [], + }, + featureToggles: { + ...original.config.featureToggles, + }, + datasources: {}, + defaultDatasource: '', + buildInfo: { + ...original.config.buildInfo, + edition: 'Open Source', + }, + caching: { + ...original.config.caching, + enabled: true, + }, + }, + getTemplateSrv: () => ({ + replace: (str: string) => str, + }), + getDataSourceSrv: () => { + return { + getInstanceSettings: (uid: string) => { + return { + id: uid, + uid: uid, + type: PluginType.datasource, + name: uid, + meta: { + id: uid, + name: uid, + type: PluginType.datasource, + backend: true, + isBackend: true, + }, + }; + }, + }; + }, + }; +}); + +describe('DataSourceEditTabs', () => { + beforeEach(() => { + process.env.NODE_ENV = 'test'; + (api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources); + (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true); + }); + + it('should render Permissions and Insights tabs', () => { + const path = ROUTES.DataSourcesEdit.replace(':uid', mockDatasources[0].uid); + renderPage(path); + + const permissionsTab = screen.getByTestId('data-testid Tab Permissions'); + expect(permissionsTab).toBeInTheDocument(); + expect(permissionsTab).toHaveTextContent('Permissions'); + expect(permissionsTab).toHaveAttribute('href', '/connections/datasources/edit/x/permissions'); + + const insightsTab = screen.getByTestId('data-testid Tab Insights'); + expect(insightsTab).toBeInTheDocument(); + expect(insightsTab).toHaveTextContent('Insights'); + expect(insightsTab).toHaveAttribute('href', '/connections/datasources/edit/x/insights'); + }); +}); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 9af2af9d11f..94e3d1ed2ad 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -406,7 +406,6 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps openQueryLibraryDrawer({ - datasourceFilters: getDatasourceNames(datasource, queries), onSelectQuery: onSelectQueryFromLibrary, options: { context: CoreApp.PanelEditor, @@ -434,16 +433,6 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps dsSrv.getInstanceSettings(ds.datasource)?.name).filter((name) => name !== undefined); - } else { - return [datasource.name]; - } -} - interface QueriesTabProps extends PanelDataTabHeaderProps { model: PanelDataQueriesTab; } diff --git a/public/app/features/datasources/components/DataSourceTabPage.tsx b/public/app/features/datasources/components/DataSourceTabPage.tsx index 2857cebe084..03f43e400c5 100644 --- a/public/app/features/datasources/components/DataSourceTabPage.tsx +++ b/public/app/features/datasources/components/DataSourceTabPage.tsx @@ -13,7 +13,7 @@ export interface Props { } export function DataSourceTabPage({ uid, pageId }: Props) { - const { navId, pageNav, dataSourceHeader } = useDataSourceSettingsNav(); + const { navId, pageNav, dataSourceHeader } = useDataSourceSettingsNav('settings'); const info = useDataSourceInfo({ dataSourcePluginName: pageNav.dataSourcePluginName, diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts index 2c685beae68..b11c13c6c4f 100644 --- a/public/app/features/datasources/state/navModel.ts +++ b/public/app/features/datasources/state/navModel.ts @@ -4,6 +4,7 @@ import { featureEnabled } from '@grafana/runtime'; import { ProBadge } from 'app/core/components/Upgrade/ProBadge'; import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; +import { isOpenSourceBuildOrUnlicenced } from 'app/features/admin/EnterpriseAuthFeaturesCard'; import { highlightTrial } from 'app/features/admin/utils'; import { AccessControlAction } from 'app/types/accessControl'; import icnDatasourceSvg from 'img/icn-datasource.svg'; @@ -53,6 +54,8 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat }); } + const shouldEnableFeatureHighlights = isOpenSourceBuildOrUnlicenced(); + const isLoadingNav = dataSource.type === loadingDSType; const permissionsExperimentId = 'feature-highlights-data-source-permissions-badge'; @@ -64,12 +67,15 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat url: `datasources/edit/${dataSource.uid}/permissions`, }; - if (highlightTrial() && !isLoadingNav) { + if ((highlightTrial() && !isLoadingNav) || shouldEnableFeatureHighlights) { dsPermissions.tabSuffix = () => ProBadge({ experimentId: permissionsExperimentId, eventVariant: 'trial' }); } - if (featureEnabled('dspermissions.enforcement')) { - if (contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesPermissionsRead, dataSource)) { + if (featureEnabled('dspermissions.enforcement') || shouldEnableFeatureHighlights) { + if ( + contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesPermissionsRead, dataSource) || + shouldEnableFeatureHighlights + ) { navModel.children!.push(dsPermissions); } } else if (highlightsEnabled && !isLoadingNav) { @@ -80,7 +86,7 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat }); } - if (config.analytics?.enabled) { + if (config.analytics?.enabled || shouldEnableFeatureHighlights) { const analyticsExperimentId = 'feature-highlights-data-source-insights-badge'; const analytics: NavModelItem = { active: false, @@ -90,12 +96,12 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat url: `datasources/edit/${dataSource.uid}/insights`, }; - if (highlightTrial() && !isLoadingNav) { + if ((highlightTrial() && !isLoadingNav) || shouldEnableFeatureHighlights) { analytics.tabSuffix = () => ProBadge({ experimentId: analyticsExperimentId, eventVariant: 'trial' }); } - if (featureEnabled('analytics')) { - if (contextSrv.hasPermission(AccessControlAction.DataSourcesInsightsRead)) { + if (featureEnabled('analytics') || shouldEnableFeatureHighlights) { + if (contextSrv.hasPermission(AccessControlAction.DataSourcesInsightsRead) || shouldEnableFeatureHighlights) { navModel.children!.push(analytics); } } else if (highlightsEnabled && !isLoadingNav) { @@ -118,12 +124,15 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat hideFromTabs: !pluginMeta.isBackend || !config.caching.enabled, }; - if (highlightTrial() && !isLoadingNav) { + if ((highlightTrial() && !isLoadingNav) || shouldEnableFeatureHighlights) { caching.tabSuffix = () => ProBadge({ experimentId: cachingExperimentId, eventVariant: 'trial' }); } - if (featureEnabled('caching')) { - if (contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesCachingRead, dataSource)) { + if (featureEnabled('caching') || shouldEnableFeatureHighlights) { + if ( + contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesCachingRead, dataSource) || + shouldEnableFeatureHighlights + ) { navModel.children!.push(caching); } } else if (highlightsEnabled && !isLoadingNav) { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 3c505a5aeea..56076abfcce 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -30,6 +30,7 @@ import { serializeStateToUrlParam, urlUtil, LogLevel, + shallowCompare, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; @@ -53,7 +54,7 @@ import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { LogRows } from 'app/features/logs/components/LogRows'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext'; -import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList'; +import { LogList, LogListOptions } from 'app/features/logs/components/panel/LogList'; import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext'; import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel'; import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; @@ -205,7 +206,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending ); const [isFlipping, setIsFlipping] = useState(false); - const [displayedFields, setDisplayedFields] = useState([]); + const [displayedFields, setDisplayedFields] = useState(panelState?.logs?.displayedFields ?? []); + const [defaultDisplayedFields, setDefaultDisplayedFields] = useState([]); const [contextOpen, setContextOpen] = useState(false); const [contextRow, setContextRow] = useState(undefined); const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState(PINNED_LOGS_MESSAGE); @@ -280,16 +282,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { store.set(visualisationTypeKey, visualisationType); }, [panelState?.logs?.visualisationType]); - useEffect(() => { - let displayedFields: string[] = []; - if (Array.isArray(panelState?.logs?.displayedFields)) { - displayedFields = panelState?.logs?.displayedFields; - } else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') { - displayedFields = Object.values(panelState?.logs?.displayedFields); - } - setDisplayedFields(displayedFields); - }, [panelState?.logs?.displayedFields]); - useUnmount(() => { if (flipOrderTimer) { window.clearTimeout(flipOrderTimer.current); @@ -346,6 +338,15 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ] ); + useEffect(() => { + if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) { + updatePanelState({ + ...panelState?.logs, + displayedFields, + }); + } + }, [displayedFields, panelState?.logs, updatePanelState]); + // actions const onLogRowHover = useCallback( (row?: LogRowModel) => { @@ -544,13 +545,9 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { if (index === -1) { const updatedDisplayedFields = displayedFields.concat(key); setDisplayedFields(updatedDisplayedFields); - updatePanelState({ - ...panelState?.logs, - displayedFields: updatedDisplayedFields, - }); } }, - [displayedFields, panelState?.logs, updatePanelState] + [displayedFields] ); const hideField = useCallback( @@ -559,22 +556,14 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { if (index > -1) { const updatedDisplayedFields = displayedFields.filter((k) => key !== k); setDisplayedFields(updatedDisplayedFields); - updatePanelState({ - ...panelState?.logs, - displayedFields: updatedDisplayedFields, - }); } }, - [displayedFields, panelState?.logs, updatePanelState] + [displayedFields] ); - const clearDetectedFields = useCallback(() => { - updatePanelState({ - ...panelState?.logs, - displayedFields: [], - }); + const clearDisplayedFields = useCallback(() => { setDisplayedFields([]); - }, [panelState?.logs, updatePanelState]); + }, []); const onCloseCallbackRef = useRef<() => void>(() => {}); @@ -703,7 +692,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const visibilityChangedRef = useRef(true); const onLogOptionsChange = useCallback( - (option: LogListControlOptions, value: string | string[] | boolean) => { + (option: LogListOptions, value: string | string[] | boolean) => { if (option === 'sortOrder' && isLogsSortOrder(value)) { sortOrderChanged(value); } else if (option === 'dedupStrategy' && isDedupStrategy(value)) { @@ -757,6 +746,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { return newLevels; }); + } else if (option === 'defaultDisplayedFields' && Array.isArray(value)) { + setDefaultDisplayedFields(value); } }, [logsVolumeData?.data, logsVolumeEnabled, sortOrderChanged] @@ -985,7 +976,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { dedupStrategy={dedupStrategy} dedupCount={dedupCount} displayedFields={displayedFields} - clearDetectedFields={clearDetectedFields} + clearDisplayedFields={clearDisplayedFields} + defaultDisplayedFields={defaultDisplayedFields} />
diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index 598b7cebb7a..ea0e65f91cb 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -26,7 +26,8 @@ const defaultProps: LogsMetaRowProps = { dedupCount: 0, displayedFields: [], logRows: [], - clearDetectedFields: jest.fn(), + clearDisplayedFields: jest.fn(), + defaultDisplayedFields: [], }; const setup = (propOverrides?: object, disableDownload = false) => { @@ -61,7 +62,7 @@ describe('LogsMetaRow', () => { it('renders a button to clear displayedfields', () => { const clearSpy = jest.fn(); - setup({ displayedFields: ['testField1234'], clearDetectedFields: clearSpy }); + setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy }); fireEvent( screen.getByRole('button', { name: 'Show original line', diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 96206ea3858..04673a11de2 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -1,7 +1,16 @@ import { css } from '@emotion/css'; import { memo } from 'react'; -import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data'; +import { + LogsDedupStrategy, + LogsMetaItem, + LogsMetaKind, + LogRowModel, + CoreApp, + Labels, + store, + shallowCompare, +} from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui'; @@ -30,11 +39,20 @@ export type Props = { dedupCount: number; displayedFields: string[]; logRows: LogRowModel[]; - clearDetectedFields: () => void; + clearDisplayedFields: () => void; + defaultDisplayedFields: string[]; }; export const LogsMetaRow = memo( - ({ meta, dedupStrategy, dedupCount, displayedFields, clearDetectedFields, logRows }: Props) => { + ({ + meta, + dedupStrategy, + dedupCount, + displayedFields, + clearDisplayedFields, + logRows, + defaultDisplayedFields, + }: Props) => { const style = useStyles2(getStyles); const logsMetaItem: Array = [...meta]; @@ -49,7 +67,7 @@ export const LogsMetaRow = memo( } // Add detected fields info - if (displayedFields?.length > 0) { + if (displayedFields?.length > 0 && shallowCompare(displayedFields, defaultDisplayedFields) === false) { logsMetaItem.push( { label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'), @@ -58,8 +76,8 @@ export const LogsMetaRow = memo( { label: '', value: ( - ), } diff --git a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx index f203b3b48a2..2fff4a27fff 100644 --- a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx +++ b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx @@ -1,5 +1,6 @@ import { t } from '@grafana/i18n'; import { useTheme2 } from '@grafana/ui'; +import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats'; import { getLogsFieldsStyles } from './LogsTableActiveFields'; import { LogsTableEmptyFields } from './LogsTableEmptyFields'; @@ -36,7 +37,9 @@ export const LogsTableAvailableFields = (props: { const theme = useTheme2(); const styles = getLogsFieldsStyles(theme); - const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); + const labelKeys = Object.keys(labels) + .filter((labelName) => labelName !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME) + .filter((labelName) => valueFilter(labelName)); if (labelKeys.length) { // Otherwise show list with a hardcoded order return ( diff --git a/public/app/features/explore/SecondaryActions.tsx b/public/app/features/explore/SecondaryActions.tsx index fd4e29fe016..a44ccf01268 100644 --- a/public/app/features/explore/SecondaryActions.tsx +++ b/public/app/features/explore/SecondaryActions.tsx @@ -4,14 +4,9 @@ import { CoreApp, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { ToolbarButton, useTheme2 } from '@grafana/ui'; -import { useSelector } from 'app/types/store'; - -import { createDatasourcesList } from '../../core/utils/richHistory'; -import { MIXED_DATASOURCE_NAME } from '../../plugins/datasource/mixed/MixedDataSource'; import { useQueryLibraryContext } from './QueryLibrary/QueryLibraryContext'; import { type OnSelectQueryType } from './QueryLibrary/types'; -import { selectExploreDSMaps } from './state/selectors'; type Props = { addQueryRowButtonDisabled?: boolean; @@ -45,17 +40,6 @@ export function SecondaryActions({ }: Props) { const theme = useTheme2(); const styles = getStyles(theme); - const exploreActiveDS = useSelector(selectExploreDSMaps); - - // Prefill the query library filter with the dataSource. - // Get current dataSource that is open. As this is only used in Explore we get it from Explore state. - const listOfDatasources = createDatasourcesList(); - const activeDatasources = exploreActiveDS.dsToExplore - .map((eDs) => { - return listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name; - }) - .filter((name): name is string => !!name && name !== MIXED_DATASOURCE_NAME); - const { queryLibraryEnabled, openDrawer: openQueryLibraryDrawer } = useQueryLibraryContext(); return ( @@ -78,7 +62,6 @@ export function SecondaryActions({ variant="canvas" onClick={() => openQueryLibraryDrawer({ - datasourceFilters: activeDatasources, onSelectQuery: onSelectQueryFromLibrary, options: { context: CoreApp.Explore }, }) diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index 13e57b3781f..ee663e3d0d9 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -20,7 +20,7 @@ import { LogsVisualisationType } from '../../explore/Logs/Logs'; import { ControlledLogsTable } from './ControlledLogsTable'; import { InfiniteScroll } from './InfiniteScroll'; import { LogRows, Props } from './LogRows'; -import { LogListControlOptions } from './panel/LogList'; +import { LogListOptions } from './panel/LogList'; import { LogListContextProvider, useLogListContext } from './panel/LogListContext'; import { LogListControls } from './panel/LogListControls'; import { ScrollToLogsEvent } from './panel/virtualization'; @@ -30,7 +30,7 @@ export interface ControlledLogRowsProps extends Omit { logsMeta?: LogsMetaItem[]; loadMoreLogs?: (range: AbsoluteTimeRange) => void; logOptionsStorageKey?: string; - onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void; + onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void; range: TimeRange; filterLevels?: LogLevel[]; diff --git a/public/app/features/logs/components/LogLabels.test.tsx b/public/app/features/logs/components/LogLabels.test.tsx index b70af572021..a5ac1219da9 100644 --- a/public/app/features/logs/components/LogLabels.test.tsx +++ b/public/app/features/logs/components/LogLabels.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody'; import { LogLabels, LogLabelsList } from './LogLabels'; +import { getNormalizedFieldName } from './panel/processing'; describe('', () => { it('renders notice when no labels are found', () => { @@ -96,6 +97,6 @@ describe('', () => { render(); expect(screen.queryByText('bar')).toBeInTheDocument(); expect(screen.queryByText('42')).toBeInTheDocument(); - expect(screen.queryByText('log line')).toBeInTheDocument(); + expect(screen.queryByText(getNormalizedFieldName(LOG_LINE_BODY_FIELD_NAME))).toBeInTheDocument(); }); }); diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index da8107663fb..c112eee4f83 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -5,7 +5,7 @@ import { GrafanaTheme2, Labels } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui'; -import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody'; +import { getNormalizedFieldName } from './panel/processing'; // Levels are already encoded in color, filename is a Loki-ism const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename']; @@ -111,7 +111,7 @@ export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => { {labels.map((label) => ( - {label === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-labels-list.log-line', 'log line') : label} + {getNormalizedFieldName(label)} ))} diff --git a/public/app/features/logs/components/otel/formats.test.ts b/public/app/features/logs/components/otel/formats.test.ts index 4844e2a307a..0da98ea9b5e 100644 --- a/public/app/features/logs/components/otel/formats.test.ts +++ b/public/app/features/logs/components/otel/formats.test.ts @@ -1,7 +1,12 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; -import { getDisplayedFieldsForLogs, getOtelFormattedBody, OTEL_PROBE_FIELD } from './formats'; +import { + getDisplayedFieldsForLogs, + getOtelAttributesField, + OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, + OTEL_PROBE_FIELD, +} from './formats'; describe('getDisplayedFieldsForLogs', () => { test('Does not return displayed fields if not an OTel log line', () => { @@ -18,43 +23,97 @@ describe('getDisplayedFieldsForLogs', () => { test('Returns displayed fields if the OTel probe field is present', () => { const log = createLogLine({ - labels: { [OTEL_PROBE_FIELD]: '1', telemetry_sdk_language: 'php', scope_name: 'scope' }, + labels: { [OTEL_PROBE_FIELD]: '1', telemetry_sdk_language: 'php', thread_name: 'John' }, entry: `place="luna" 1ms 3 KB`, }); - expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]); + expect(getDisplayedFieldsForLogs([log])).toEqual([ + 'thread_name', + LOG_LINE_BODY_FIELD_NAME, + OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, + ]); expect(log.otelLanguage).toBe('php'); }); test('Returns displayed fields if the OTel probe field is present and the language unknown', () => { const log = createLogLine({ - labels: { [OTEL_PROBE_FIELD]: '1', scope_name: 'scope' }, + labels: { [OTEL_PROBE_FIELD]: '1', exception_type: 'fatal', exception_message: 'message' }, entry: `place="luna" 1ms 3 KB`, }); - expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]); + expect(getDisplayedFieldsForLogs([log])).toEqual([ + 'exception_type', + 'exception_message', + LOG_LINE_BODY_FIELD_NAME, + OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, + ]); expect(log.otelLanguage).toBe('unknown'); }); + + test('Returns the minimal displayed fields if others are not present', () => { + const log = createLogLine({ + labels: { [OTEL_PROBE_FIELD]: '1' }, + entry: `place="luna" 1ms 3 KB`, + }); + + expect(getDisplayedFieldsForLogs([log])).toEqual([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]); + }); }); -describe('getOtelFormattedBody', () => { - test('Does not modify non OTel logs', () => { - const log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` }); - expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB`); - }); - - test('Returns an OTel augmented log line body', () => { +describe('getOtelAttributesField', () => { + test('Builds the OTel attributes fields from the log line fields including and excluding fields', () => { const log = createLogLine({ labels: { - severity_number: '1', - telemetry_sdk_language: 'php', - scope_name: 'scope', - aws_ignore: 'ignored', - key: 'value', - otel: 'otel', + aws_something: 'nope', + k8s_something: 'nope', + cluster: 'nope', + namespace: 'nope', + pod: 'nope', + vcs_ref_head_name: 'main', + field: 'value', }, entry: `place="luna" 1ms 3 KB`, }); - expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`); + + expect(getOtelAttributesField(log, true)).toEqual('vcs_ref_head_name=main field=value'); + }); + + test('Correctly matches excluded labels', () => { + const log = createLogLine({ + labels: { + aws_something: 'nope', + k8s_something: 'nope', + cluster: 'nope', + namespace: 'nope', + pod: 'nope', + cluster_1: 'yes', + namespace_2: 'yes', + pod_3: 'yes', + vcs_ref_head_name: 'main', + field: 'value', + }, + entry: `place="luna" 1ms 3 KB`, + }); + + expect(getOtelAttributesField(log, true)).toEqual( + 'cluster_1=yes namespace_2=yes pod_3=yes vcs_ref_head_name=main field=value' + ); + }); + + test('Removes new lines when wrapping is disabled', () => { + const log = createLogLine({ + labels: { + aws_something: 'nope', + k8s_something: 'nope', + cluster: 'nope', + namespace: 'nope', + pod: 'nope', + vcs_ref_head_name: 'ma\nin', + field: 'val\nue', + }, + entry: `place="luna" 1ms 3 KB`, + }); + + expect(getOtelAttributesField(log, false)).toEqual('vcs_ref_head_name=main field=value'); }); }); diff --git a/public/app/features/logs/components/otel/formats.ts b/public/app/features/logs/components/otel/formats.ts index dc28b49e52a..6f870325231 100644 --- a/public/app/features/logs/components/otel/formats.ts +++ b/public/app/features/logs/components/otel/formats.ts @@ -1,14 +1,15 @@ import { LogRowModel } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; -import { LogListModel } from '../panel/processing'; +import { LogListModel, NEWLINES_REGEX } from '../panel/processing'; /** * The presence of this field along log fields determines OTel origin. */ export const OTEL_PROBE_FIELD = 'severity_number'; const OTEL_LANGUAGE_UNKNOWN = 'unknown'; -export function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] { + +function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] { const languagesSet = new Set(); logs.forEach((log) => { const lang = identifyOTelLanguage(log); @@ -28,7 +29,7 @@ export function identifyOTelLanguage(log: LogListModel | LogRowModel): string | : undefined; } -export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) { +function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) { const displayedFields: string[] = []; languages.forEach((language) => { @@ -41,7 +42,10 @@ export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowMode }); return displayedFields.filter( - (field) => field === LOG_LINE_BODY_FIELD_NAME || logs.some((log) => log.labels[field] !== undefined) + (field) => + field === LOG_LINE_BODY_FIELD_NAME || + field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME || + logs.some((log) => log.labels[field] !== undefined) ); } @@ -55,24 +59,34 @@ export function getDisplayFormatForLanguage(language: string) { } export function getDefaultOTelDisplayFormat() { - return ['scope_name', 'thread_name', 'exception_type', 'exception_message', LOG_LINE_BODY_FIELD_NAME]; + return [ + 'thread_name', + 'exception_type', + 'exception_message', + LOG_LINE_BODY_FIELD_NAME, + OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, + ]; } const OTEL_RESOURCE_ATTRS_REGEX = - /^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_)/; + /^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_|cluster$|namespace$|pod$)/; const OTEL_LOG_FIELDS_REGEX = - /^(flags|observed_timestamp|scope_name|severity_number|severity_text|span_id|trace_id|detected_level)$/; + /^(flags|observed_timestamp|severity_number|severity_text|span_id|trace_id|detected_level)$/; -export function getOtelFormattedBody(log: LogListModel) { - if (!log.otelLanguage) { - return log.raw; - } +export const OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME = '___OTEL_LOG_ATTRIBUTES___'; + +export function getOtelAttributesField(log: LogListModel, wrapLogMessage: boolean) { const additionalFields = Object.keys(log.labels).filter( - (label) => !OTEL_RESOURCE_ATTRS_REGEX.test(label) && !OTEL_LOG_FIELDS_REGEX.test(label) - ); - return ( - log.raw + - ' ' + - additionalFields.map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : '')).join(' ') + (label) => + !OTEL_RESOURCE_ATTRS_REGEX.test(label) && + !OTEL_LOG_FIELDS_REGEX.test(label) && + label !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME ); + const attributes = additionalFields + .map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : '')) + .join(' '); + if (!wrapLogMessage) { + return attributes.replace(NEWLINES_REGEX, ''); + } + return attributes; } diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx index 272bb0e2ade..99c0d5fcf96 100644 --- a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx @@ -34,7 +34,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); @@ -177,7 +177,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); @@ -201,7 +201,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx index 2016bc84cbc..4a59b1e6d81 100644 --- a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx @@ -1,18 +1,18 @@ import { Token } from 'prismjs'; +import { memo } from 'react'; -import { LogListModel } from './processing'; - -export const HighlightedLogRenderer = ({ log }: { log: LogListModel }) => { +export const HighlightedLogRenderer = memo(({ tokens }: { tokens: Array }) => { return ( <> - {log.highlightedBodyTokens.map((token, i) => ( + {tokens.map((token, i) => ( ))} ); -}; +}); +HighlightedLogRenderer.displayName = 'HighlightedLogRenderer'; -const LogToken = ({ token }: { token: Token | string }) => { +const LogToken = memo(({ token }: { token: Token | string }) => { if (typeof token === 'string') { return token; } @@ -30,4 +30,5 @@ const LogToken = ({ token }: { token: Token | string }) => { {typeof token.content === 'string' ? token.content : } ); -}; +}); +LogToken.displayName = 'LogToken'; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index b79bf06e2e0..6e4b1599398 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -2,9 +2,11 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoreApp, createTheme, getDefaultTimeRange, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; +import { getDisplayedFieldsForLogs, OTEL_PROBE_FIELD } from '../otel/formats'; import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine'; import { LogListFontSize } from './LogList'; @@ -270,6 +272,55 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument(); expect(screen.queryByText(log.entry)).not.toBeInTheDocument(); }); + + test('Highlights the OTel attributes field when rendered', () => { + const originalState = config.featureToggles.otelLogsFormatting; + config.featureToggles.otelLogsFormatting = true; + log = createLogLine({ + labels: { [OTEL_PROBE_FIELD]: '1', service: 'some service' }, + entry: `place="luna" 1ms 3 KB`, + }); + const displayedFields = getDisplayedFieldsForLogs([log]); + + render( + + + + ); + expect(screen.getByText('service=')).toBeInTheDocument(); + expect(screen.getByText('some service')).toBeInTheDocument(); + + expect(screen.getByText('place')).toBeInTheDocument(); + expect(screen.getByText('1ms')).toBeInTheDocument(); + expect(screen.getByText('3 KB')).toBeInTheDocument(); + expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument(); + + config.featureToggles.otelLogsFormatting = originalState; + }); + + test('OTel attributes field is not present when the flag is disabled', () => { + const originalState = config.featureToggles.otelLogsFormatting; + config.featureToggles.otelLogsFormatting = false; + log = createLogLine({ + labels: { [OTEL_PROBE_FIELD]: '1', service: 'some service' }, + entry: `place="luna" 1ms 3 KB`, + }); + + render( + + + + ); + expect(screen.queryByText('service')).not.toBeInTheDocument(); + expect(screen.queryByText('some service')).not.toBeInTheDocument(); + + expect(screen.getByText('place')).toBeInTheDocument(); + expect(screen.getByText('1ms')).toBeInTheDocument(); + expect(screen.getByText('3 KB')).toBeInTheDocument(); + expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument(); + + config.featureToggles.otelLogsFormatting = originalState; + }); }); describe('Collapsible log lines', () => { diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 62a19356407..d58a861439e 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -20,13 +20,14 @@ import { Button, Icon, Tooltip } from '@grafana/ui'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LogLabels } from '../LogLabels'; import { LogMessageAnsi } from '../LogMessageAnsi'; +import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats'; import { HighlightedLogRenderer } from './HighlightedLogRenderer'; import { InlineLogLineDetails } from './LogLineDetails'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext'; import { useLogListSearchContext } from './LogListSearchContext'; -import { LogListModel } from './processing'; +import { getNormalizedFieldName, LogListModel } from './processing'; import { FIELD_GAP_MULTIPLIER, getLogLineDOMHeight, @@ -374,6 +375,7 @@ const DisplayedFields = ({ styles: LogLineStyles; }) => { const { matchingUids, search } = useLogListSearchContext(); + const { syntaxHighlighting } = useLogListContext(); const searchWords = useMemo(() => { const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords.slice() : []; @@ -386,11 +388,19 @@ const DisplayedFields = ({ return searchWords; }, [log.searchWords, log.uid, matchingUids, search]); - return displayedFields.map((field) => - field === LOG_LINE_BODY_FIELD_NAME ? ( - - ) : ( - + return displayedFields.map((field) => { + if (field === LOG_LINE_BODY_FIELD_NAME) { + return ; + } + if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) { + return ( + + + + ); + } + return ( + {searchWords ? ( - ) - ); + ); + }); }; const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => { @@ -444,7 +454,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles return ( - + ); }; diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx index e5bce32bd86..24a0c67948b 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx @@ -6,11 +6,10 @@ import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Card, IconButton, useStyles2 } from '@grafana/ui'; -import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; - import { LogLineDetailsMode } from './LogLineDetails'; import { useLogListContext } from './LogListContext'; import { reportInteractionOnce } from './analytics'; +import { getNormalizedFieldName } from './processing'; export const LogLineDetailsDisplayedFields = () => { const { displayedFields, setDisplayedFields } = useLogListContext(); @@ -98,9 +97,7 @@ const DisplayedField = ({
-
- {field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field} -
+
{getNormalizedFieldName(field)}
{displayedFields.length > 1 && ( <>
{!disableActions && (
- {onClickFilterLabel && ( + {onClickFilterLabel && fieldSupportsFilters && ( )} - {onClickFilterOutLabel && ( + {onClickFilterOutLabel && fieldSupportsFilters && (
)} -
{singleKey ? keys[0] : }
+
+ {singleKey ? getNormalizedFieldName(keys[0]) : } +
{singleValue ? ( diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx index 1f8955c44cb..28ecad3d8a3 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx @@ -33,7 +33,9 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: <> {!syntaxHighlighting &&
{log.body}
} {syntaxHighlighting && ( -
{}
+
+ {} +
)} )} diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 572ff363097..7f292ab7216 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -13,7 +13,9 @@ import { import { config, reportInteraction } from '@grafana/runtime'; import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils'; +import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogRow } from '../mocks/logRow'; +import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats'; import { LogList, Props } from './LogList'; @@ -223,6 +225,67 @@ describe('LogList', () => { expect(screen.getByText('debug')).toBeInTheDocument(); }); + describe('OTel log lines', () => { + const originalState = config.featureToggles.otelLogsFormatting; + + test('Does not perform OTel-related actions when the flag is disabled', () => { + config.featureToggles.otelLogsFormatting = false; + const onLogOptionsChange = jest.fn(); + const setDisplayedFields = jest.fn(); + + render( + + ); + expect(screen.getByText('log message 1')).toBeInTheDocument(); + expect(onLogOptionsChange).not.toHaveBeenCalled(); + expect(setDisplayedFields).not.toHaveBeenCalled(); + + config.featureToggles.otelLogsFormatting = originalState; + }); + + test('Reports the default displayed fields for non-OTel logs', () => { + config.featureToggles.otelLogsFormatting = true; + const onLogOptionsChange = jest.fn(); + const setDisplayedFields = jest.fn(); + + render( + + ); + expect(screen.getByText('log message 1')).toBeInTheDocument(); + expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', []); + + // No fields to display, no call + expect(setDisplayedFields).not.toHaveBeenCalled(); + + config.featureToggles.otelLogsFormatting = originalState; + }); + + test('Reports the default OTel displayed fields', () => { + config.featureToggles.otelLogsFormatting = true; + const onLogOptionsChange = jest.fn(); + const setDisplayedFields = jest.fn(); + + const logs = [createLogRow({ uid: '1', labels: { [OTEL_PROBE_FIELD]: '1' } })]; + + render( + + ); + expect(screen.getByText('log message 1')).toBeInTheDocument(); + expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [ + LOG_LINE_BODY_FIELD_NAME, + OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, + ]); + expect(setDisplayedFields).toHaveBeenCalledWith([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]); + + config.featureToggles.otelLogsFormatting = originalState; + }); + }); + describe('Popover menu', () => { function setup(overrides: Partial = {}) { return render( diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1986a9d2e57..055539ed985 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -66,7 +66,7 @@ export interface Props { onClickFilterOutString?: (value: string, refId?: string) => void; onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; - onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void; + onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void; onLogLineHover?: (row?: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; onPinLine?: (row: LogRowModel) => void; @@ -78,6 +78,11 @@ export interface Props { prettifyJSON?: boolean; setDisplayedFields?: (displayedFields: string[]) => void; showControls: boolean; + /** + * Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata + * @alpha + */ + showLogAttributes?: boolean; showTime: boolean; showUniqueLabels?: boolean; sortOrder: LogsSortOrder; @@ -90,7 +95,7 @@ export interface Props { export type LogListFontSize = 'default' | 'small'; -export type LogListControlOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyLogMessage'; +export type LogListOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyLogMessage' | 'defaultDisplayedFields'; type LogListComponentProps = Omit< Props, @@ -148,6 +153,7 @@ export const LogList = ({ prettifyJSON = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.prettifyLogMessage`, true) : true, setDisplayedFields, showControls, + showLogAttributes, showTime, showUniqueLabels, sortOrder, @@ -193,6 +199,7 @@ export const LogList = ({ prettifyJSON={prettifyJSON} setDisplayedFields={setDisplayedFields} showControls={showControls} + showLogAttributes={showLogAttributes} showTime={showTime} showUniqueLabels={showUniqueLabels} sortOrder={sortOrder} diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index bac7d507652..9ec63e2765d 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -33,7 +33,7 @@ import { getDisplayedFieldsForLogs } from '../otel/formats'; import { LogLineTimestampResolution } from './LogLine'; import { LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; -import { LogListControlOptions, LogListFontSize } from './LogList'; +import { LogListOptions, LogListFontSize } from './LogList'; import { reportInteractionOnce } from './analytics'; import { LogListModel } from './processing'; import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization'; @@ -176,7 +176,7 @@ export interface Props { onClickFilterOutString?: (value: string, refId?: string) => void; onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; - onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void; + onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void; onLogLineHover?: (row?: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; onPinLine?: (row: LogRowModel) => void; @@ -188,6 +188,7 @@ export interface Props { prettifyJSON?: boolean; setDisplayedFields?: (displayedFields: string[]) => void; showControls: boolean; + showLogAttributes?: boolean; showUniqueLabels?: boolean; showTime: boolean; sortOrder: LogsSortOrder; @@ -234,6 +235,7 @@ export const LogListContextProvider = ({ prettifyJSON: prettifyJSONProp, setDisplayedFields, showControls, + showLogAttributes, showTime, showUniqueLabels, sortOrder, @@ -289,16 +291,28 @@ export const LogListContextProvider = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const otelDisplayedFields = useMemo(() => { + if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) { + return []; + } + return getDisplayedFieldsForLogs(logs); + }, [logs, setDisplayedFields, showLogAttributes]); + // OTel displayed fields useEffect(() => { - if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) { + if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) { + onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields); + } + }, [onLogOptionsChange, otelDisplayedFields, showLogAttributes]); + + useEffect(() => { + if (displayedFields.length > 0 || !setDisplayedFields) { return; } - const otelDisplayedFields = getDisplayedFieldsForLogs(logs); if (otelDisplayedFields.length) { setDisplayedFields(otelDisplayedFields); } - }, [displayedFields.length, logs, setDisplayedFields]); + }, [displayedFields.length, otelDisplayedFields, setDisplayedFields]); // Sync state useEffect(() => { @@ -404,6 +418,13 @@ export const LogListContextProvider = ({ })); }, [timestampResolution]); + // Sync showLogAttributes + useEffect(() => { + if (showLogAttributes === false && setDisplayedFields) { + setDisplayedFields([]); + } + }, [setDisplayedFields, showLogAttributes]); + const controlsExpandedFromStore = store.getBool( `${logOptionsStorageKey}.controlsExpanded`, getDefaultControlsExpandedMode(containerElement ?? null) diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 0b69cd15f7a..1e8cf516325 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -3,6 +3,7 @@ import { config } from '@grafana/runtime'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine, createLogRow } from '../mocks/logRow'; +import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats'; import { LogListFontSize } from './LogList'; import { LogListModel, preProcessLogs } from './processing'; @@ -237,6 +238,67 @@ describe('preProcessLogs', () => { expect(logListModel.body).toBeDefined(); // Triggers parsing expect(logListModel.isJSON).toBe(false); }); + + describe('OTel logs', () => { + const originalState = config.featureToggles.otelLogsFormatting; + + test('Does not create the OTel attribute field when not enabled', () => { + config.featureToggles.otelLogsFormatting = false; + + const logListModel = createLogLine( + { entry: 'the log' }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, // wrapped + prettifyJSON: true, + } + ); + expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toBeUndefined(); + expect(logListModel.highlightedLogAttributesTokens).toHaveLength(0); + + config.featureToggles.otelLogsFormatting = originalState; + }); + + test('Does not create the OTel attribute field when is not an OTel log', () => { + config.featureToggles.otelLogsFormatting = false; + + const logListModel = createLogLine( + { entry: 'the log', labels: {} }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, // wrapped + prettifyJSON: true, + } + ); + expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toBeUndefined(); + expect(logListModel.highlightedLogAttributesTokens).toHaveLength(0); + + config.featureToggles.otelLogsFormatting = originalState; + }); + + test('Generates and highlights an OTel log line attributes field', () => { + config.featureToggles.otelLogsFormatting = true; + + const logListModel = createLogLine( + { entry: 'the log', labels: { [OTEL_PROBE_FIELD]: '1', field: 'value' } }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, // wrapped + prettifyJSON: true, + } + ); + expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toEqual('field=value'); + expect(logListModel.highlightedLogAttributesTokens).toHaveLength(2); + + config.featureToggles.otelLogsFormatting = originalState; + }); + }); }); test('Orders logs', () => { @@ -440,43 +502,3 @@ describe('preProcessLogs', () => { }); }); }); - -describe('OTel logs', () => { - let originalOtelLogsFormatting = config.featureToggles.otelLogsFormatting; - afterAll(() => { - config.featureToggles.otelLogsFormatting = originalOtelLogsFormatting; - }); - - test('Requires a feature flag', () => { - const log = createLogLine({ - labels: { - severity_number: '1', - telemetry_sdk_language: 'php', - scope_name: 'scope', - aws_ignore: 'ignored', - key: 'value', - otel: 'otel', - }, - entry: `place="luna" 1ms 3 KB`, - }); - expect(log.otelLanguage).toBeDefined(); - expect(log.body).toEqual(`place="luna" 1ms 3 KB`); - }); - - test('Augments OTel log lines', () => { - config.featureToggles.otelLogsFormatting = true; - const log = createLogLine({ - labels: { - severity_number: '1', - telemetry_sdk_language: 'php', - scope_name: 'scope', - aws_ignore: 'ignored', - key: 'value', - otel: 'otel', - }, - entry: `place="luna" 1ms 3 KB`, - }); - expect(log.otelLanguage).toBeDefined(); - expect(log.body).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`); - }); -}); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 98a0cf6a00c..7ad570acad8 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -11,19 +11,20 @@ import { LogsSortOrder, systemDateFormats, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { FieldDef, getAllFields } from '../logParser'; -import { identifyOTelLanguage, getOtelFormattedBody } from '../otel/formats'; +import { identifyOTelLanguage, getOtelAttributesField, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats'; import { generateLogGrammar, generateTextMatchGrammar } from './grammar'; import { LogLineVirtualization } from './virtualization'; const TRUNCATION_DEFAULT_LENGTH = 50000; -const NEWLINES_REGEX = /(\r\n|\n|\r)/g; +export const NEWLINES_REGEX = /(\r\n|\n|\r)/g; export class LogListModel implements LogRowModel { collapsed: boolean | undefined = undefined; @@ -59,6 +60,7 @@ export class LogListModel implements LogRowModel { private _currentSearch: string | undefined = undefined; private _grammar?: Grammar; private _highlightedBody: string | undefined = undefined; + private _highlightedLogAttributesTokens: Array | undefined = undefined; private _highlightTokens: Array | undefined = undefined; private _fields: FieldDef[] | undefined = undefined; private _getFieldLinks: GetFieldLinksFn | undefined = undefined; @@ -114,6 +116,10 @@ export class LogListModel implements LogRowModel { raw = escapeUnescapedString(raw); } this.raw = raw; + + if (config.featureToggles.otelLogsFormatting && this.otelLanguage) { + this.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME] = getOtelAttributesField(this, wrapLogMessage); + } } clone() { @@ -137,7 +143,7 @@ export class LogListModel implements LogRowModel { this.raw = reStringified; } } catch (error) {} - const raw = config.featureToggles.otelLogsFormatting && this.otelLanguage ? getOtelFormattedBody(this) : this.raw; + const raw = this.raw; this._body = this.collapsed ? raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH) : raw; @@ -181,6 +187,19 @@ export class LogListModel implements LogRowModel { return this._highlightTokens; } + get highlightedLogAttributesTokens() { + if (this._highlightedLogAttributesTokens === undefined) { + const attributes = this.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME] ?? ''; + if (!attributes) { + return []; + } + this._grammar = this._grammar ?? generateLogGrammar(this); + const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch); + this._highlightedLogAttributesTokens = Prism.tokenize(attributes, { ...extraGrammar, ...this._grammar }); + } + return this._highlightedLogAttributesTokens; + } + get isJSON() { return this._json; } @@ -250,6 +269,7 @@ export class LogListModel implements LogRowModel { setCurrentSearch(search: string | undefined) { this._currentSearch = search; this._highlightTokens = undefined; + this._highlightedLogAttributesTokens = undefined; } } @@ -335,3 +355,12 @@ export function getLevelsFromLogs(logs: LogListModel[]) { } return Array.from(levels).filter((level) => level != null); } + +export function getNormalizedFieldName(field: string) { + if (field === LOG_LINE_BODY_FIELD_NAME) { + return t('logs.log-line-details.log-line-field', 'Log line'); + } else if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME) { + return t('logs.log-line-details.log-attributes-field', 'OTel attributes'); + } + return field; +} diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index e4f2ea1d762..1282eb93f05 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -405,6 +405,7 @@ describe('getPluginExtensions()', () => { expect.objectContaining({ context, openModal: expect.any(Function), + extensionPointId: extensionPoint2, }) ); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 5d5498ce0ac..5077da479d3 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -537,6 +537,7 @@ export function getLinkExtensionOnClick( const helpers: PluginExtensionEventHelpers = { context, + extensionPointId, openModal: createOpenModalFunction(config), openSidebar: (componentTitle, context) => { appEvents.publish( diff --git a/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx b/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx index 0c426d956ec..6e553ed760c 100644 --- a/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx @@ -195,11 +195,12 @@ export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismis const workflowOptions = getWorkflowOptions(repository); const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; const timestamp = generateTimestamp(); + const defaultWorkflow = getDefaultWorkflow(repository); const initialValues = { comment: '', - ref: `bulk-move/${timestamp}`, - workflow: getDefaultWorkflow(repository), + ref: defaultWorkflow === 'branch' ? `bulk-move/${timestamp}` : (repository?.branch ?? ''), + workflow: defaultWorkflow, }; if (!repository || isReadOnlyRepo) { diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index fe8fa6c018e..59aee0f80c1 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -592,13 +592,7 @@ function SavedQueryButtons(props: { datasourceFilters: string[]; }) { const { renderSavedQueryButtons } = useQueryLibraryContext(); - return renderSavedQueryButtons( - props.query, - props.app, - props.onUpdateSuccess, - props.onSelectQuery, - props.datasourceFilters - ); + return renderSavedQueryButtons(props.query, props.app, props.onUpdateSuccess, props.onSelectQuery); } // Will render editing header only if query library is enabled diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index 137fee41620..fe7d7f2b664 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -2,7 +2,7 @@ import { DataFrame, DataFrameView, FieldType, getDisplayProcessor, SelectableVal import { config } from '@grafana/runtime'; import { TermCount } from 'app/core/components/TagFilter/TagFilter'; import { backendSrv } from 'app/core/services/backend_srv'; -import { PermissionLevelString } from 'app/types/acl'; +import { PermissionLevel } from 'app/types/acl'; import { DEFAULT_MAX_VALUES, GENERAL_FOLDER_UID, TYPE_KIND_MAP } from '../constants'; import { DashboardSearchHit, DashboardSearchItemType } from '../types'; @@ -21,7 +21,7 @@ interface APIQuery { folderUIDs?: string[]; sort?: string; starred?: boolean; - permission?: PermissionLevelString; + permission?: PermissionLevel; deleted?: boolean; } diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index 6617bb284f9..a670b054979 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -1,6 +1,6 @@ import { DataFrameView, SelectableValue } from '@grafana/data'; import { TermCount } from 'app/core/components/TagFilter/TagFilter'; -import { PermissionLevelString } from 'app/types/acl'; +import { PermissionLevel } from 'app/types/acl'; import { ManagerKind } from '../../apiserver/types'; @@ -39,7 +39,7 @@ export interface SearchQuery { limit?: number; from?: number; starred?: boolean; - permission?: PermissionLevelString; + permission?: PermissionLevel; deleted?: boolean; offset?: number; } diff --git a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx index 6c064b2ad0c..89895799e6b 100644 --- a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.test.tsx @@ -67,3 +67,62 @@ describe('FilterByValueTransformerEditor', () => { }); }); }); +it('hides conditions field when there is 0 or 1 filter', () => { + const onChangeMock = jest.fn(); + const input: DataFrame[] = [ + { + fields: [{ name: 'field1', type: FieldType.string, config: {}, values: [] }], + length: 0, + }, + ]; + + // Test with 0 filters + const { queryByText, rerender } = render( + + ); + expect(queryByText('Conditions')).not.toBeInTheDocument(); + + // Test with 1 filter + rerender( + + ); + expect(queryByText('Conditions')).not.toBeInTheDocument(); +}); + +it('shows conditions field when there are more than 1 filter', () => { + const onChangeMock = jest.fn(); + const input: DataFrame[] = [ + { + fields: [{ name: 'field1', type: FieldType.string, config: {}, values: [] }], + length: 0, + }, + ]; + + const { getByText } = render( + + ); + expect(getByText('Conditions')).toBeInTheDocument(); +}); diff --git a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx index 366da659cf2..5f8c350f60f 100644 --- a/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx @@ -124,14 +124,16 @@ export const FilterByValueTransformerEditor = (props: TransformerUIProps
- -
- -
-
+ {options.filters.length > 1 && ( + +
+ +
+
+ )} {options.filters.map((filter, idx) => ( void; } -const GroupByTransformerEditor = ({ input, options, onChange }: TransformerUIProps) => { - const fieldNames = useAllFieldNamesFromDataFrames(input, true); +interface GroupByTransformerEditorProps extends TransformerUIProps { + fieldNames: string[]; +} +export const GroupByTransformerEditorBase = ({ options, onChange, fieldNames }: GroupByTransformerEditorProps) => { const onConfigChange = useCallback( (fieldName: string) => (config: GroupByFieldOptions) => { onChange({ @@ -84,16 +85,20 @@ const GroupByTransformerEditor = ({ input, options, onChange }: TransformerUIPro ); }; +const GroupByTransformerEditor = DataFieldsErrorWrapper(GroupByTransformerEditorBase, { + withBaseFieldNames: true, +}); + const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldProps) => { const theme = useTheme2(); const styles = getStyles(theme); const onChange = useCallback( - (value: SelectableValue) => { + (option: ComboboxOption | null) => { onConfigChange({ aggregations: config?.aggregations ?? [], - operation: value?.value ?? null, + operation: option?.value ?? null, }); }, [config, onConfigChange] @@ -114,7 +119,7 @@ const GroupByFieldConfiguration = ({ fieldName, config, onConfigChange }: FieldP
-