From c75a451b13327f36aafe4b81e649f619518be5d1 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:17:07 +0000 Subject: [PATCH 001/209] Alerting: Add counts for firing and pending alert rules (#113309) * add counts for firing and pending alert rules * resolve Pr comments part 1 * resolve PR comments part 2 * resolve PR comments part 3 --- .../unified/triage/scene/SummaryStats.test.ts | 227 ++++++++++++++++++ .../unified/triage/scene/SummaryStats.tsx | 226 ++++++++++++++--- public/locales/en-US/grafana.json | 2 - 3 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts b/public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts new file mode 100644 index 00000000000..b232733d430 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts @@ -0,0 +1,227 @@ +import { DataFrameView } from '@grafana/data'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { RuleFrame, countRules, parseAlertstateFilter } from './SummaryStats'; + +describe('parseAlertstateFilter', () => { + it('should return "firing" when filter contains alertstate="firing"', () => { + expect(parseAlertstateFilter('alertstate="firing"')).toBe(PromAlertingRuleState.Firing); + }); + + it('should return "pending" when filter contains alertstate="pending"', () => { + expect(parseAlertstateFilter('alertstate="pending"')).toBe(PromAlertingRuleState.Pending); + }); + + it('should return null when filter contains both firing and pending', () => { + expect(parseAlertstateFilter('alertstate=~"firing|pending"')).toBe(null); + }); + + it('should return null when no alertstate filter', () => { + expect(parseAlertstateFilter('')).toBe(null); + expect(parseAlertstateFilter('namespace="default"')).toBe(null); + }); + + it('should handle regex operator =~', () => { + expect(parseAlertstateFilter('alertstate=~"firing"')).toBe(PromAlertingRuleState.Firing); + expect(parseAlertstateFilter('alertstate=~"pending"')).toBe(PromAlertingRuleState.Pending); + }); + + it('should handle whitespace', () => { + expect(parseAlertstateFilter('alertstate = "firing"')).toBe(PromAlertingRuleState.Firing); + expect(parseAlertstateFilter('alertstate =~ "pending"')).toBe(PromAlertingRuleState.Pending); + }); +}); + +describe('countRules', () => { + // Helper to create mock DataFrameView + function createMockRuleDfv( + data: Array<{ ruleUID: string; alertstate: PromAlertingRuleState.Firing | PromAlertingRuleState.Pending }> + ): DataFrameView { + return { + length: data.length, + fields: { + grafana_rule_uid: { + values: data.map((d) => d.ruleUID), + }, + alertstate: { + values: data.map((d) => d.alertstate), + }, + }, + } as unknown as DataFrameView; + } + + describe('when no alertstate filter applied', () => { + it('should count rules with ANY firing instances', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, + ]); + + const result = countRules(ruleDfv, null); + + expect(result.firing).toBe(2); + expect(result.pending).toBe(1); + }); + + it('should count rules with ONLY pending instances (no firing)', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Firing }, + ]); + + const result = countRules(ruleDfv, null); + + expect(result.firing).toBe(1); + expect(result.pending).toBe(2); + }); + + it('should count rules with BOTH firing and pending as firing (firing takes precedence)', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Same rule, both states + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, // Only firing + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, // Only pending + ]); + + const result = countRules(ruleDfv, null); + + // rule1 should be counted as firing (firing takes precedence) + // rule2 should be counted as firing + // rule3 should be counted as pending + expect(result.firing).toBe(2); // rule1 and rule2 + expect(result.pending).toBe(1); // only rule3 + }); + + it('should handle multiple instances of the same rule with same state', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, // Same rule, duplicate entry + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, // Same rule, duplicate entry + ]); + + const result = countRules(ruleDfv, null); + + // Each rule should only be counted once despite multiple entries + expect(result.firing).toBe(1); + expect(result.pending).toBe(1); + }); + + it('should return 0 for both counts when no rules', () => { + const ruleDfv = createMockRuleDfv([]); + + const result = countRules(ruleDfv, null); + + expect(result.firing).toBe(0); + expect(result.pending).toBe(0); + }); + }); + + describe('when filtering by alertstate=pending', () => { + it('should count ALL rules with any pending instances', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Has both + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, // Only pending + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Firing }, // Only firing + ]); + + const result = countRules(ruleDfv, PromAlertingRuleState.Pending); + + // Should count rule1 and rule2 (both have pending instances) + expect(result.pending).toBe(2); + expect(result.firing).toBe(0); + }); + + it('should count rules with BOTH states as pending', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, + ]); + + const result = countRules(ruleDfv, PromAlertingRuleState.Pending); + + // Should count all three rules (all have pending instances) + expect(result.pending).toBe(3); + expect(result.firing).toBe(0); + }); + + it('should be >= pending count from no filter scenario', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + ]); + + const noFilterResult = countRules(ruleDfv, null); + const pendingFilterResult = countRules(ruleDfv, PromAlertingRuleState.Pending); + + // With pending filter: should count rule1 and rule2 = 2 + // Without filter: should count only rule2 = 1 (rule1 is counted as firing, not pending) + expect(pendingFilterResult.pending).toBeGreaterThanOrEqual(noFilterResult.pending); + expect(pendingFilterResult.pending).toBe(2); + expect(noFilterResult.pending).toBe(1); // Only rule2 (rule1 is firing) + expect(noFilterResult.firing).toBe(1); // rule1 + }); + }); + + describe('when filtering by alertstate=firing', () => { + it('should count ALL rules with any firing instances', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Has both + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, // Only firing + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, // Only pending + ]); + + const result = countRules(ruleDfv, PromAlertingRuleState.Firing); + + // Should count rule1 and rule2 (both have firing instances) + expect(result.firing).toBe(2); + expect(result.pending).toBe(0); + }); + }); + + describe('real-world scenarios', () => { + it('should handle complex scenario with many rules', () => { + const ruleDfv = createMockRuleDfv([ + // Rule A: Only firing (3 instances) + { ruleUID: 'ruleA', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleA', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleA', alertstate: PromAlertingRuleState.Firing }, + // Rule B: Only pending (2 instances) + { ruleUID: 'ruleB', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'ruleB', alertstate: PromAlertingRuleState.Pending }, + // Rule C: Both firing and pending (4 instances) + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Pending }, + // Rule D: Only firing (1 instance) + { ruleUID: 'ruleD', alertstate: PromAlertingRuleState.Firing }, + // Rule E: Only pending (1 instance) + { ruleUID: 'ruleE', alertstate: PromAlertingRuleState.Pending }, + ]); + + // No filter: Firing takes precedence + const noFilter = countRules(ruleDfv, null); + expect(noFilter.firing).toBe(3); // Rule A, C, and D (C has firing so it's counted as firing) + expect(noFilter.pending).toBe(2); // Rule B and E (only those with NO firing) + + // With pending filter: Count all rules with ANY pending instances + const pendingFilter = countRules(ruleDfv, PromAlertingRuleState.Pending); + expect(pendingFilter.pending).toBe(3); // Rule B, C, and E + expect(pendingFilter.firing).toBe(0); + + // With firing filter: Count all rules with ANY firing instances + const firingFilter = countRules(ruleDfv, PromAlertingRuleState.Firing); + expect(firingFilter.firing).toBe(3); // Rule A, C, and D + expect(firingFilter.pending).toBe(0); + }); + }); +}); diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx index 73957f85789..b26e9b9aa8f 100644 --- a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx @@ -2,63 +2,235 @@ 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 { ErrorBoundaryAlert, Stack, Text } from '@grafana/ui'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { Spacer } from '../../components/Spacer'; import { METRIC_NAME } from '../constants'; import { getDataQuery, useQueryFilter } from './utils'; +type AlertState = PromAlertingRuleState.Firing | PromAlertingRuleState.Pending; + interface Frame { - alertstate: 'firing' | 'pending'; + alertstate: AlertState; Value: number; } -export function SummaryStatsReact() { - const filter = useQueryFilter(); +export interface RuleFrame { + alertstate: AlertState; + alertname: string; + grafana_folder: string; + grafana_rule_uid: string; + Value: number; +} - const dataProvider = useQueryRunner({ +export function parseAlertstateFilter(filter: string): AlertState | null { + const firingMatch = filter.match(/alertstate\s*=~?\s*"firing"/); + const pendingMatch = filter.match(/alertstate\s*=~?\s*"pending"/); + + if (firingMatch && pendingMatch) { + return null; + } + + if (firingMatch) { + return PromAlertingRuleState.Firing; + } + + if (pendingMatch) { + return PromAlertingRuleState.Pending; + } + + return null; +} + +export function countRules(ruleDfv: DataFrameView, alertstateFilter: AlertState | null) { + const rulesWithFiring = new Set(); + const rulesWithPending = new Set(); + + ruleDfv.fields.grafana_rule_uid.values.forEach((ruleUID, i) => { + const alertstate = ruleDfv.fields.alertstate.values[i]; + if (alertstate === PromAlertingRuleState.Firing) { + rulesWithFiring.add(ruleUID); + } + if (alertstate === PromAlertingRuleState.Pending) { + rulesWithPending.add(ruleUID); + } + }); + + // When filtering by pending, count all rules with pending instances (may also have firing) + if (alertstateFilter === PromAlertingRuleState.Pending) { + return { + firing: 0, + pending: rulesWithPending.size, + }; + } + + // When filtering by firing, count all rules with firing instances (may also have pending) + if (alertstateFilter === PromAlertingRuleState.Firing) { + return { + firing: rulesWithFiring.size, + pending: 0, + }; + } + + // When no filter: firing takes precedence + // A rule is "firing" if it has ANY firing instances (even if it also has pending) + // A rule is "pending" ONLY if it has pending instances but NO firing instances + const onlyPending = new Set([...rulesWithPending].filter((uid) => !rulesWithFiring.has(uid))); + + return { + firing: rulesWithFiring.size, // ALL rules with firing instances + pending: onlyPending.size, // ONLY rules with no firing instances + }; +} + +function countInstances(instanceDfv: DataFrameView) { + const getValue = (state: AlertState) => { + const index = instanceDfv.fields.alertstate.values.findIndex((s) => s === state); + return instanceDfv.fields.Value.values[index] ?? 0; + }; + return { firing: getValue(PromAlertingRuleState.Firing), pending: getValue(PromAlertingRuleState.Pending) }; +} + +interface StatRowProps { + i18nKey: string; + color: 'error' | 'warning'; + values: Record; + children: React.ReactNode; +} + +function StatRow({ i18nKey, color, values, children }: StatRowProps) { + return ( + + + {children} + + + ); +} + +function SummaryStatsContent() { + const filter = useQueryFilter(); + const alertstateFilter = parseAlertstateFilter(filter); + + const instanceDataProvider = useQueryRunner({ + queries: [getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { instant: true, format: 'table' })], + }); + + // Always remove alertstate filter from rule query to get accurate counts across both states + // This ensures we can count rules that have instances in either state + const ruleFilter = filter + .replace(/alertstate\s*=~?\s*"(firing|pending)"[,\s]*/, '') + .replace(/,\s*$/, '') + .replace(/^\s*,/, ''); + const ruleDataProvider = useQueryRunner({ queries: [ - getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { - instant: true, - exemplar: false, - format: 'table', - }), + getDataQuery( + `count by (alertname, grafana_folder, grafana_rule_uid, alertstate) (${METRIC_NAME}{${ruleFilter}})`, + { + instant: true, + format: 'table', + } + ), ], }); - const isLoading = !dataProvider.isDataReadyToDisplay; - const data = dataProvider.useState().data; - const firstFrame = data?.series?.at(0); + const { data: instanceData } = instanceDataProvider.useState(); + const { data: ruleData } = ruleDataProvider.useState(); + const instanceFrame = instanceData?.series?.at(0); + const ruleFrame = ruleData?.series?.at(0); - if (isLoading || !firstFrame) { + if ( + !instanceDataProvider.isDataReadyToDisplay() || + !ruleDataProvider.isDataReadyToDisplay() || + !instanceFrame || + !ruleFrame + ) { return
; } - const dfv = new DataFrameView(firstFrame); - if (dfv.length === 0) { + const instanceDfv = new DataFrameView(instanceFrame); + const ruleDfv = new DataFrameView(ruleFrame); + + if (instanceDfv.length === 0 && ruleDfv.length === 0) { return
; } - 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; + const instances = countInstances(instanceDfv); + const rules = countRules(ruleDfv, alertstateFilter); return ( - - {{ firingCount }} firing instances - - - {{ pendingCount }} pending instances - + {alertstateFilter === PromAlertingRuleState.Firing && ( + <> + + {'{{count}} firing alert rules'} + + + {'{{firingCount}} firing instances'} + + + )} + {alertstateFilter === PromAlertingRuleState.Pending && ( + <> + + {'{{count}} rules with pending instances'} + + + {'{{pendingCount}} pending instances'} + + + )} + {!alertstateFilter && ( + <> + + {'{{count}} firing alert rules'} + + + {'{{firingCount}} firing instances'} + + + {'{{count}} pending alert rules'} + + + {'{{pendingCount}} pending instances'} + + + )} ); } +export function SummaryStatsReact() { + return ( + + + + ); +} + // simple wrapper so we can render the Chart using a Scene parent export class SummaryStatsScene extends SceneObjectBase { static Component = SummaryStatsReact; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index dc7eeab17c4..d95bf14de31 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2945,7 +2945,6 @@ "triage": { "alert-instances": "Alert instances", "error-loading-rule": "Error loading rule", - "firing-instances-count": "{{firingCount}} firing instances", "instance-details-drawer": { "instance-details": "Instance details" }, @@ -2953,7 +2952,6 @@ "no-labels": "No labels", "open-in-sidebar": "Open in sidebar", "open-rule-details": "Open rule details", - "pending-instances-count": "{{pendingCount}} pending instances", "rule-details": { "subtitle": "Rule details and conditions", "title": "Rule Details" From b4312a220f58ed268b0f6851e79ccfd44e9a1cb4 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Tue, 4 Nov 2025 11:20:10 +0100 Subject: [PATCH 002/209] Dashboard Controls: Adjust spacing for annotation controls (#113381) * fix: spacing issues with annotation control switches inside the dashboad controls * refactor: remove unnecessary css class --- .../dashboard-scene/scene/DashboardControlsMenu.tsx | 6 +++--- .../app/features/dashboard-scene/scene/DataLayerControl.tsx | 5 ++++- .../app/features/dashboard-scene/scene/VariableControls.tsx | 4 ---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx index d74717c9285..03ac5f0ea88 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.tsx @@ -88,7 +88,7 @@ function DashboardControlsMenu({ variables, links, annotationLayers, dashboardUI > {/* Variables */} {variables.map((variable, index) => ( -
0 })} key={variable.state.key}> +
0 })} key={variable.state.key}>
))} @@ -96,7 +96,7 @@ function DashboardControlsMenu({ variables, links, annotationLayers, dashboardUI {/* Annotation layers */} {annotationLayers.length > 0 && annotationLayers.map((layer, index) => ( -
0 && styles.variableItem)} key={layer.state.key}> +
0 || index > 0 })} key={layer.state.key}>
))} @@ -131,7 +131,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ marginTop: theme.spacing(2), padding: theme.spacing(0, 0.5), }), - variableItem: css({ + menuItem: css({ marginTop: theme.spacing(2), }), }); diff --git a/public/app/features/dashboard-scene/scene/DataLayerControl.tsx b/public/app/features/dashboard-scene/scene/DataLayerControl.tsx index 8bc3f2f5c5b..6318fa248a6 100644 --- a/public/app/features/dashboard-scene/scene/DataLayerControl.tsx +++ b/public/app/features/dashboard-scene/scene/DataLayerControl.tsx @@ -69,6 +69,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ '& > div': { border: 'none', background: 'transparent', + paddingRight: theme.spacing(0.5), + height: theme.spacing(2), '&:hover': { border: 'none', background: 'transparent', @@ -76,6 +78,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ }, }), menuLabel: css({ - marginTop: theme.spacing(0.5), + marginTop: 0, + marginBottom: 0, }), }); diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 470b20fea23..45c48610f1a 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -171,10 +171,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ marginTop: 0, marginBottom: 0, }), - labelWrapper: css({ - display: 'flex', - alignItems: 'center', - }), labelSelectable: css({ cursor: 'pointer', }), From 769787ea40bd1a863490321e1b8f55e165013225 Mon Sep 17 00:00:00 2001 From: Jo Date: Tue, 4 Nov 2025 11:54:38 +0100 Subject: [PATCH 003/209] IAM: Improve team binding hooks error handling and validation (#113393) small team hook fixes --- pkg/registry/apis/iam/team_binding_hooks.go | 21 +- .../apis/iam/team_binding_hooks_test.go | 217 ++++++++++++++++++ 2 files changed, 235 insertions(+), 3 deletions(-) diff --git a/pkg/registry/apis/iam/team_binding_hooks.go b/pkg/registry/apis/iam/team_binding_hooks.go index c43c3a91ab1..dcad58cae0a 100644 --- a/pkg/registry/apis/iam/team_binding_hooks.go +++ b/pkg/registry/apis/iam/team_binding_hooks.go @@ -86,6 +86,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime. "name", tb.Name, "subject", tb.Spec.Subject.Name, "teamRef", tb.Spec.TeamRef.Name, + "permission", tb.Spec.Permission, "err", err, ) status = "failure" @@ -117,6 +118,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime. "name", tb.Name, "subject", tb.Spec.Subject.Name, "teamRef", tb.Spec.TeamRef.Name, + "permission", tb.Spec.Permission, ) } else { // Record successful tuple write @@ -143,6 +145,20 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. return nil, nil } + if oldTB.Spec.Subject.Name == newTB.Spec.Subject.Name && oldTB.Spec.TeamRef.Name == newTB.Spec.TeamRef.Name && oldTB.Spec.Permission == newTB.Spec.Permission { + return nil, nil // No changes to the team binding + } + + if newTB.Spec.Subject.Name == "" || newTB.Spec.TeamRef.Name == "" { + b.logger.Error("invalid team binding", + "namespace", newTB.Namespace, + "name", newTB.Name, + "subject", newTB.Spec.Subject.Name, + "teamRef", newTB.Spec.TeamRef.Name, + ) + return nil, nil + } + // Convert old team binding to tuple for deletion var oldTuple *v1.TupleKey var oldErr error @@ -154,6 +170,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. "name", oldTB.Name, "err", oldErr, ) + return nil, nil } } @@ -168,18 +185,16 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. "name", newTB.Name, "err", newErr, ) + return nil, nil } } // Return a finish function that performs the zanzana write only on success return func(ctx context.Context, success bool) { if !success { - // Update failed, don't write to zanzana return } - // Grab a ticket to write to Zanzana - // This limits the amount of concurrent connections to Zanzana wait := time.Now() b.zTickets <- true hooksWaitHistogram.WithLabelValues("teambinding", "update").Observe(time.Since(wait).Seconds()) diff --git a/pkg/registry/apis/iam/team_binding_hooks_test.go b/pkg/registry/apis/iam/team_binding_hooks_test.go index 869060bcacb..2f56ba12c87 100644 --- a/pkg/registry/apis/iam/team_binding_hooks_test.go +++ b/pkg/registry/apis/iam/team_binding_hooks_test.go @@ -487,6 +487,223 @@ func TestBeginTeamBindingUpdate(t *testing.T) { require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when zClient is nil }) + + t.Run("should handle empty old binding subject name gracefully", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-6", + Namespace: "org-6", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "", // Empty name - conversion will be skipped + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-6", + Namespace: "org-6", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-6", req.Namespace) + + // Should not delete old binding (it was skipped due to empty name) + require.Nil(t, req.Deletes) + + // Should write new binding + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) // Should still return finish function + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should return nil finish func when bindings are identical", func(t *testing.T) { + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-7", + Namespace: "org-7", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-7", + Namespace: "org-7", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testNoWriteOnNoChange := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when bindings are identical") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when bindings are identical + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when bindings are identical") + }) + + t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) { + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "", // Empty name - should cause early return + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when new binding has empty subject name") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name") + }) + + t.Run("should return nil finish func when new binding has empty team ref name", func(t *testing.T) { + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-9", + Namespace: "org-9", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-9", + Namespace: "org-9", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "", // Empty name - should cause early return + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when new binding has empty team ref name") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when new binding has empty team ref name + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name") + }) } func TestAfterTeamBindingDelete(t *testing.T) { From 0c016e210ad39502253e02f35c65ff6721eb0736 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Tue, 4 Nov 2025 13:15:54 +0100 Subject: [PATCH 004/209] Linter: Rollback suppressed errors (#113396) chore(linter): rollback suppressed errors --- eslint-suppressions.json | 17 +++++++++++++++++ .../query/components/QueryEditorRow.tsx | 6 ------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 18367de0c3f..a3292e1a102 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3328,6 +3328,23 @@ "count": 2 } }, + "public/app/features/query/components/QueryEditorRow.tsx": { + "@grafana/no-aria-label-selectors": { + "count": 1 + }, + "@typescript-eslint/consistent-type-assertions": { + "count": 3 + }, + "no-restricted-syntax": { + "count": 1 + }, + "react-hooks/rules-of-hooks": { + "count": 1 + }, + "react-prefer-function-component/react-prefer-function-component": { + "count": 1 + } + }, "public/app/features/query/components/QueryEditorRowHeader.tsx": { "@grafana/no-aria-label-selectors": { "count": 1 diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 5612ce37e0b..c13c6c97c93 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -85,7 +85,6 @@ interface State { showingHelp: boolean; } -// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component export class QueryEditorRow extends PureComponent, State> { dataSourceSrv = getDataSourceSrv(); id = ''; @@ -136,7 +135,6 @@ export class QueryEditorRow extends PureComponent, queriedDataSourceIdentifier: interpolatedUID, }); @@ -378,7 +376,6 @@ export class QueryEditorRow extends PureComponent void, dataSource, key: index, @@ -497,7 +494,6 @@ export class QueryEditorRow extends PureComponent e.refId === query.refId); const rowClasses = classNames('query-editor-row', { 'query-editor-row--disabled': isHidden, - // eslint-disable-next-line no-restricted-syntax 'gf-form-disabled': isHidden, }); @@ -539,7 +535,6 @@ export class QueryEditorRow extends PureComponent {queryLibraryRef && ( ({ extensionPointId: PluginExtensionPoints.QueryEditorRowAdaptiveTelemetryV1, }); From 69e4b4667b0c066b6b266dc6e15d4df55cc6dac4 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 4 Nov 2025 13:28:33 +0000 Subject: [PATCH 005/209] Backend: Add logs and metric for when host is redirected (#112373) --- pkg/middleware/validate_host.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/middleware/validate_host.go b/pkg/middleware/validate_host.go index 3b75552cd58..eb077b8e06c 100644 --- a/pkg/middleware/validate_host.go +++ b/pkg/middleware/validate_host.go @@ -3,11 +3,22 @@ package middleware import ( "strings" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) +var ( + hostRedirectCounter = promauto.NewCounter(prometheus.CounterOpts{ + Name: "host_redirect_total", + Help: "Number of requests redirected due to host header mismatch", + Namespace: "grafana", + }) +) + func ValidateHostHeader(cfg *setting.Cfg) web.Handler { return func(c *contextmodel.ReqContext) { // ignore local render calls @@ -21,6 +32,8 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler { } if !strings.EqualFold(h, cfg.Domain) { + hostRedirectCounter.Inc() + c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain) c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301) return } From 8f8ed2bbecb47fa563d071b28b8a5173a4fc5eeb Mon Sep 17 00:00:00 2001 From: Serge Zaitsev Date: Tue, 4 Nov 2025 14:30:47 +0100 Subject: [PATCH 006/209] Chore: Change ownership for annotations (#112791) change ownership for annotations --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9cf3235c27f..be3eaaef522 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -151,7 +151,7 @@ /pkg/promlib @grafana/oss-big-tent /pkg/storage/ @grafana/grafana-search-and-storage /pkg/storage/secret/ @grafana/grafana-operator-experience-squad -/pkg/services/annotations/ @grafana/grafana-search-and-storage +/pkg/services/annotations/ @grafana/grafana-backend-services-squad /pkg/services/apikey/ @grafana/identity-squad /pkg/services/cleanup/ @grafana/grafana-backend-group /pkg/services/contexthandler/ @grafana/grafana-backend-group @grafana/grafana-app-platform-squad From 3fca7cf9527901d3b79c91c8e58321665e836985 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Tue, 4 Nov 2025 16:29:56 +0100 Subject: [PATCH 007/209] Zanzana: Refactor basic role write APIs (#113397) * Zanzana: Refactor basic role write APIs * Fix updates * fix linter --- pkg/services/authz/proto/v1/extention.pb.go | 712 ++++++++++-------- pkg/services/authz/proto/v1/extention.proto | 9 + .../authz/zanzana/common/translations.go | 14 + pkg/services/authz/zanzana/common/tuple.go | 18 + .../authz/zanzana/server/server_mutate.go | 2 +- .../zanzana/server/server_mutate_org_role.go | 63 +- .../server/server_mutate_org_role_test.go | 54 +- 7 files changed, 532 insertions(+), 340 deletions(-) diff --git a/pkg/services/authz/proto/v1/extention.pb.go b/pkg/services/authz/proto/v1/extention.pb.go index 48ffa5eb226..0cc4cb36a16 100644 --- a/pkg/services/authz/proto/v1/extention.pb.go +++ b/pkg/services/authz/proto/v1/extention.pb.go @@ -122,6 +122,7 @@ type MutateOperation struct { // *MutateOperation_DeletePermission // *MutateOperation_UpdateUserOrgRole // *MutateOperation_DeleteUserOrgRole + // *MutateOperation_AddUserOrgRole Operation isMutateOperation_Operation `protobuf_oneof:"operation"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -218,6 +219,15 @@ func (x *MutateOperation) GetDeleteUserOrgRole() *DeleteUserOrgRoleOperation { return nil } +func (x *MutateOperation) GetAddUserOrgRole() *AddUserOrgRoleOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_AddUserOrgRole); ok { + return x.AddUserOrgRole + } + } + return nil +} + type isMutateOperation_Operation interface { isMutateOperation_Operation() } @@ -246,6 +256,10 @@ type MutateOperation_DeleteUserOrgRole struct { DeleteUserOrgRole *DeleteUserOrgRoleOperation `protobuf:"bytes,6,opt,name=delete_user_org_role,json=deleteUserOrgRole,proto3,oneof"` } +type MutateOperation_AddUserOrgRole struct { + AddUserOrgRole *AddUserOrgRoleOperation `protobuf:"bytes,7,opt,name=add_user_org_role,json=addUserOrgRole,proto3,oneof"` +} + func (*MutateOperation_SetFolderParent) isMutateOperation_Operation() {} func (*MutateOperation_DeleteFolder) isMutateOperation_Operation() {} @@ -258,6 +272,8 @@ func (*MutateOperation_UpdateUserOrgRole) isMutateOperation_Operation() {} func (*MutateOperation_DeleteUserOrgRole) isMutateOperation_Operation() {} +func (*MutateOperation_AddUserOrgRole) isMutateOperation_Operation() {} + type SetFolderParentOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // UID of the folder @@ -488,6 +504,61 @@ func (x *DeletePermissionOperation) GetPermission() *Permission { return nil } +type AddUserOrgRoleOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // User UID + User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + // Role name (e.g: "Admin", "Editor", "Viewer") + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddUserOrgRoleOperation) Reset() { + *x = AddUserOrgRoleOperation{} + mi := &file_extention_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddUserOrgRoleOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddUserOrgRoleOperation) ProtoMessage() {} + +func (x *AddUserOrgRoleOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddUserOrgRoleOperation.ProtoReflect.Descriptor instead. +func (*AddUserOrgRoleOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{7} +} + +func (x *AddUserOrgRoleOperation) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *AddUserOrgRoleOperation) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +// UpdateUserOrgRoleOperation assigns the user's basic role and deletes existing basic role assignments. type UpdateUserOrgRoleOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // User UID @@ -500,7 +571,7 @@ type UpdateUserOrgRoleOperation struct { func (x *UpdateUserOrgRoleOperation) Reset() { *x = UpdateUserOrgRoleOperation{} - mi := &file_extention_proto_msgTypes[7] + mi := &file_extention_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -512,7 +583,7 @@ func (x *UpdateUserOrgRoleOperation) String() string { func (*UpdateUserOrgRoleOperation) ProtoMessage() {} func (x *UpdateUserOrgRoleOperation) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[7] + mi := &file_extention_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -525,7 +596,7 @@ func (x *UpdateUserOrgRoleOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateUserOrgRoleOperation.ProtoReflect.Descriptor instead. func (*UpdateUserOrgRoleOperation) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{7} + return file_extention_proto_rawDescGZIP(), []int{8} } func (x *UpdateUserOrgRoleOperation) GetUser() string { @@ -554,7 +625,7 @@ type DeleteUserOrgRoleOperation struct { func (x *DeleteUserOrgRoleOperation) Reset() { *x = DeleteUserOrgRoleOperation{} - mi := &file_extention_proto_msgTypes[8] + mi := &file_extention_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -566,7 +637,7 @@ func (x *DeleteUserOrgRoleOperation) String() string { func (*DeleteUserOrgRoleOperation) ProtoMessage() {} func (x *DeleteUserOrgRoleOperation) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[8] + mi := &file_extention_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -579,7 +650,7 @@ func (x *DeleteUserOrgRoleOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteUserOrgRoleOperation.ProtoReflect.Descriptor instead. func (*DeleteUserOrgRoleOperation) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{8} + return file_extention_proto_rawDescGZIP(), []int{9} } func (x *DeleteUserOrgRoleOperation) GetUser() string { @@ -610,7 +681,7 @@ type Resource struct { func (x *Resource) Reset() { *x = Resource{} - mi := &file_extention_proto_msgTypes[9] + mi := &file_extention_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -622,7 +693,7 @@ func (x *Resource) String() string { func (*Resource) ProtoMessage() {} func (x *Resource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[9] + mi := &file_extention_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -635,7 +706,7 @@ func (x *Resource) ProtoReflect() protoreflect.Message { // Deprecated: Use Resource.ProtoReflect.Descriptor instead. func (*Resource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{9} + return file_extention_proto_rawDescGZIP(), []int{10} } func (x *Resource) GetGroup() string { @@ -673,7 +744,7 @@ type Permission struct { func (x *Permission) Reset() { *x = Permission{} - mi := &file_extention_proto_msgTypes[10] + mi := &file_extention_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -685,7 +756,7 @@ func (x *Permission) String() string { func (*Permission) ProtoMessage() {} func (x *Permission) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[10] + mi := &file_extention_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -698,7 +769,7 @@ func (x *Permission) ProtoReflect() protoreflect.Message { // Deprecated: Use Permission.ProtoReflect.Descriptor instead. func (*Permission) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{10} + return file_extention_proto_rawDescGZIP(), []int{11} } func (x *Permission) GetKind() string { @@ -734,7 +805,7 @@ type TupleKey struct { func (x *TupleKey) Reset() { *x = TupleKey{} - mi := &file_extention_proto_msgTypes[11] + mi := &file_extention_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -746,7 +817,7 @@ func (x *TupleKey) String() string { func (*TupleKey) ProtoMessage() {} func (x *TupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[11] + mi := &file_extention_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -759,7 +830,7 @@ func (x *TupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKey.ProtoReflect.Descriptor instead. func (*TupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{11} + return file_extention_proto_rawDescGZIP(), []int{12} } func (x *TupleKey) GetUser() string { @@ -800,7 +871,7 @@ type Tuple struct { func (x *Tuple) Reset() { *x = Tuple{} - mi := &file_extention_proto_msgTypes[12] + mi := &file_extention_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -812,7 +883,7 @@ func (x *Tuple) String() string { func (*Tuple) ProtoMessage() {} func (x *Tuple) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[12] + mi := &file_extention_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -825,7 +896,7 @@ func (x *Tuple) ProtoReflect() protoreflect.Message { // Deprecated: Use Tuple.ProtoReflect.Descriptor instead. func (*Tuple) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{12} + return file_extention_proto_rawDescGZIP(), []int{13} } func (x *Tuple) GetKey() *TupleKey { @@ -853,7 +924,7 @@ type TupleKeyWithoutCondition struct { func (x *TupleKeyWithoutCondition) Reset() { *x = TupleKeyWithoutCondition{} - mi := &file_extention_proto_msgTypes[13] + mi := &file_extention_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -865,7 +936,7 @@ func (x *TupleKeyWithoutCondition) String() string { func (*TupleKeyWithoutCondition) ProtoMessage() {} func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[13] + mi := &file_extention_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -878,7 +949,7 @@ func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKeyWithoutCondition.ProtoReflect.Descriptor instead. func (*TupleKeyWithoutCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{13} + return file_extention_proto_rawDescGZIP(), []int{14} } func (x *TupleKeyWithoutCondition) GetUser() string { @@ -912,7 +983,7 @@ type RelationshipCondition struct { func (x *RelationshipCondition) Reset() { *x = RelationshipCondition{} - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -924,7 +995,7 @@ func (x *RelationshipCondition) String() string { func (*RelationshipCondition) ProtoMessage() {} func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -937,7 +1008,7 @@ func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationshipCondition.ProtoReflect.Descriptor instead. func (*RelationshipCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{14} + return file_extention_proto_rawDescGZIP(), []int{15} } func (x *RelationshipCondition) GetName() string { @@ -966,7 +1037,7 @@ type ReadRequest struct { func (x *ReadRequest) Reset() { *x = ReadRequest{} - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -978,7 +1049,7 @@ func (x *ReadRequest) String() string { func (*ReadRequest) ProtoMessage() {} func (x *ReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -991,7 +1062,7 @@ func (x *ReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. func (*ReadRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{15} + return file_extention_proto_rawDescGZIP(), []int{16} } func (x *ReadRequest) GetNamespace() string { @@ -1033,7 +1104,7 @@ type ReadRequestTupleKey struct { func (x *ReadRequestTupleKey) Reset() { *x = ReadRequestTupleKey{} - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1045,7 +1116,7 @@ func (x *ReadRequestTupleKey) String() string { func (*ReadRequestTupleKey) ProtoMessage() {} func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1058,7 +1129,7 @@ func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequestTupleKey.ProtoReflect.Descriptor instead. func (*ReadRequestTupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{16} + return file_extention_proto_rawDescGZIP(), []int{17} } func (x *ReadRequestTupleKey) GetUser() string { @@ -1092,7 +1163,7 @@ type ReadResponse struct { func (x *ReadResponse) Reset() { *x = ReadResponse{} - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1104,7 +1175,7 @@ func (x *ReadResponse) String() string { func (*ReadResponse) ProtoMessage() {} func (x *ReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1117,7 +1188,7 @@ func (x *ReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. func (*ReadResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{17} + return file_extention_proto_rawDescGZIP(), []int{18} } func (x *ReadResponse) GetTuples() []*Tuple { @@ -1143,7 +1214,7 @@ type WriteRequestWrites struct { func (x *WriteRequestWrites) Reset() { *x = WriteRequestWrites{} - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1155,7 +1226,7 @@ func (x *WriteRequestWrites) String() string { func (*WriteRequestWrites) ProtoMessage() {} func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1168,7 +1239,7 @@ func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestWrites.ProtoReflect.Descriptor instead. func (*WriteRequestWrites) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{18} + return file_extention_proto_rawDescGZIP(), []int{19} } func (x *WriteRequestWrites) GetTupleKeys() []*TupleKey { @@ -1187,7 +1258,7 @@ type WriteRequestDeletes struct { func (x *WriteRequestDeletes) Reset() { *x = WriteRequestDeletes{} - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1199,7 +1270,7 @@ func (x *WriteRequestDeletes) String() string { func (*WriteRequestDeletes) ProtoMessage() {} func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1212,7 +1283,7 @@ func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestDeletes.ProtoReflect.Descriptor instead. func (*WriteRequestDeletes) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{19} + return file_extention_proto_rawDescGZIP(), []int{20} } func (x *WriteRequestDeletes) GetTupleKeys() []*TupleKeyWithoutCondition { @@ -1233,7 +1304,7 @@ type WriteRequest struct { func (x *WriteRequest) Reset() { *x = WriteRequest{} - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1245,7 +1316,7 @@ func (x *WriteRequest) String() string { func (*WriteRequest) ProtoMessage() {} func (x *WriteRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1258,7 +1329,7 @@ func (x *WriteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequest.ProtoReflect.Descriptor instead. func (*WriteRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{20} + return file_extention_proto_rawDescGZIP(), []int{21} } func (x *WriteRequest) GetNamespace() string { @@ -1290,7 +1361,7 @@ type WriteResponse struct { func (x *WriteResponse) Reset() { *x = WriteResponse{} - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1302,7 +1373,7 @@ func (x *WriteResponse) String() string { func (*WriteResponse) ProtoMessage() {} func (x *WriteResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1315,7 +1386,7 @@ func (x *WriteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteResponse.ProtoReflect.Descriptor instead. func (*WriteResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{21} + return file_extention_proto_rawDescGZIP(), []int{22} } type BatchCheckRequest struct { @@ -1329,7 +1400,7 @@ type BatchCheckRequest struct { func (x *BatchCheckRequest) Reset() { *x = BatchCheckRequest{} - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1341,7 +1412,7 @@ func (x *BatchCheckRequest) String() string { func (*BatchCheckRequest) ProtoMessage() {} func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1354,7 +1425,7 @@ func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckRequest.ProtoReflect.Descriptor instead. func (*BatchCheckRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{22} + return file_extention_proto_rawDescGZIP(), []int{23} } func (x *BatchCheckRequest) GetSubject() string { @@ -1392,7 +1463,7 @@ type BatchCheckItem struct { func (x *BatchCheckItem) Reset() { *x = BatchCheckItem{} - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1404,7 +1475,7 @@ func (x *BatchCheckItem) String() string { func (*BatchCheckItem) ProtoMessage() {} func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1417,7 +1488,7 @@ func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckItem.ProtoReflect.Descriptor instead. func (*BatchCheckItem) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{23} + return file_extention_proto_rawDescGZIP(), []int{24} } func (x *BatchCheckItem) GetVerb() string { @@ -1471,7 +1542,7 @@ type BatchCheckResponse struct { func (x *BatchCheckResponse) Reset() { *x = BatchCheckResponse{} - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1483,7 +1554,7 @@ func (x *BatchCheckResponse) String() string { func (*BatchCheckResponse) ProtoMessage() {} func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1496,7 +1567,7 @@ func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckResponse.ProtoReflect.Descriptor instead. func (*BatchCheckResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{24} + return file_extention_proto_rawDescGZIP(), []int{25} } func (x *BatchCheckResponse) GetGroups() map[string]*BatchCheckGroupResource { @@ -1515,7 +1586,7 @@ type BatchCheckGroupResource struct { func (x *BatchCheckGroupResource) Reset() { *x = BatchCheckGroupResource{} - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1527,7 +1598,7 @@ func (x *BatchCheckGroupResource) String() string { func (*BatchCheckGroupResource) ProtoMessage() {} func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1540,7 +1611,7 @@ func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckGroupResource.ProtoReflect.Descriptor instead. func (*BatchCheckGroupResource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{25} + return file_extention_proto_rawDescGZIP(), []int{26} } func (x *BatchCheckGroupResource) GetItems() map[string]bool { @@ -1569,7 +1640,7 @@ var file_extention_proto_rawDesc = string([]byte{ 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x10, 0x0a, 0x0e, 0x4d, 0x75, 0x74, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xce, 0x04, 0x0a, 0x0f, 0x4d, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa8, 0x05, 0x0a, 0x0f, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x5a, 0x0a, 0x11, 0x73, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, @@ -1605,208 +1676,218 @@ var file_extention_proto_rawDesc = string([]byte{ 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x64, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x0b, - 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, 0x18, 0x53, - 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x4f, 0x70, - 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, - 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, - 0x22, 0x70, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, - 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, - 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, - 0x6e, 0x67, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x65, 0x72, - 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x70, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, - 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, - 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, - 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, - 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0x44, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, - 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, - 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, - 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x50, - 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x22, 0x48, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, - 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, - 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 0x22, 0x9b, 0x01, 0x0a, 0x08, 0x54, - 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, - 0x47, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x63, - 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x05, 0x54, 0x75, 0x70, 0x6c, - 0x65, 0x12, 0x2e, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, - 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x62, 0x0a, 0x18, 0x54, - 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, - 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, - 0x5e, 0x0a, 0x15, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, - 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x31, 0x0a, 0x07, - 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, - 0xda, 0x01, 0x0a, 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x44, 0x0a, - 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x74, 0x75, 0x70, 0x6c, 0x65, - 0x4b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2d, 0x0a, - 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, - 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x5d, 0x0a, 0x13, - 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, - 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x70, 0x0a, 0x0c, 0x52, - 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x74, - 0x75, 0x70, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, + 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x58, + 0x0a, 0x11, 0x61, 0x64, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x6f, 0x72, 0x67, 0x5f, 0x72, + 0x6f, 0x6c, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x41, + 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x0e, 0x61, 0x64, 0x64, 0x55, 0x73, 0x65, + 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, 0x18, 0x53, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, + 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, + 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, + 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x22, 0x70, 0x0a, 0x15, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, + 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, + 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, + 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x22, 0x95, 0x01, 0x0a, + 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, + 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x41, 0x0a, 0x17, + 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, + 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, + 0x44, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, + 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, + 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, + 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x50, 0x0a, 0x08, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x48, 0x0a, + 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 0x22, 0x9b, 0x01, 0x0a, 0x08, 0x54, 0x75, 0x70, 0x6c, + 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x47, 0x0a, 0x09, + 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, + 0x70, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x05, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x12, 0x2e, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, - 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x52, 0x06, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x2d, - 0x0a, 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, - 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51, 0x0a, - 0x12, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, - 0x74, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, - 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, - 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, - 0x22, 0x62, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, - 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, - 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, - 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, - 0x4b, 0x65, 0x79, 0x73, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, - 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, 0x52, 0x06, 0x77, 0x72, 0x69, - 0x74, 0x65, 0x73, 0x12, 0x41, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x85, 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, - 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x38, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, - 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, - 0xa4, 0x01, 0x0a, 0x0e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, - 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, - 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, - 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0xc8, 0x01, 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, - 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, + 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, + 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x62, 0x0a, 0x18, 0x54, 0x75, 0x70, 0x6c, + 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x5e, 0x0a, 0x15, + 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x31, 0x0a, 0x07, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, + 0x75, 0x63, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0xda, 0x01, 0x0a, + 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, + 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x74, 0x75, + 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, + 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x12, 0x38, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, + 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x5d, 0x0a, 0x13, 0x52, 0x65, 0x61, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x70, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x74, 0x75, 0x70, 0x6c, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, + 0x70, 0x6c, 0x65, 0x52, 0x06, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, + 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51, 0x0a, 0x12, 0x57, 0x72, + 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, + 0x12, 0x3b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, + 0x65, 0x79, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, 0x62, 0x0a, + 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, + 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, + 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, + 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x73, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x12, 0x3e, 0x0a, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, 0x52, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, + 0x12, 0x41, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x85, 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x12, 0x38, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xa4, 0x01, 0x0a, + 0x0e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x12, + 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, + 0x65, 0x72, 0x62, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x75, 0x62, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, + 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x22, 0xc8, 0x01, 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x06, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, + 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa1, + 0x01, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x69, 0x74, + 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0xa1, 0x01, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, - 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, - 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, - 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, - 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, 0xde, 0x02, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, - 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x5b, 0x0a, 0x0a, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x25, 0x2e, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x32, 0xde, 0x02, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, + 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, - 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, - 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, - 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, - 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x12, + 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, - 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, - 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x73, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, + 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -1821,7 +1902,7 @@ func file_extention_proto_rawDescGZIP() []byte { return file_extention_proto_rawDescData } -var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_extention_proto_goTypes = []any{ (*MutateRequest)(nil), // 0: authz.extention.v1.MutateRequest (*MutateResponse)(nil), // 1: authz.extention.v1.MutateResponse @@ -1830,30 +1911,31 @@ var file_extention_proto_goTypes = []any{ (*DeleteFolderOperation)(nil), // 4: authz.extention.v1.DeleteFolderOperation (*CreatePermissionOperation)(nil), // 5: authz.extention.v1.CreatePermissionOperation (*DeletePermissionOperation)(nil), // 6: authz.extention.v1.DeletePermissionOperation - (*UpdateUserOrgRoleOperation)(nil), // 7: authz.extention.v1.UpdateUserOrgRoleOperation - (*DeleteUserOrgRoleOperation)(nil), // 8: authz.extention.v1.DeleteUserOrgRoleOperation - (*Resource)(nil), // 9: authz.extention.v1.Resource - (*Permission)(nil), // 10: authz.extention.v1.Permission - (*TupleKey)(nil), // 11: authz.extention.v1.TupleKey - (*Tuple)(nil), // 12: authz.extention.v1.Tuple - (*TupleKeyWithoutCondition)(nil), // 13: authz.extention.v1.TupleKeyWithoutCondition - (*RelationshipCondition)(nil), // 14: authz.extention.v1.RelationshipCondition - (*ReadRequest)(nil), // 15: authz.extention.v1.ReadRequest - (*ReadRequestTupleKey)(nil), // 16: authz.extention.v1.ReadRequestTupleKey - (*ReadResponse)(nil), // 17: authz.extention.v1.ReadResponse - (*WriteRequestWrites)(nil), // 18: authz.extention.v1.WriteRequestWrites - (*WriteRequestDeletes)(nil), // 19: authz.extention.v1.WriteRequestDeletes - (*WriteRequest)(nil), // 20: authz.extention.v1.WriteRequest - (*WriteResponse)(nil), // 21: authz.extention.v1.WriteResponse - (*BatchCheckRequest)(nil), // 22: authz.extention.v1.BatchCheckRequest - (*BatchCheckItem)(nil), // 23: authz.extention.v1.BatchCheckItem - (*BatchCheckResponse)(nil), // 24: authz.extention.v1.BatchCheckResponse - (*BatchCheckGroupResource)(nil), // 25: authz.extention.v1.BatchCheckGroupResource - nil, // 26: authz.extention.v1.BatchCheckResponse.GroupsEntry - nil, // 27: authz.extention.v1.BatchCheckGroupResource.ItemsEntry - (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 29: google.protobuf.Struct - (*wrapperspb.Int32Value)(nil), // 30: google.protobuf.Int32Value + (*AddUserOrgRoleOperation)(nil), // 7: authz.extention.v1.AddUserOrgRoleOperation + (*UpdateUserOrgRoleOperation)(nil), // 8: authz.extention.v1.UpdateUserOrgRoleOperation + (*DeleteUserOrgRoleOperation)(nil), // 9: authz.extention.v1.DeleteUserOrgRoleOperation + (*Resource)(nil), // 10: authz.extention.v1.Resource + (*Permission)(nil), // 11: authz.extention.v1.Permission + (*TupleKey)(nil), // 12: authz.extention.v1.TupleKey + (*Tuple)(nil), // 13: authz.extention.v1.Tuple + (*TupleKeyWithoutCondition)(nil), // 14: authz.extention.v1.TupleKeyWithoutCondition + (*RelationshipCondition)(nil), // 15: authz.extention.v1.RelationshipCondition + (*ReadRequest)(nil), // 16: authz.extention.v1.ReadRequest + (*ReadRequestTupleKey)(nil), // 17: authz.extention.v1.ReadRequestTupleKey + (*ReadResponse)(nil), // 18: authz.extention.v1.ReadResponse + (*WriteRequestWrites)(nil), // 19: authz.extention.v1.WriteRequestWrites + (*WriteRequestDeletes)(nil), // 20: authz.extention.v1.WriteRequestDeletes + (*WriteRequest)(nil), // 21: authz.extention.v1.WriteRequest + (*WriteResponse)(nil), // 22: authz.extention.v1.WriteResponse + (*BatchCheckRequest)(nil), // 23: authz.extention.v1.BatchCheckRequest + (*BatchCheckItem)(nil), // 24: authz.extention.v1.BatchCheckItem + (*BatchCheckResponse)(nil), // 25: authz.extention.v1.BatchCheckResponse + (*BatchCheckGroupResource)(nil), // 26: authz.extention.v1.BatchCheckGroupResource + nil, // 27: authz.extention.v1.BatchCheckResponse.GroupsEntry + nil, // 28: authz.extention.v1.BatchCheckGroupResource.ItemsEntry + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 30: google.protobuf.Struct + (*wrapperspb.Int32Value)(nil), // 31: google.protobuf.Int32Value } var file_extention_proto_depIdxs = []int32{ 2, // 0: authz.extention.v1.MutateRequest.operations:type_name -> authz.extention.v1.MutateOperation @@ -1861,40 +1943,41 @@ var file_extention_proto_depIdxs = []int32{ 4, // 2: authz.extention.v1.MutateOperation.delete_folder:type_name -> authz.extention.v1.DeleteFolderOperation 5, // 3: authz.extention.v1.MutateOperation.create_permission:type_name -> authz.extention.v1.CreatePermissionOperation 6, // 4: authz.extention.v1.MutateOperation.delete_permission:type_name -> authz.extention.v1.DeletePermissionOperation - 7, // 5: authz.extention.v1.MutateOperation.update_user_org_role:type_name -> authz.extention.v1.UpdateUserOrgRoleOperation - 8, // 6: authz.extention.v1.MutateOperation.delete_user_org_role:type_name -> authz.extention.v1.DeleteUserOrgRoleOperation - 9, // 7: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 10, // 8: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 9, // 9: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 10, // 10: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 14, // 11: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition - 11, // 12: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey - 28, // 13: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp - 29, // 14: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct - 16, // 15: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey - 30, // 16: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value - 12, // 17: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple - 11, // 18: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey - 13, // 19: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition - 18, // 20: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites - 19, // 21: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes - 23, // 22: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem - 26, // 23: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry - 27, // 24: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry - 25, // 25: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource - 22, // 26: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest - 15, // 27: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest - 20, // 28: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest - 0, // 29: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest - 24, // 30: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse - 17, // 31: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse - 21, // 32: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse - 1, // 33: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse - 30, // [30:34] is the sub-list for method output_type - 26, // [26:30] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 8, // 5: authz.extention.v1.MutateOperation.update_user_org_role:type_name -> authz.extention.v1.UpdateUserOrgRoleOperation + 9, // 6: authz.extention.v1.MutateOperation.delete_user_org_role:type_name -> authz.extention.v1.DeleteUserOrgRoleOperation + 7, // 7: authz.extention.v1.MutateOperation.add_user_org_role:type_name -> authz.extention.v1.AddUserOrgRoleOperation + 10, // 8: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 11, // 9: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 10, // 10: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 11, // 11: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 15, // 12: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition + 12, // 13: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey + 29, // 14: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp + 30, // 15: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct + 17, // 16: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey + 31, // 17: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value + 13, // 18: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple + 12, // 19: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey + 14, // 20: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition + 19, // 21: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites + 20, // 22: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes + 24, // 23: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem + 27, // 24: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry + 28, // 25: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry + 26, // 26: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource + 23, // 27: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest + 16, // 28: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest + 21, // 29: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest + 0, // 30: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest + 25, // 31: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse + 18, // 32: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse + 22, // 33: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse + 1, // 34: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse + 31, // [31:35] is the sub-list for method output_type + 27, // [27:31] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_extention_proto_init() } @@ -1909,6 +1992,7 @@ func file_extention_proto_init() { (*MutateOperation_DeletePermission)(nil), (*MutateOperation_UpdateUserOrgRole)(nil), (*MutateOperation_DeleteUserOrgRole)(nil), + (*MutateOperation_AddUserOrgRole)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -1916,7 +2000,7 @@ func file_extention_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extention_proto_rawDesc), len(file_extention_proto_rawDesc)), NumEnums: 0, - NumMessages: 28, + NumMessages: 29, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/authz/proto/v1/extention.proto b/pkg/services/authz/proto/v1/extention.proto index afc76b92e7f..37a05a5d75c 100644 --- a/pkg/services/authz/proto/v1/extention.proto +++ b/pkg/services/authz/proto/v1/extention.proto @@ -32,6 +32,7 @@ message MutateOperation { DeletePermissionOperation delete_permission = 4; UpdateUserOrgRoleOperation update_user_org_role = 5; DeleteUserOrgRoleOperation delete_user_org_role = 6; + AddUserOrgRoleOperation add_user_org_role = 7; } } @@ -63,6 +64,14 @@ message DeletePermissionOperation { Permission permission = 2; } +message AddUserOrgRoleOperation { + // User UID + string user = 1; + // Role name (e.g: "Admin", "Editor", "Viewer") + string role = 2; +} + +// UpdateUserOrgRoleOperation assigns the user's basic role and deletes existing basic role assignments. message UpdateUserOrgRoleOperation { // User UID string user = 1; diff --git a/pkg/services/authz/zanzana/common/translations.go b/pkg/services/authz/zanzana/common/translations.go index ba9a05ad7fa..25f3c22318b 100644 --- a/pkg/services/authz/zanzana/common/translations.go +++ b/pkg/services/authz/zanzana/common/translations.go @@ -1,6 +1,8 @@ package common import ( + "slices" + authlib "github.com/grafana/authlib/types" dashboards "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -23,6 +25,14 @@ var basicRolesTranslations = map[string]string{ roleNone: "basic_none", } +var basicRolesUIDs = []string{ + "basic_grafana_admin", + "basic_admin", + "basic_editor", + "basic_viewer", + "basic_none", +} + type resourceTranslation struct { typ string group string @@ -149,3 +159,7 @@ func TranslateToGroupResource(kind string) string { func TranslateBasicRole(name string) string { return basicRolesTranslations[name] } + +func IsBasicRole(name string) bool { + return slices.Contains(basicRolesUIDs, name) +} diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index 9cea3bfba62..79efce7373d 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -465,3 +465,21 @@ func AddRenderContext(req *openfgav1.CheckRequest) { ), }) } + +func SplitTupleObject(object string) (string, string, string) { + var objectType, name, relation string + parts := strings.Split(object, ":") + if len(parts) < 2 { + return "", "", "" + } + + objectType = parts[0] + nameRel := parts[1] + parts = strings.Split(nameRel, "#") + if len(parts) > 1 { + relation = parts[1] + } + name = parts[0] + + return objectType, name, relation +} diff --git a/pkg/services/authz/zanzana/server/server_mutate.go b/pkg/services/authz/zanzana/server/server_mutate.go index 3de4538d9b4..3a691ed6e12 100644 --- a/pkg/services/authz/zanzana/server/server_mutate.go +++ b/pkg/services/authz/zanzana/server/server_mutate.go @@ -77,7 +77,7 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e return OperationGroupFolder, nil case *authzextv1.MutateOperation_CreatePermission, *authzextv1.MutateOperation_DeletePermission: return OperationGroupPermission, nil - case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole: + case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole, *authzextv1.MutateOperation_AddUserOrgRole: return OperationGroupUserOrgRole, nil } return OperationGroup(""), errors.New("unsupported mutate operation type") diff --git a/pkg/services/authz/zanzana/server/server_mutate_org_role.go b/pkg/services/authz/zanzana/server/server_mutate_org_role.go index b8417d17308..bda9decb3d5 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_org_role.go +++ b/pkg/services/authz/zanzana/server/server_mutate_org_role.go @@ -18,18 +18,29 @@ func (s *Server) mutateOrgRoles(ctx context.Context, store *storeInfo, operation for _, operation := range operations { switch op := operation.Operation.(type) { - case *authzextv1.MutateOperation_UpdateUserOrgRole: - tuple, err := s.getUserOrgRoleWriteTuple(ctx, store, op.UpdateUserOrgRole) - if err != nil { - return err + case *authzextv1.MutateOperation_AddUserOrgRole: + basicRole := zanzana.TranslateBasicRole(op.AddUserOrgRole.GetRole()) + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, op.AddUserOrgRole.GetUser(), ""), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), } writeTuples = append(writeTuples, tuple) case *authzextv1.MutateOperation_DeleteUserOrgRole: - tuple, err := s.getUserOrgRoleDeleteTuple(ctx, store, op.DeleteUserOrgRole) + basicRole := zanzana.TranslateBasicRole(op.DeleteUserOrgRole.GetRole()) + tuple := &openfgav1.TupleKeyWithoutCondition{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, op.DeleteUserOrgRole.GetUser(), ""), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), + } + deleteTuples = append(deleteTuples, tuple) + case *authzextv1.MutateOperation_UpdateUserOrgRole: + writeTuple, existingTuples, err := s.getUserOrgRoleUpdateTuples(ctx, store, op.UpdateUserOrgRole) if err != nil { return err } - deleteTuples = append(deleteTuples, tuple) + writeTuples = append(writeTuples, writeTuple) + deleteTuples = append(deleteTuples, existingTuples...) default: s.logger.Debug("unsupported mutate operation", "operation", op) } @@ -65,20 +76,38 @@ func (s *Server) mutateOrgRoles(ctx context.Context, store *storeInfo, operation return nil } -func (s *Server) getUserOrgRoleWriteTuple(ctx context.Context, store *storeInfo, req *authzextv1.UpdateUserOrgRoleOperation) (*openfgav1.TupleKey, error) { - basicRole := zanzana.TranslateBasicRole(req.GetRole()) - return &openfgav1.TupleKey{ - User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""), - Relation: zanzana.RelationAssignee, - Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), - }, nil -} +func (s *Server) getUserOrgRoleUpdateTuples(ctx context.Context, store *storeInfo, req *authzextv1.UpdateUserOrgRoleOperation) (*openfgav1.TupleKey, []*openfgav1.TupleKeyWithoutCondition, error) { + readReq := &openfgav1.ReadRequest{ + StoreId: store.ID, + TupleKey: &openfgav1.ReadRequestTupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""), + Relation: zanzana.RelationAssignee, + // read tuples by object type ("role:") + Object: zanzana.NewTupleEntry(zanzana.TypeRole, "", ""), + }, + } + res, err := s.openfga.Read(ctx, readReq) + if err != nil { + return nil, nil, err + } + existingBasicRoleTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + for _, tuple := range res.GetTuples() { + _, roleName, _ := zanzana.SplitTupleObject(tuple.GetKey().GetObject()) + if zanzana.IsBasicRole(roleName) { + existingBasicRoleTuples = append(existingBasicRoleTuples, &openfgav1.TupleKeyWithoutCondition{ + User: tuple.GetKey().GetUser(), + Relation: tuple.GetKey().GetRelation(), + Object: tuple.GetKey().GetObject(), + }) + } + } -func (s *Server) getUserOrgRoleDeleteTuple(ctx context.Context, store *storeInfo, req *authzextv1.DeleteUserOrgRoleOperation) (*openfgav1.TupleKeyWithoutCondition, error) { basicRole := zanzana.TranslateBasicRole(req.GetRole()) - return &openfgav1.TupleKeyWithoutCondition{ + writeTuple := &openfgav1.TupleKey{ User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""), Relation: zanzana.RelationAssignee, Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), - }, nil + } + + return writeTuple, existingBasicRoleTuples, nil } diff --git a/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go b/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go index 275c796f61e..7c7472cf6ac 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go @@ -36,14 +36,6 @@ func testMutateOrgRoles(t *testing.T, srv *Server) { }, }, }, - { - Operation: &v1.MutateOperation_DeleteUserOrgRole{ - DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{ - User: "1", - Role: "Editor", - }, - }, - }, }, }) require.NoError(t, err) @@ -69,4 +61,50 @@ func testMutateOrgRoles(t *testing.T, srv *Server) { require.NoError(t, err) require.Len(t, res.Tuples, 0) }) + + t.Run("should add user org role and delete old role", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_AddUserOrgRole{ + AddUserOrgRole: &v1.AddUserOrgRoleOperation{ + User: "1", + Role: "Viewer", + }, + }, + }, + { + Operation: &v1.MutateOperation_DeleteUserOrgRole{ + DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{ + User: "1", + Role: "Admin", + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationAssignee, + Object: "role:basic_admin", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 0) + + res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationAssignee, + Object: "role:basic_viewer", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "user:1", res.Tuples[0].Key.User) + }) } From de512fb02ec51e66e83a013a71d8d6750adf006e Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:47:58 -0500 Subject: [PATCH 008/209] Plugins: Fix API sync error handling (#113240) --- apps/plugins/go.mod | 1 + apps/plugins/pkg/app/install/registrar.go | 19 +- .../plugins/pkg/app/install/registrar_test.go | 908 ++++++++++++++++++ 3 files changed, 923 insertions(+), 5 deletions(-) create mode 100644 apps/plugins/pkg/app/install/registrar_test.go diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index f0ef53718bc..e4a419d9214 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -6,6 +6,7 @@ require ( github.com/grafana/grafana-app-sdk v0.48.1 github.com/grafana/grafana-app-sdk/logging v0.48.1 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250428110029-a8ea72012bde + github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.1 k8s.io/apiserver v0.34.1 k8s.io/klog/v2 v2.130.1 diff --git a/apps/plugins/pkg/app/install/registrar.go b/apps/plugins/pkg/app/install/registrar.go index 98d7c02b94b..fe747eb2806 100644 --- a/apps/plugins/pkg/app/install/registrar.go +++ b/apps/plugins/pkg/app/install/registrar.go @@ -93,6 +93,7 @@ func equalStringPointers(a, b *string) bool { type InstallRegistrar struct { clientGenerator resource.ClientGenerator client *pluginsv0alpha1.PluginClient + clientErr error clientOnce sync.Once } @@ -107,20 +108,21 @@ func (r *InstallRegistrar) GetClient() (*pluginsv0alpha1.PluginClient, error) { r.clientOnce.Do(func() { client, err := pluginsv0alpha1.NewPluginClientFromGenerator(r.clientGenerator) if err != nil { + r.clientErr = err r.client = nil return } r.client = client }) - return r.client, nil + return r.client, r.clientErr } // Register creates or updates a plugin install in the registry. func (r *InstallRegistrar) Register(ctx context.Context, namespace string, install *PluginInstall) error { client, err := r.GetClient() if err != nil { - return nil + return err } identifier := resource.Identifier{ Namespace: namespace, @@ -132,9 +134,12 @@ func (r *InstallRegistrar) Register(ctx context.Context, namespace string, insta return err } - if existing != nil && install.ShouldUpdate(existing) { - _, err = client.Update(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.UpdateOptions{ResourceVersion: existing.ResourceVersion}) - return err + if existing != nil { + if install.ShouldUpdate(existing) { + _, err = client.Update(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.UpdateOptions{ResourceVersion: existing.ResourceVersion}) + return err + } + return nil } _, err = client.Create(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.CreateOptions{}) @@ -155,6 +160,10 @@ func (r *InstallRegistrar) Unregister(ctx context.Context, namespace string, nam if err != nil && !errorsK8s.IsNotFound(err) { return err } + // if the plugin doesn't exist, nothing to unregister + if existing == nil { + return nil + } // if the source is different, do not unregister if existingSource, ok := existing.Annotations[PluginInstallSourceAnnotation]; ok && existingSource != source { return nil diff --git a/apps/plugins/pkg/app/install/registrar_test.go b/apps/plugins/pkg/app/install/registrar_test.go new file mode 100644 index 00000000000..36359896ae6 --- /dev/null +++ b/apps/plugins/pkg/app/install/registrar_test.go @@ -0,0 +1,908 @@ +package install + +import ( + "context" + "errors" + "testing" + + "github.com/grafana/grafana-app-sdk/resource" + "github.com/stretchr/testify/require" + errorsK8s "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" +) + +func TestPluginInstall_ShouldUpdate(t *testing.T) { + baseExisting := &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginSpec{ + Id: "plugin-1", + Version: "1.0.0", + Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), + }, + } + + baseInstall := PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + } + + tests := []struct { + name string + modifyInstall func(*PluginInstall) + modifyExisting func(*pluginsv0alpha1.Plugin) + expectUpdate bool + }{ + { + name: "no changes", + expectUpdate: false, + }, + { + name: "version differs", + modifyInstall: func(pi *PluginInstall) { + pi.Version = "2.0.0" + }, + expectUpdate: true, + }, + { + name: "class differs", + modifyInstall: func(pi *PluginInstall) { + pi.Class = ClassCore + }, + expectUpdate: true, + }, + { + name: "url differs", + modifyInstall: func(pi *PluginInstall) { + pi.URL = "https://example.com/plugin.zip" + }, + expectUpdate: true, + }, + { + name: "source differs", + modifyExisting: func(existing *pluginsv0alpha1.Plugin) { + existing.Annotations[PluginInstallSourceAnnotation] = SourceUnknown + }, + expectUpdate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + existing := baseExisting.DeepCopy() + install := baseInstall + + if tt.modifyExisting != nil { + tt.modifyExisting(existing) + } + if tt.modifyInstall != nil { + tt.modifyInstall(&install) + } + + require.Equal(t, tt.expectUpdate, install.ShouldUpdate(existing)) + }) + } +} + +func TestInstallRegistrar_Register(t *testing.T) { + tests := []struct { + name string + install *PluginInstall + existing *pluginsv0alpha1.Plugin + existingErr error + expectedCreates int + expectedUpdates int + expectError bool + }{ + { + name: "creates plugin when not found", + install: &PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + existingErr: errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1"), + expectedCreates: 1, + }, + { + name: "updates plugin when fields change", + install: &PluginInstall{ + ID: "plugin-1", + Version: "2.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + existing: &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + ResourceVersion: "7", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginSpec{ + Id: "plugin-1", + Version: "1.0.0", + Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), + }, + }, + expectedUpdates: 1, + }, + { + name: "skips create when plugin matches", + install: &PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + existing: &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + ResourceVersion: "9", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginSpec{ + Id: "plugin-1", + Version: "1.0.0", + Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), + }, + }, + }, + { + name: "returns error on unexpected get failure", + install: &PluginInstall{ + ID: "plugin-err", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + existingErr: errorsK8s.NewInternalError(errors.New("boom")), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + createCalls := 0 + updateCalls := 0 + var receivedResourceVersions []string + var updatedPlugins []*pluginsv0alpha1.Plugin + + fakeClient := &fakePluginInstallClient{ + getFunc: func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) { + if tt.existingErr != nil { + return nil, tt.existingErr + } + if tt.existing == nil { + return nil, errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1") + } + return tt.existing.DeepCopy(), nil + }, + createFunc: func(context.Context, *pluginsv0alpha1.Plugin, resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) { + createCalls++ + return tt.install.ToPluginInstallV0Alpha1("org-1"), nil + }, + updateFunc: func(_ context.Context, obj *pluginsv0alpha1.Plugin, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) { + updateCalls++ + receivedResourceVersions = append(receivedResourceVersions, opts.ResourceVersion) + updatedPlugins = append(updatedPlugins, obj) + return obj, nil + }, + } + + registrar := NewInstallRegistrar(&fakeClientGenerator{client: fakeClient}) + + err := registrar.Register(ctx, "org-1", tt.install) + if tt.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.expectedCreates, createCalls) + require.Equal(t, tt.expectedUpdates, updateCalls) + + if tt.expectedUpdates > 0 { + require.Equal(t, []string{tt.existing.ResourceVersion}, receivedResourceVersions) + require.Len(t, updatedPlugins, 1) + require.Equal(t, tt.install.Version, updatedPlugins[0].Spec.Version) + } + }) + } +} + +func pluginGroupResource() schema.GroupResource { + return schema.GroupResource{Group: pluginsv0alpha1.APIGroup, Resource: "plugininstalls"} +} + +type fakePluginInstallClient struct { + listAllFunc func(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginList, error) + getFunc func(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.Plugin, error) + createFunc func(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) + updateFunc func(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) + deleteFunc func(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error +} + +func (f *fakePluginInstallClient) Get(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.Plugin, error) { + if f.getFunc != nil { + return f.getFunc(ctx, identifier) + } + return nil, errorsK8s.NewNotFound(pluginGroupResource(), identifier.Name) +} + +func (f *fakePluginInstallClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginList, error) { + if f.listAllFunc != nil { + return f.listAllFunc(ctx, namespace, opts) + } + return &pluginsv0alpha1.PluginList{}, nil +} + +func (f *fakePluginInstallClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginList, error) { + return f.ListAll(ctx, namespace, opts) +} + +func (f *fakePluginInstallClient) Create(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) { + if f.createFunc != nil { + return f.createFunc(ctx, obj, opts) + } + return obj, nil +} + +func (f *fakePluginInstallClient) Update(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) { + if f.updateFunc != nil { + return f.updateFunc(ctx, obj, opts) + } + return obj, nil +} + +func (f *fakePluginInstallClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus pluginsv0alpha1.PluginStatus, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) { + return nil, nil +} + +func (f *fakePluginInstallClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*pluginsv0alpha1.Plugin, error) { + return nil, nil +} + +func (f *fakePluginInstallClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + if f.deleteFunc != nil { + return f.deleteFunc(ctx, identifier, opts) + } + return nil +} + +type fakeClientGenerator struct { + client *fakePluginInstallClient + shouldError bool +} + +func (f *fakeClientGenerator) ClientFor(resource.Kind) (resource.Client, error) { + if f.shouldError { + return nil, errors.New("client generation failed") + } + return &fakeResourceClient{client: f.client}, nil +} + +type fakeResourceClient struct { + client *fakePluginInstallClient +} + +func (f *fakeResourceClient) Get(ctx context.Context, identifier resource.Identifier) (resource.Object, error) { + return f.client.Get(ctx, identifier) +} + +func (f *fakeResourceClient) GetInto(ctx context.Context, identifier resource.Identifier, into resource.Object) error { + obj, err := f.client.Get(ctx, identifier) + if err != nil { + return err + } + if target, ok := into.(*pluginsv0alpha1.Plugin); ok { + *target = *obj + } + return nil +} + +func (f *fakeResourceClient) List(ctx context.Context, namespace string, options resource.ListOptions) (resource.ListObject, error) { + return f.client.ListAll(ctx, namespace, options) +} + +func (f *fakeResourceClient) ListInto(ctx context.Context, namespace string, options resource.ListOptions, into resource.ListObject) error { + list, err := f.client.ListAll(ctx, namespace, options) + if err != nil { + return err + } + if target, ok := into.(*pluginsv0alpha1.PluginList); ok { + *target = *list + } + return nil +} + +func (f *fakeResourceClient) Create(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.CreateOptions) (resource.Object, error) { + plugin := obj.(*pluginsv0alpha1.Plugin) + return f.client.Create(ctx, plugin, options) +} + +func (f *fakeResourceClient) CreateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.CreateOptions, into resource.Object) error { + created, err := f.Create(ctx, identifier, obj, options) + if err != nil { + return err + } + if plugin, ok := created.(*pluginsv0alpha1.Plugin); ok { + if target, ok := into.(*pluginsv0alpha1.Plugin); ok { + *target = *plugin + } + } + return nil +} + +func (f *fakeResourceClient) Update(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.UpdateOptions) (resource.Object, error) { + plugin := obj.(*pluginsv0alpha1.Plugin) + return f.client.Update(ctx, plugin, options) +} + +func (f *fakeResourceClient) UpdateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.UpdateOptions, into resource.Object) error { + updated, err := f.Update(ctx, identifier, obj, options) + if err != nil { + return err + } + if plugin, ok := updated.(*pluginsv0alpha1.Plugin); ok { + if target, ok := into.(*pluginsv0alpha1.Plugin); ok { + *target = *plugin + } + } + return nil +} + +func (f *fakeResourceClient) Patch(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions) (resource.Object, error) { + return nil, nil +} + +func (f *fakeResourceClient) PatchInto(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions, into resource.Object) error { + return nil +} + +func (f *fakeResourceClient) Delete(ctx context.Context, identifier resource.Identifier, options resource.DeleteOptions) error { + return f.client.Delete(ctx, identifier, options) +} + +func (f *fakeResourceClient) SubresourceRequest(ctx context.Context, identifier resource.Identifier, req resource.CustomRouteRequestOptions) ([]byte, error) { + return []byte{}, nil +} + +func (f *fakeResourceClient) Watch(ctx context.Context, namespace string, options resource.WatchOptions) (resource.WatchResponse, error) { + return &fakeWatchResponse{}, nil +} + +type fakeWatchResponse struct{} + +func (f *fakeWatchResponse) Stop() {} + +func (f *fakeWatchResponse) WatchEvents() <-chan resource.WatchEvent { + ch := make(chan resource.WatchEvent) + close(ch) + return ch +} + +func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) { + tests := []struct { + name string + install PluginInstall + namespace string + validate func(*testing.T, *pluginsv0alpha1.Plugin) + }{ + { + name: "empty URL creates nil pointer", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + namespace: "org-1", + validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { + require.Nil(t, p.Spec.Url) + }, + }, + { + name: "non-empty URL creates pointer", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + URL: "https://example.com/plugin.zip", + Class: ClassExternal, + Source: SourcePluginStore, + }, + namespace: "org-1", + validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { + require.NotNil(t, p.Spec.Url) + require.Equal(t, "https://example.com/plugin.zip", *p.Spec.Url) + }, + }, + { + name: "core class is mapped correctly", + install: PluginInstall{ + ID: "plugin-core", + Version: "2.0.0", + Class: ClassCore, + Source: SourcePluginStore, + }, + namespace: "org-2", + validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { + require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCore), p.Spec.Class) + }, + }, + { + name: "cdn class is mapped correctly", + install: PluginInstall{ + ID: "plugin-cdn", + Version: "3.0.0", + Class: ClassCDN, + Source: SourcePluginStore, + }, + namespace: "org-3", + validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { + require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCDN), p.Spec.Class) + }, + }, + { + name: "source annotation is set correctly", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourceUnknown, + }, + namespace: "org-1", + validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { + require.Equal(t, SourceUnknown, p.Annotations[PluginInstallSourceAnnotation]) + }, + }, + { + name: "namespace and name are set correctly", + install: PluginInstall{ + ID: "my-plugin", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + namespace: "my-namespace", + validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { + require.Equal(t, "my-namespace", p.Namespace) + require.Equal(t, "my-plugin", p.Name) + require.Equal(t, "my-plugin", p.Spec.Id) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.install.ToPluginInstallV0Alpha1(tt.namespace) + require.NotNil(t, result) + require.Equal(t, tt.namespace, result.Namespace) + require.Equal(t, tt.install.ID, result.Name) + require.Equal(t, tt.install.ID, result.Spec.Id) + require.Equal(t, tt.install.Version, result.Spec.Version) + tt.validate(t, result) + }) + } +} + +func TestEqualStringPointers(t *testing.T) { + str1 := "value1" + str2 := "value2" + str3 := "value1" + + tests := []struct { + name string + a *string + b *string + expected bool + }{ + { + name: "both nil", + a: nil, + b: nil, + expected: true, + }, + { + name: "first nil, second non-nil", + a: nil, + b: &str1, + expected: false, + }, + { + name: "first non-nil, second nil", + a: &str1, + b: nil, + expected: false, + }, + { + name: "both non-nil with same value", + a: &str1, + b: &str3, + expected: true, + }, + { + name: "both non-nil with different values", + a: &str1, + b: &str2, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := equalStringPointers(tt.a, tt.b) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) { + existingURL := "https://old.example.com/plugin.zip" + newURL := "https://new.example.com/plugin.zip" + + tests := []struct { + name string + install PluginInstall + existingURL *string + expectUpdate bool + }{ + { + name: "URL transition from nil to non-nil", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + URL: newURL, + Class: ClassExternal, + Source: SourcePluginStore, + }, + existingURL: nil, + expectUpdate: true, + }, + { + name: "URL transition from non-nil to nil", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + URL: "", + Class: ClassExternal, + Source: SourcePluginStore, + }, + existingURL: &existingURL, + expectUpdate: true, + }, + { + name: "URL stays nil", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + URL: "", + Class: ClassExternal, + Source: SourcePluginStore, + }, + existingURL: nil, + expectUpdate: false, + }, + { + name: "URL stays same non-nil value", + install: PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + URL: existingURL, + Class: ClassExternal, + Source: SourcePluginStore, + }, + existingURL: &existingURL, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + existing := &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginSpec{ + Id: "plugin-1", + Version: "1.0.0", + Url: tt.existingURL, + Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), + }, + } + + require.Equal(t, tt.expectUpdate, tt.install.ShouldUpdate(existing)) + }) + } +} + +func TestInstallRegistrar_GetClient(t *testing.T) { + t.Run("successfully creates client on first call", func(t *testing.T) { + fakeClient := &fakePluginInstallClient{} + generator := &fakeClientGenerator{client: fakeClient} + registrar := NewInstallRegistrar(generator) + + client, err := registrar.GetClient() + require.NoError(t, err) + require.NotNil(t, client) + }) + + t.Run("returns same client on subsequent calls", func(t *testing.T) { + fakeClient := &fakePluginInstallClient{} + generator := &fakeClientGenerator{client: fakeClient} + registrar := NewInstallRegistrar(generator) + + client1, err1 := registrar.GetClient() + require.NoError(t, err1) + + client2, err2 := registrar.GetClient() + require.NoError(t, err2) + + require.Equal(t, client1, client2) + }) + + t.Run("returns error when client generation fails", func(t *testing.T) { + generator := &fakeClientGenerator{client: nil, shouldError: true} + registrar := NewInstallRegistrar(generator) + + client, err := registrar.GetClient() + require.Error(t, err) + require.Nil(t, client) + }) +} + +func TestInstallRegistrar_Register_ErrorCases(t *testing.T) { + tests := []struct { + name string + install *PluginInstall + setupClient func(*fakePluginInstallClient) + expectError bool + }{ + { + name: "create fails", + install: &PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + setupClient: func(fc *fakePluginInstallClient) { + fc.getFunc = func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) { + return nil, errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1") + } + fc.createFunc = func(context.Context, *pluginsv0alpha1.Plugin, resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) { + return nil, errors.New("create failed") + } + }, + expectError: true, + }, + { + name: "update fails", + install: &PluginInstall{ + ID: "plugin-1", + Version: "2.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + }, + setupClient: func(fc *fakePluginInstallClient) { + fc.getFunc = func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) { + return &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + ResourceVersion: "5", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginSpec{ + Id: "plugin-1", + Version: "1.0.0", + Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), + }, + }, nil + } + fc.updateFunc = func(context.Context, *pluginsv0alpha1.Plugin, resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) { + return nil, errors.New("update failed") + } + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + fakeClient := &fakePluginInstallClient{} + tt.setupClient(fakeClient) + + registrar := NewInstallRegistrar(&fakeClientGenerator{client: fakeClient}) + + err := registrar.Register(ctx, "org-1", tt.install) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestInstallRegistrar_Unregister(t *testing.T) { + tests := []struct { + name string + namespace string + pluginName string + source Source + existing *pluginsv0alpha1.Plugin + existingErr error + expectedCalls int + expectError bool + }{ + { + name: "successfully deletes plugin with matching source", + namespace: "org-1", + pluginName: "plugin-1", + source: SourcePluginStore, + existing: &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + }, + expectedCalls: 1, + }, + { + name: "plugin not found should not error", + namespace: "org-1", + pluginName: "plugin-nonexistent", + source: SourcePluginStore, + existingErr: errorsK8s.NewNotFound(pluginGroupResource(), "plugin-nonexistent"), + expectedCalls: 0, + expectError: false, + }, + { + name: "skips delete when source doesn't match", + namespace: "org-1", + pluginName: "plugin-1", + source: SourcePluginStore, + existing: &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourceUnknown, + }, + }, + }, + expectedCalls: 0, + }, + { + name: "returns error on unexpected get failure", + namespace: "org-1", + pluginName: "plugin-err", + source: SourcePluginStore, + existingErr: errorsK8s.NewInternalError(errors.New("get failed")), + expectedCalls: 0, + expectError: true, + }, + { + name: "delete failure returns error", + namespace: "org-1", + pluginName: "plugin-1", + source: SourcePluginStore, + existing: &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + Annotations: map[string]string{ + PluginInstallSourceAnnotation: SourcePluginStore, + }, + }, + }, + expectedCalls: 1, + expectError: true, + }, + { + name: "handles missing source annotation", + namespace: "org-1", + pluginName: "plugin-1", + source: SourcePluginStore, + existing: &pluginsv0alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-1", + Name: "plugin-1", + Annotations: map[string]string{}, + }, + }, + expectedCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + deleteCalls := 0 + + fakeClient := &fakePluginInstallClient{ + getFunc: func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) { + if tt.existingErr != nil { + return nil, tt.existingErr + } + if tt.existing == nil { + return nil, errorsK8s.NewNotFound(pluginGroupResource(), tt.pluginName) + } + return tt.existing.DeepCopy(), nil + }, + deleteFunc: func(context.Context, resource.Identifier, resource.DeleteOptions) error { + deleteCalls++ + if tt.name == "delete failure returns error" { + return errors.New("delete failed") + } + return nil + }, + } + + registrar := NewInstallRegistrar(&fakeClientGenerator{client: fakeClient}) + + err := registrar.Unregister(ctx, tt.namespace, tt.pluginName, tt.source) + + require.Equal(t, tt.expectedCalls, deleteCalls) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestInstallRegistrar_GetClientError(t *testing.T) { + t.Run("Register returns error with nil client", func(t *testing.T) { + ctx := context.Background() + generator := &fakeClientGenerator{client: nil, shouldError: true} + registrar := NewInstallRegistrar(generator) + + install := &PluginInstall{ + ID: "plugin-1", + Version: "1.0.0", + Class: ClassExternal, + Source: SourcePluginStore, + } + + err := registrar.Register(ctx, "org-1", install) + require.Error(t, err) + }) + + t.Run("Unregister returns error with nil client", func(t *testing.T) { + ctx := context.Background() + generator := &fakeClientGenerator{client: nil, shouldError: true} + registrar := NewInstallRegistrar(generator) + + err := registrar.Unregister(ctx, "org-1", "plugin-1", SourcePluginStore) + require.Error(t, err) + }) +} From 84bd99f1c1f322680d0430fbd59d4672e2ef569f Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:18:15 -0800 Subject: [PATCH 009/209] SQL Expressions: Update to feature badges (#112795) * chore: update badge + update logic * chore: update comment --- .../expressions/ExpressionQueryEditor.tsx | 48 +++++++++++++------ .../components/ExpressionTypeDropdown.tsx | 2 +- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index f8e3ad50abb..538aa2e52f6 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/css'; import { useCallback, useEffect, useRef } from 'react'; -import { DataSourceApi, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { DataSourceApi, FeatureState, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Button, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui'; +import { Button, FeatureBadge, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui'; import { ClassicConditions } from './components/ClassicConditions'; import { ExpressionTypeDropdown } from './components/ExpressionTypeDropdown'; @@ -23,21 +23,33 @@ const labelWidth = 15; type NonClassicExpressionType = Exclude; type ExpressionTypeConfigStorage = Partial>; -// Help text for each expression type - can be expanded with more detailed content -const getExpressionHelpText = (type: ExpressionQueryType): PopoverContent | string => { +/** + * Get the configuration for an expression type (helper text and feature state). + * @param type - The expression type. + * @returns The configuration for the expression type. + */ +const getExpressionTypeConfig = ( + type: ExpressionQueryType +): { helperText: PopoverContent; featureState: FeatureState | undefined } => { const description = expressionTypes.find(({ value }) => value === type)?.description; switch (type) { case ExpressionQueryType.sql: - return ( - - Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie - "A", "B") are available as tables and referenced by query-name. Fields are available as - columns, as returned from the data source. - - ); + return { + helperText: ( + + Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie + "A", "B") are available as tables and referenced by query-name. Fields are available as + columns, as returned from the data source. + + ), + featureState: FeatureState.preview, + }; default: - return description ?? ''; + return { + helperText: description ?? '', + featureState: undefined, + }; } }; @@ -148,7 +160,7 @@ export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) { } }; - const helperText = getExpressionHelpText(query.type); + const { helperText, featureState } = getExpressionTypeConfig(query.type); return (
@@ -163,7 +175,10 @@ export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) { - {helperText && } +
+ {featureState && } + {helperText && } +
{renderExpressionType()}
@@ -176,7 +191,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ alignItems: 'center', gap: theme.spacing(1), }), - infoIcon: css({ + fieldContainer: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), marginBottom: theme.spacing(0.5), // Align with the select field }), }); diff --git a/public/app/features/expressions/components/ExpressionTypeDropdown.tsx b/public/app/features/expressions/components/ExpressionTypeDropdown.tsx index 2fc78ba53d2..bb84dcd076c 100644 --- a/public/app/features/expressions/components/ExpressionTypeDropdown.tsx +++ b/public/app/features/expressions/components/ExpressionTypeDropdown.tsx @@ -37,7 +37,7 @@ const ExpressionMenuItem = memo(({ item, onSelect }) =>
From ffa5e41bec36d8050bd2e9189159e8d83ea5f5f5 Mon Sep 17 00:00:00 2001 From: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:56:41 +0100 Subject: [PATCH 010/209] docs(alerting): add note about invalid numeric identifiers in templates (#113269) --- .../alerting/alerting-rules/templates/reference.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/sources/alerting/alerting-rules/templates/reference.md b/docs/sources/alerting/alerting-rules/templates/reference.md index d638b8e1d3c..b80aed07766 100644 --- a/docs/sources/alerting/alerting-rules/templates/reference.md +++ b/docs/sources/alerting/alerting-rules/templates/reference.md @@ -141,6 +141,20 @@ Alternatively, you can use the `index()` function to retrieve the query value: {{ index $values "B" }} CPU usage for {{ index $labels "instance" }} over the last 5 minutes. ``` +{{< admonition type="note" >}} + +Variable names that start with a number (for example, `1B`) are not [valid identifiers in Go templates](https://go.dev/ref/spec#Identifiers). + +To access a value or label whose key starts with a number, use the `index` function: + +``` +{{ index $values "1B" }} CPU usage for {{ index $labels "1instance" }} over the last 5 minutes. +``` + +Using `{{ $values.1B.Value }}` is invalid and causes the template code to render as plain text. + +{{< /admonition >}} + #### $value The `$value` variable is a string containing the labels and values of all instant queries; threshold, reduce and math expressions, and classic conditions in the alert rule. From 39720466954a030f7ba3592d3981e9850a56d8b5 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 4 Nov 2025 17:38:42 +0000 Subject: [PATCH 011/209] Chore: Improve step name to differentiate between light/dark themes (#113407) improve step name to differentiate between light/dark themes --- .github/workflows/storybook-a11y.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/storybook-a11y.yml b/.github/workflows/storybook-a11y.yml index 5736c86d8bf..04d01151bae 100644 --- a/.github/workflows/storybook-a11y.yml +++ b/.github/workflows/storybook-a11y.yml @@ -34,7 +34,7 @@ jobs: id-token: write needs: detect-changes if: needs.detect-changes.outputs.changed == 'true' - name: "Run Storybook a11y tests" + name: "Run Storybook a11y tests (light theme)" steps: - uses: actions/checkout@v5 with: @@ -64,7 +64,7 @@ jobs: id-token: write needs: detect-changes if: needs.detect-changes.outputs.changed == 'true' - name: "Run Storybook a11y tests" + name: "Run Storybook a11y tests (dark theme)" steps: - uses: actions/checkout@v5 with: From 5abc0d0d91ca807802fe2416abea1f9f5933ac2f Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 4 Nov 2025 14:35:56 -0500 Subject: [PATCH 012/209] Coverage: Add new exclusions for team coverage report (#112997) --- jest.config.codeowner.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jest.config.codeowner.js b/jest.config.codeowner.js index 5e1a2755949..8e26692a8d4 100644 --- a/jest.config.codeowner.js +++ b/jest.config.codeowner.js @@ -35,17 +35,20 @@ const sourceFiles = teamFiles.filter((file) => { const ext = path.extname(file); return ( ['.ts', '.tsx', '.js', '.jsx'].includes(ext) && - // exclude all tests + // exclude all tests and mocks !path.matchesGlob(file, '**/test/**/*') && !file.includes('.test.') && !file.includes('.spec.') && + !path.matchesGlob(file, '**/__mocks__/**/*') && // and storybook stories !file.includes('.story.') && // and generated files !file.includes('.gen.ts') && // and type definitions !file.includes('.d.ts') && - !file.endsWith('/types.ts') + !file.endsWith('/types.ts') && + // and anything in graveyard + !path.matchesGlob(file, '**/graveyard/**/*') ); }); From 867e8bb98f83839416f0eb5d3804e9d332b29517 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 4 Nov 2025 15:21:30 -0500 Subject: [PATCH 013/209] StatusHistory: Add e2e suite to confirm standard cases (#113358) * StatusHistory: Add e2e suite to confirm standard cases * update dev dashbord tests * update CODEOWNERS --- .github/CODEOWNERS | 15 +- ...tatus-history-thresholds-mappings.v42.json | 1158 ++++++++++++++++ .../status-history-thresholds-mappings.json | 1159 +++++++++++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 1 + .../panels-suite/status-history.spec.ts | 87 ++ 5 files changed, 2407 insertions(+), 13 deletions(-) create mode 100644 apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-status-history/status-history-thresholds-mappings.v42.json create mode 100644 devenv/dev-dashboards/panel-status-history/status-history-thresholds-mappings.json create mode 100644 e2e-playwright/panels-suite/status-history.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be3eaaef522..b7191187e93 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -242,6 +242,7 @@ /devenv/dev-dashboards/panel-library @grafana/dataviz-squad /devenv/dev-dashboards/panel-piechart @grafana/dataviz-squad /devenv/dev-dashboards/panel-stat @grafana/dataviz-squad +/devenv/dev-dashboards/panel-status-history @grafana/dataviz-squad /devenv/dev-dashboards/panel-table @grafana/dataviz-squad /devenv/dev-dashboards/panel-timeline @grafana/dataviz-squad /devenv/dev-dashboards/panel-timeseries @grafana/dataviz-squad @@ -473,24 +474,12 @@ i18next.config.ts @grafana/grafana-frontend-platform /e2e-playwright/fixtures/long-trace-response.json @grafana/observability-traces-and-profiling /e2e-playwright/fixtures/tempo-response.json @grafana/oss-big-tent /e2e-playwright/fixtures/prometheus-response.json @grafana/datapro -/e2e-playwright/panels-suite/canvas-scene.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/ @grafana/dataviz-squad /e2e-playwright/panels-suite/dashlist.spec.ts @grafana/grafana-search-navigate-organise -/e2e-playwright/panels-suite/datagrid-data-change.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/datagrid-editing-features.spec.ts @grafana/dataviz-squad /e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts @grafana/plugins-platform-frontend -/e2e-playwright/panels-suite/geomap-layer-types.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/geomap-map-controls.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/geomap-spatial-operations-transform.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/heatmap.spec.ts @grafana/dataviz-squad /e2e-playwright/panels-suite/panelEdit_base.spec.ts @grafana/dashboards-squad /e2e-playwright/panels-suite/panelEdit_queries.spec.ts @grafana/dashboards-squad /e2e-playwright/panels-suite/panelEdit_transforms.spec.ts @grafana/datapro -/e2e-playwright/panels-suite/state-timeline.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/table-footer.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/table-markdown.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/table-sparkline.spec.ts @grafana/dataviz-squad -/e2e-playwright/panels-suite/table-utils.ts @grafana/dataviz-squad /e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources /e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend /e2e-playwright/smoke-tests-suite/ @grafana/grafana-frontend-platform diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-status-history/status-history-thresholds-mappings.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-status-history/status-history-thresholds-mappings.v42.json new file mode 100644 index 00000000000..3853fe01b94 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-status-history/status-history-thresholds-mappings.v42.json @@ -0,0 +1,1158 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 15116, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 11, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "title": "default", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default absolute thresholds", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "percentage", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 8, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default percentage thresholds", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-series" + }, + "properties": [ + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 3, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "override thresholds", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0 + }, + "to": 9.9999 + }, + "type": "range" + }, + { + "options": { + "from": 10, + "result": { + "color": "yellow", + "index": 1 + }, + "to": 14.9999 + }, + "type": "range" + }, + { + "options": { + "from": 15, + "result": { + "color": "red", + "index": 2 + }, + "to": 24.9999 + }, + "type": "range" + }, + { + "options": { + "from": 25, + "result": { + "color": "blue", + "index": 3 + }, + "to": 100000 + }, + "type": "range" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 9 + }, + "id": 6, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.47, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default value mappings", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-series" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0 + }, + "to": 9.9999 + }, + "type": "range" + }, + { + "options": { + "from": 10, + "result": { + "color": "yellow", + "index": 1 + }, + "to": 14.9999 + }, + "type": "range" + }, + { + "options": { + "from": 15, + "result": { + "color": "red", + "index": 2 + }, + "to": 24.9999 + }, + "type": "range" + }, + { + "options": { + "from": 25, + "result": { + "color": "blue", + "index": 3 + }, + "to": 100000 + }, + "type": "range" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 9 + }, + "id": 7, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "override value mappings", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 9 + }, + "id": 5, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n },\n {\n \"color\": \"#EAB839\",\n \"value\": 10\n },\n {\n \"color\": \"red\",\n \"value\": 15\n },\n {\n \"color\": \"#6ED0E0\",\n \"value\": 25\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000\n ],\n [\n 5,\n 10,\n 20,\n 30\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "field thresholds from data", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 9 + }, + "id": 9, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "hide": false, + "max": 30, + "min": 0.01, + "noise": 30, + "refId": "B", + "scenarioId": "random_walk", + "startValue": 1 + } + ], + "title": "threshold from random walk", + "transformations": [ + { + "id": "configFromData", + "options": { + "configRefId": "B", + "mappings": [ + { + "fieldName": "B-series", + "handlerKey": "threshold1" + } + ] + } + } + ], + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "purple", + "index": 0, + "text": "null" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "red", + "index": 1, + "text": "NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 18 + }, + "id": 12, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000\n ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [3]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "special null | NaN value mapping from data", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "null+nan", + "result": { + "color": "super-light-red", + "index": 0, + "text": "null + NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 18 + }, + "id": 13, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000\n ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [1]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "special null + NaN value mapping from data", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "false", + "result": { + "color": "red", + "index": 0 + } + }, + "type": "special" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "blue", + "index": 1, + "text": "null + NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 18 + }, + "id": 14, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000,\n 1674750835000,\n 1674754235000,\n 1674757835000\n ],\n [\n null,\n null,\n false,\n true,\n true,\n false,\n true,\n null\n ]\n ],\n \"entities\": [null, { \"NaN\": [0], \"Undefined\": [1]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "boolean values from data", + "type": "status-history" + }, + { + "id": 15, + "type": "status-history", + "title": "no data", + "gridPos": { + "x": 18, + "y": 18, + "h": 9, + "w": 6 + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 0, + "fillOpacity": 70, + "spanNulls": false, + "insertNulls": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "axisPlacement": "auto" + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "fieldMinMax": false + }, + "overrides": [] + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "scenarioId": "random_walk", + "refId": "A", + "seriesCount": 0 + } + ], + "datasource": { + "type": "grafana-testdata-datasource" + }, + "options": { + "mergeValues": true, + "showValue": "auto", + "alignValue": "center", + "rowHeight": 0.9, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none", + "hideZeros": false + } + } + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": ["gdev", "panel-tests", "state-timeline", "graph-ng"], + "templating": { + "list": [] + }, + "time": { + "from": "2023-01-26T11:29:47.180Z", + "to": "2023-01-26T16:29:39.205Z" + }, + "timepicker": {}, + "timezone": "utc", + "title": "StatusHistory - Thresholds & Mappings", + "uid": "a2f4ad9e-3b44-4624-8067-35f31be5d309", + "weekStart": "" +} diff --git a/devenv/dev-dashboards/panel-status-history/status-history-thresholds-mappings.json b/devenv/dev-dashboards/panel-status-history/status-history-thresholds-mappings.json new file mode 100644 index 00000000000..44d4dda9522 --- /dev/null +++ b/devenv/dev-dashboards/panel-status-history/status-history-thresholds-mappings.json @@ -0,0 +1,1159 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 15116, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 11, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "title": "default", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default absolute thresholds", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "percentage", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 8, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default percentage thresholds", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-series" + }, + "properties": [ + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 10 + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "#6ED0E0", + "value": 30 + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 3, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "override thresholds", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0 + }, + "to": 9.9999 + }, + "type": "range" + }, + { + "options": { + "from": 10, + "result": { + "color": "yellow", + "index": 1 + }, + "to": 14.9999 + }, + "type": "range" + }, + { + "options": { + "from": 15, + "result": { + "color": "red", + "index": 2 + }, + "to": 24.9999 + }, + "type": "range" + }, + { + "options": { + "from": 25, + "result": { + "color": "blue", + "index": 3 + }, + "to": 100000 + }, + "type": "range" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 9 + }, + "id": 6, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.47, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "default value mappings", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A-series" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0 + }, + "to": 9.9999 + }, + "type": "range" + }, + { + "options": { + "from": 10, + "result": { + "color": "yellow", + "index": 1 + }, + "to": 14.9999 + }, + "type": "range" + }, + { + "options": { + "from": 15, + "result": { + "color": "red", + "index": 2 + }, + "to": 24.9999 + }, + "type": "range" + }, + { + "options": { + "from": 25, + "result": { + "color": "blue", + "index": 3 + }, + "to": 100000 + }, + "type": "range" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 9 + }, + "id": 7, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + } + ], + "title": "override value mappings", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 9 + }, + "id": 5, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n },\n {\n \"color\": \"#EAB839\",\n \"value\": 10\n },\n {\n \"color\": \"red\",\n \"value\": 15\n },\n {\n \"color\": \"#6ED0E0\",\n \"value\": 25\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000\n ],\n [\n 5,\n 10,\n 20,\n 30\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "field thresholds from data", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 9 + }, + "id": 9, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "5,10,20,30,40" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "hide": false, + "max": 30, + "min": 0.01, + "noise": 30, + "refId": "B", + "scenarioId": "random_walk", + "startValue": 1 + } + ], + "title": "threshold from random walk", + "transformations": [ + { + "id": "configFromData", + "options": { + "configRefId": "B", + "mappings": [ + { + "fieldName": "B-series", + "handlerKey": "threshold1" + } + ] + } + } + ], + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "purple", + "index": 0, + "text": "null" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "red", + "index": 1, + "text": "NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 0, + "y": 18 + }, + "id": 12, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000\n ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [3]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "special null | NaN value mapping from data", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "null+nan", + "result": { + "color": "super-light-red", + "index": 0, + "text": "null + NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 6, + "y": 18 + }, + "id": 13, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000\n ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [1]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "special null + NaN value mapping from data", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "fieldMinMax": false, + "mappings": [ + { + "options": { + "match": "false", + "result": { + "color": "red", + "index": 0 + } + }, + "type": "special" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "blue", + "index": 1, + "text": "null + NaN" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 18 + }, + "id": 14, + "options": { + "alignValue": "center", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000,\n 1674750835000,\n 1674754235000,\n 1674757835000\n ],\n [\n null,\n null,\n false,\n true,\n true,\n false,\n true,\n null\n ]\n ],\n \"entities\": [null, { \"NaN\": [0], \"Undefined\": [1]}]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "boolean values from data", + "type": "status-history" + }, + { + "id": 15, + "type": "status-history", + "title": "no data", + "gridPos": { + "x": 18, + "y": 18, + "h": 9, + "w": 6 + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 0, + "fillOpacity": 70, + "spanNulls": false, + "insertNulls": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "axisPlacement": "auto" + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "fieldMinMax": false + }, + "overrides": [] + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "scenarioId": "random_walk", + "refId": "A", + "seriesCount": 0 + } + ], + "datasource": { + "type": "grafana-testdata-datasource" + }, + "options": { + "mergeValues": true, + "showValue": "auto", + "alignValue": "center", + "rowHeight": 0.9, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none", + "hideZeros": false + } + } + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 41, + "tags": ["gdev", "panel-tests", "state-timeline", "graph-ng"], + "templating": { + "list": [] + }, + "time": { + "from": "2023-01-26T11:29:47.180Z", + "to": "2023-01-26T16:29:39.205Z" + }, + "timepicker": {}, + "timezone": "utc", + "title": "StatusHistory - Thresholds & Mappings", + "uid": "a2f4ad9e-3b44-4624-8067-35f31be5d309", + "version": 1 +} diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 80be668ecc4..6078526e079 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -96,6 +96,7 @@ "rows-to-fields": (import '../dev-dashboards/transforms/rows-to-fields.json'), "shared_queries": (import '../dev-dashboards/panel-common/shared_queries.json'), "slow_queries_and_annotations": (import '../dev-dashboards/scenarios/slow_queries_and_annotations.json'), + "status-history-thresholds-mappings": (import '../dev-dashboards/panel-status-history/status-history-thresholds-mappings.json'), "table_footer": (import '../dev-dashboards/panel-table/table_footer.json'), "table_kitchen_sink": (import '../dev-dashboards/panel-table/table_kitchen_sink.json'), "table_markdown": (import '../dev-dashboards/panel-table/table_markdown.json'), diff --git a/e2e-playwright/panels-suite/status-history.spec.ts b/e2e-playwright/panels-suite/status-history.spec.ts new file mode 100644 index 00000000000..e6c7cccac67 --- /dev/null +++ b/e2e-playwright/panels-suite/status-history.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'a2f4ad9e-3b44-4624-8067-35f31be5d309'; + +test.use({ + viewport: { width: 1280, height: 2000 }, +}); + +test.describe('Panels test: StatusHistory', { tag: ['@panels', '@status-history'] }, () => { + test('renders successfully', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + }); + + // check that gauges are rendered + const statusHistoryUplot = page.locator('.uplot'); + await expect(statusHistoryUplot, 'panels are rendered').toHaveCount(11); + + // check that no panel errors exist + const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error')); + await expect(errorInfo, 'no errors in the panels').toBeHidden(); + }); + + test('"no data"', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '15' }), + }); + + const statusHistoryUplot = page.locator('.uplot'); + await expect(statusHistoryUplot, "that uplot doesn't appear").toBeHidden(); + + const emptyMessage = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage); + await expect(emptyMessage, 'that the empty text appears').toHaveText('No data'); + + // update the "No value" option and see if the panel updates + const noValueOption = dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value')) + .locator('input'); + + await noValueOption.fill('My empty value'); + await noValueOption.blur(); + await expect(emptyMessage, 'that the empty text has changed').toHaveText('My empty value'); + }); + + test('tooltip interactions', async ({ gotoDashboardPage, page, selectors }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '13' }), + }); + + const statusHistoryUplot = page.locator('.uplot'); + await expect(statusHistoryUplot, 'uplot is rendered').toBeVisible(); + + const tooltip = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Tooltip.Wrapper); + + // hover over a spot to trigger the tooltip + await statusHistoryUplot.hover({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip appears on hover').toBeVisible(); + await expect(tooltip, 'tooltip displays the value').toContainText('value5'); + + // click to pin the tooltip, hover away to be sure it's pinned + await statusHistoryUplot.click({ position: { x: 100, y: 50 } }); + await statusHistoryUplot.hover({ position: { x: 300, y: 50 } }); + await expect(tooltip, 'tooltip pinned on click').toBeVisible(); + await expect(tooltip, 'tooltip displays the first value').toContainText('value5'); + + // unpin the tooltip, ensure it closes on hover away + await statusHistoryUplot.click({ position: { x: 300, y: 50 } }); + await statusHistoryUplot.blur(); + await expect(tooltip, 'tooltip closed after unpinning and hovering away').toBeHidden(); + + // test clicking the "x" as well + await statusHistoryUplot.click({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip appears on click').toBeVisible(); + await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click(); + await expect(tooltip, 'tooltip closed on "x" click').toBeHidden(); + + // disable tooltips + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode')) + .getByLabel('Hidden') + .click(); + await statusHistoryUplot.hover({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden(); + }); +}); From 4cecab3185167e59e6e4da3a1e57ce0b2b6bdb72 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 4 Nov 2025 23:57:05 +0100 Subject: [PATCH 014/209] Dashboards: add isPublic to dto and remove public endpoint call (#113334) --------- Co-authored-by: Matheus Macabu --- apps/dashboard/pkg/apis/dashboard/types.go | 5 ++- .../pkg/apis/dashboard/v0alpha1/types.go | 5 ++- .../v0alpha1/zz_generated.conversion.go | 2 + .../v0alpha1/zz_generated.openapi.go | 9 ++++- .../pkg/apis/dashboard/v1beta1/types.go | 5 ++- .../v1beta1/zz_generated.conversion.go | 2 + .../dashboard/v1beta1/zz_generated.openapi.go | 9 ++++- .../pkg/apis/dashboard/v2alpha1/types.go | 5 ++- .../v2alpha1/zz_generated.conversion.go | 2 + .../v2alpha1/zz_generated.openapi.go | 9 ++++- .../pkg/apis/dashboard/v2beta1/types.go | 5 ++- .../v2beta1/zz_generated.conversion.go | 2 + .../dashboard/v2beta1/zz_generated.openapi.go | 9 ++++- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 1 + .../dashboard.grafana.app/v1beta1/handlers.ts | 1 + pkg/registry/apis/dashboard/register.go | 5 +++ pkg/registry/apis/dashboard/sub_dto.go | 37 ++++++++++++------- pkg/server/wire_gen.go | 4 +- .../dashboard.grafana.app-v0alpha1.json | 5 +++ .../dashboard.grafana.app-v1beta1.json | 5 +++ .../dashboard.grafana.app-v2alpha1.json | 5 +++ .../actions/PublicDashboardBadge.tsx | 17 +++++++-- .../transformSaveModelSchemaV2ToScene.ts | 1 + .../dashboard/api/ResponseTransformers.ts | 2 + public/app/features/dashboard/api/types.ts | 1 + public/app/features/dashboard/api/v1.ts | 1 + 26 files changed, 120 insertions(+), 34 deletions(-) diff --git a/apps/dashboard/pkg/apis/dashboard/types.go b/apps/dashboard/pkg/apis/dashboard/types.go index 29d6f8458e3..e204bee7f37 100644 --- a/apps/dashboard/pkg/apis/dashboard/types.go +++ b/apps/dashboard/pkg/apis/dashboard/types.go @@ -3,8 +3,9 @@ package dashboard // Information about how the requesting user can use a given dashboard type DashboardAccess struct { // Metadata fields - Slug string `json:"slug,omitempty"` - Url string `json:"url,omitempty"` + Slug string `json:"slug,omitempty"` + Url string `json:"url,omitempty"` + IsPublic bool `json:"isPublic"` // The permissions part CanSave bool `json:"canSave"` diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go index 7f813a12a1f..d0b222ac7d2 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go @@ -12,8 +12,9 @@ type DashboardWithAccessInfo struct { // +k8s:deepcopy-gen=true type DashboardAccess struct { // Metadata fields - Slug string `json:"slug,omitempty"` - Url string `json:"url,omitempty"` + Slug string `json:"slug,omitempty"` + Url string `json:"url,omitempty"` + IsPublic bool `json:"isPublic"` // The permissions part CanSave bool `json:"canSave"` diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.conversion.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.conversion.go index 626201ec314..48869b1c0e8 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.conversion.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.conversion.go @@ -112,6 +112,7 @@ func Convert_dashboard_AnnotationPermission_To_v0alpha1_AnnotationPermission(in func autoConvert_v0alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin @@ -129,6 +130,7 @@ func Convert_v0alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *Dashboard func autoConvert_dashboard_DashboardAccess_To_v0alpha1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go index 1e81a87f2fa..b4a69d0031d 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go @@ -170,6 +170,13 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardAccess(ref common.ReferenceCall Format: "", }, }, + "isPublic": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, "canSave": { SchemaProps: spec.SchemaProps{ Description: "The permissions part", @@ -212,7 +219,7 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardAccess(ref common.ReferenceCall }, }, }, - Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, + Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, }, }, Dependencies: []string{ diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/types.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/types.go index ee4125d8f6a..285c3661980 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/types.go @@ -123,8 +123,9 @@ type DashboardWithAccessInfo struct { // +k8s:deepcopy-gen=true type DashboardAccess struct { // Metadata fields - Slug string `json:"slug,omitempty"` - Url string `json:"url,omitempty"` + Slug string `json:"slug,omitempty"` + Url string `json:"url,omitempty"` + IsPublic bool `json:"isPublic"` // The permissions part CanSave bool `json:"canSave"` diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.conversion.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.conversion.go index e3f1f46785b..b8f76f5deb9 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.conversion.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.conversion.go @@ -118,6 +118,7 @@ func Convert_dashboard_AnnotationPermission_To_v1beta1_AnnotationPermission(in * func autoConvert_v1beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin @@ -135,6 +136,7 @@ func Convert_v1beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardA func autoConvert_dashboard_DashboardAccess_To_v1beta1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go index be747be2ae3..3e2bdeab5f8 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go @@ -165,6 +165,13 @@ func schema_pkg_apis_dashboard_v1beta1_DashboardAccess(ref common.ReferenceCallb Format: "", }, }, + "isPublic": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, "canSave": { SchemaProps: spec.SchemaProps{ Description: "The permissions part", @@ -207,7 +214,7 @@ func schema_pkg_apis_dashboard_v1beta1_DashboardAccess(ref common.ReferenceCallb }, }, }, - Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, + Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, }, }, Dependencies: []string{ diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/types.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/types.go index d3599063b7a..c48e2d00602 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/types.go @@ -123,8 +123,9 @@ type DashboardWithAccessInfo struct { // +k8s:deepcopy-gen=true type DashboardAccess struct { // Metadata fields - Slug string `json:"slug,omitempty"` - Url string `json:"url,omitempty"` + Slug string `json:"slug,omitempty"` + Url string `json:"url,omitempty"` + IsPublic bool `json:"isPublic"` // The permissions part CanSave bool `json:"canSave"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.conversion.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.conversion.go index fe4c7479194..0ac74fef54e 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.conversion.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.conversion.go @@ -118,6 +118,7 @@ func Convert_dashboard_AnnotationPermission_To_v2alpha1_AnnotationPermission(in func autoConvert_v2alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin @@ -135,6 +136,7 @@ func Convert_v2alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *Dashboard func autoConvert_dashboard_DashboardAccess_To_v2alpha1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index 548a8c93268..f5db9945589 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -265,6 +265,13 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref common.ReferenceCall Format: "", }, }, + "isPublic": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, "canSave": { SchemaProps: spec.SchemaProps{ Description: "The permissions part", @@ -307,7 +314,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref common.ReferenceCall }, }, }, - Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, + Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, }, }, Dependencies: []string{ diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/types.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/types.go index 8ecda3cfd7d..07a0cf6da4f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/types.go @@ -123,8 +123,9 @@ type DashboardWithAccessInfo struct { // +k8s:deepcopy-gen=true type DashboardAccess struct { // Metadata fields - Slug string `json:"slug,omitempty"` - Url string `json:"url,omitempty"` + Slug string `json:"slug,omitempty"` + Url string `json:"url,omitempty"` + IsPublic bool `json:"isPublic"` // The permissions part CanSave bool `json:"canSave"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go index fce43178b8a..9d7ab0b1ee2 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.conversion.go @@ -118,6 +118,7 @@ func Convert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission(in * func autoConvert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin @@ -135,6 +136,7 @@ func Convert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardA func autoConvert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error { out.Slug = in.Slug out.Url = in.Url + out.IsPublic = in.IsPublic out.CanSave = in.CanSave out.CanEdit = in.CanEdit out.CanAdmin = in.CanAdmin diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index f8108887a27..5c0c44e07c3 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -269,6 +269,13 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref common.ReferenceCallb Format: "", }, }, + "isPublic": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, "canSave": { SchemaProps: spec.SchemaProps{ Description: "The permissions part", @@ -311,7 +318,7 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref common.ReferenceCallb }, }, }, - Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, + Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"}, }, }, Dependencies: []string{ diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 6ca3505d067..4d23ca92514 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -845,6 +845,7 @@ export type DashboardAccess = { /** The permissions part */ canSave: boolean; canStar: boolean; + isPublic: boolean; /** Metadata fields */ slug?: string; url?: string; diff --git a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts index 0c9c364780d..251ca44e766 100644 --- a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts @@ -27,6 +27,7 @@ const dashboardToAppPlatform = (dashboard: (typeof mockTree)[number]['item']) => }, status: {}, // TODO: Eventually add access properties, as required by tests + access: {}, }; }; diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index b21877720cd..cb351569358 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -53,6 +53,7 @@ import ( "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" + "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/user" @@ -112,6 +113,7 @@ type DashboardsAPIBuilder struct { dualWriter dualwrite.Service folderClientProvider client.K8sHandlerProvider libraryPanels libraryelements.Service // for legacy library panels + publicDashboardService publicdashboards.Service isStandalone bool // skips any handling including anything to do with legacy storage } @@ -140,6 +142,7 @@ func RegisterAPIService( restConfigProvider apiserver.RestConfigProvider, userService user.Service, libraryPanels libraryelements.Service, + publicDashboardService publicdashboards.Service, ) *DashboardsAPIBuilder { dbp := legacysql.NewDatabaseProvider(sql) namespacer := request.GetNamespaceMapper(cfg) @@ -163,6 +166,7 @@ func RegisterAPIService( dualWriter: dual, folderClientProvider: newSimpleFolderClientProvider(folderClient), libraryPanels: libraryPanels, + publicDashboardService: publicDashboardService, legacy: &DashboardStorage{ Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features), @@ -652,6 +656,7 @@ func (b *DashboardsAPIBuilder) storageForVersion( b.accessControl, opts.Scheme, newDTOFunc, + b.publicDashboardService, ) if err != nil { return err diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go index 30e58e0d22e..f2e9bc0b6df 100644 --- a/pkg/registry/apis/dashboard/sub_dto.go +++ b/pkg/registry/apis/dashboard/sub_dto.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -28,13 +29,14 @@ type dtoBuilder = func(dashboard runtime.Object, access *dashboard.DashboardAcce // The DTO returns everything the UI needs in a single request type DTOConnector struct { - getter rest.Getter - legacy legacy.DashboardAccess - unified resource.ResourceClient - largeObjects apistore.LargeObjectSupport - accessControl accesscontrol.AccessControl - scheme *runtime.Scheme - builder dtoBuilder + getter rest.Getter + legacy legacy.DashboardAccess + unified resource.ResourceClient + largeObjects apistore.LargeObjectSupport + accessControl accesscontrol.AccessControl + scheme *runtime.Scheme + builder dtoBuilder + publicDashboardService publicdashboards.Service } func NewDTOConnector( @@ -45,15 +47,17 @@ func NewDTOConnector( accessControl accesscontrol.AccessControl, scheme *runtime.Scheme, builder dtoBuilder, + publicDashboardService publicdashboards.Service, ) (rest.Storage, error) { return &DTOConnector{ - getter: getter, - legacy: legacyAccess, - accessControl: accessControl, - unified: resourceClient, - largeObjects: largeObjects, - builder: builder, - scheme: scheme, + getter: getter, + legacy: legacyAccess, + accessControl: accessControl, + unified: resourceClient, + largeObjects: largeObjects, + builder: builder, + scheme: scheme, + publicDashboardService: publicDashboardService, }, nil } @@ -154,6 +158,11 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob access.Slug = slugify.Slugify(title) access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug) + pubDash, err := r.publicDashboardService.FindByDashboardUid(ctx, user.GetOrgID(), name) + if err == nil && pubDash != nil { + access.IsPublic = true + } + dash, err := r.builder(rawobj, access) if err != nil { responder.Error(err) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 42ef5427c90..417b4aeeb4d 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -840,7 +840,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer) if err != nil { @@ -1474,7 +1474,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer) if err != nil { diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index b9c3ee36a91..e53e6509bc1 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -2324,6 +2324,7 @@ "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v0alpha1.DashboardAccess": { "type": "object", "required": [ + "isPublic", "canSave", "canEdit", "canAdmin", @@ -2356,6 +2357,10 @@ "type": "boolean", "default": false }, + "isPublic": { + "type": "boolean", + "default": false + }, "slug": { "description": "Metadata fields", "type": "string" diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json index 4ae094ff867..363af97fb34 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json @@ -1018,6 +1018,7 @@ "description": "Information about how the requesting user can use a given dashboard", "type": "object", "required": [ + "isPublic", "canSave", "canEdit", "canAdmin", @@ -1050,6 +1051,10 @@ "type": "boolean", "default": false }, + "isPublic": { + "type": "boolean", + "default": false + }, "slug": { "description": "Metadata fields", "type": "string" diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index d8962a72a5d..56d972f6514 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -1019,6 +1019,7 @@ "description": "Information about how the requesting user can use a given dashboard", "type": "object", "required": [ + "isPublic", "canSave", "canEdit", "canAdmin", @@ -1051,6 +1052,10 @@ "type": "boolean", "default": false }, + "isPublic": { + "type": "boolean", + "default": false + }, "slug": { "description": "Metadata fields", "type": "string" diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx index 58df0c52c7e..1687e099574 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx @@ -13,17 +13,26 @@ export const PublicDashboardBadge = ({ dashboard }: ToolbarActionProps) => { return null; } - return ; + return ( + + ); }; // Used in old architecture export const PublicDashboardBadgeLegacy = PublicDashboardBadgeInternal; -function PublicDashboardBadgeInternal({ uid }: { uid: string }) { - const { data: publicDashboard } = useGetPublicDashboardQuery(uid); +function PublicDashboardBadgeInternal({ uid, hasPublicDashboard }: { uid: string; hasPublicDashboard?: boolean }) { + const { data: publicDashboard } = useGetPublicDashboardQuery(uid, { + skip: hasPublicDashboard !== undefined && !hasPublicDashboard, + }); const styles = useStyles2(getStyles); - if (!publicDashboard) { + const showBadge = hasPublicDashboard !== undefined ? hasPublicDashboard : !!publicDashboard; + + if (!showBadge) { return null; } diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 2a2f96caa30..490429f5712 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -148,6 +148,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo extends Resource { k8s: dash.metadata, version: dash.metadata.generation, created: dash.metadata.creationTimestamp, + publicDashboardEnabled: dash.access.isPublic, }, dashboard: { ...dash.spec, From e067b1de98f564045dfbb726546ef06957eeb3a6 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Tue, 4 Nov 2025 21:39:46 -0400 Subject: [PATCH 015/209] FeatureToggle: Create experimental `timeRangePan` flag (#112988) feat(toggle): new experimental timeRangePan feature toggle --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 16 ++++++++++++++++ 5 files changed, 32 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index eeddfb65a7b..14148797750 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -729,6 +729,10 @@ export interface FeatureToggles { */ timeRangeProvider?: boolean; /** + * Enables time range panning functionality + */ + timeRangePan?: boolean; + /** * Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. * @default false */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 6f6b6395b5e..be79b887b14 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1254,6 +1254,13 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, }, + { + Name: "timeRangePan", + Description: "Enables time range panning functionality", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, { Name: "azureMonitorDisableLogLimit", Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b317a33e91e..160802fc2db 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -164,6 +164,7 @@ managedDualWriter,experimental,@grafana/search-and-storage,false,false,false pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false +timeRangePan,experimental,@grafana/dataviz-squad,false,false,true azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c470fb6bce3..a82856cd292 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -667,6 +667,10 @@ const ( // Enables time pickers sync FlagTimeRangeProvider = "timeRangeProvider" + // FlagTimeRangePan + // Enables time range panning functionality + FlagTimeRangePan = "timeRangePan" + // FlagAzureMonitorDisableLogLimit // Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. FlagAzureMonitorDisableLogLimit = "azureMonitorDisableLogLimit" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 5d6688fb853..ed9c80b988f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3915,6 +3915,22 @@ "frontend": true } }, + { + "metadata": { + "name": "timeRangePan", + "resourceVersion": "1762290731154", + "creationTimestamp": "2025-10-24T19:49:53Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-04 21:12:11.154822 +0000 UTC" + } + }, + "spec": { + "description": "Enables time range panning functionality", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, { "metadata": { "name": "timeRangeProvider", From 891ed6c4b77ad06236659c16a4141fb92659f22e Mon Sep 17 00:00:00 2001 From: Anna Urbiztondo Date: Wed, 5 Nov 2025 09:38:48 +0100 Subject: [PATCH 016/209] Docs: Git Sync permissions (#113405) * Permissions * Prettier * Edit --- .../provision-resources/intro-git-sync.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 5a1a32339ee..5b1b1809463 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 @@ -10,6 +10,12 @@ labels: - enterprise - oss - cloud +refs: + roles-and-permissions: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/roles-and-permissions/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/account-management/authentication-and-permissions/cloud-roles/ title: Git Sync weight: 100 --- @@ -84,6 +90,11 @@ Refer to [Requirements](https://grafana.com/docs/grafana//obser - You can only authenticate in GitHub using your Personal Access Token token. +**Permission management** + +- You cannot modify the permissions of a provisioned folder after you've synced it. +- Default permissions are: Admin = Admin, Editor = Editor, and Viewer = Viewer. Refer to [Roles and permissions](ref:roles-and-permissions) for more information. + **Compatibility** - Support for native Git, Git app, and other providers, such as GitLab or Bitbucket, is on the roadmap. From 1d38cf7f0dfceb4e11b531c25097c42275592f4c Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:46:41 +0000 Subject: [PATCH 017/209] Alerting: Add empty state to triage page WIP (#113390) * add empty state to triage page WIP * tidy up and refactor * generate translations * resolve PR comments * generate translations * resolve PR comment part 2 --- .../alerting/unified/triage/Workbench.tsx | 86 ++++++++++++------- .../unified/triage/scene/Workbench.tsx | 12 ++- public/locales/en-US/grafana.json | 2 + 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx index 64e39d64775..1d28aa3e285 100644 --- a/public/app/features/alerting/unified/triage/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -4,8 +4,9 @@ import { useState } from 'react'; import { useMeasure } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { SceneQueryRunner } from '@grafana/scenes'; -import { ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui'; +import { Box, EmptyState, ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui'; import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; import LoadMoreHelper from '../rule-list/LoadMoreHelper'; @@ -26,6 +27,7 @@ type WorkbenchProps = { groupBy?: string[]; filterBy?: Filter[]; queryRunner: SceneQueryRunner; + hasActiveFilters?: boolean; }; const initialSize = 1 / 3; @@ -116,7 +118,7 @@ function renderWorkbenchRow( │ │ │ │ └─────────────────────────┘ └───────────────────────────────────┘ */ -export function Workbench({ domain, data, queryRunner, groupBy }: WorkbenchProps) { +export function Workbench({ domain, data, queryRunner, groupBy, hasActiveFilters = false }: WorkbenchProps) { const styles = useStyles2(getStyles); const isLoading = !queryRunner.isDataReadyToDisplay(); @@ -124,6 +126,10 @@ export function Workbench({ domain, data, queryRunner, groupBy }: WorkbenchProps // Calculate once: show folder metadata only if not grouping by grafana_folder const enableFolderMeta = !groupBy?.includes('grafana_folder'); + + // Determine UI state + const showEmptyState = !isLoading && data.length === 0; + const showData = !isLoading && data.length > 0; // splitter for template and payload editor const splitter = useSplitter({ direction: 'row', @@ -149,42 +155,62 @@ export function Workbench({ domain, data, queryRunner, groupBy }: WorkbenchProps
-
+ {!showEmptyState &&
}
{/* content goes here */}
-
- - -
- {/* Render actual data */} -
- - - {isLoading ? ( - <> - - - - + {showEmptyState ? ( + + + {hasActiveFilters ? ( + + No alert instances match your current set of filters for the selected time range. + ) : ( - dataSlice.map((row, index) => { - const rowKey = generateRowKey(row, index); - return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey, enableFolderMeta); - }) + + You have no alert instances in a firing or pending state for the selected time range. + )} - {hasMore && setPageIndex((prevIndex) => prevIndex + 1)} />} - - -
+ + + ) : ( + <> +
+ + +
+
+ + + {isLoading && ( + <> + + + + + )} + {showData && + dataSlice.map((row, index) => { + const rowKey = generateRowKey(row, index); + return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey, enableFolderMeta); + })} + {hasMore && setPageIndex((prevIndex) => prevIndex + 1)} />} + + +
+ + )}
); diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx index d83908c2e91..7df0b354c13 100644 --- a/public/app/features/alerting/unified/triage/scene/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -34,7 +34,17 @@ export function WorkbenchRenderer() { const { data } = runner.useState(); const rows = data ? convertToWorkbenchRows(data, groupByKeys) : []; - return ; + const hasFiltersApplied = queryFilter.length > 0; + + return ( + + ); } type DataPoint = Record, string> & Record; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d95bf14de31..d185a8ca321 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2948,8 +2948,10 @@ "instance-details-drawer": { "instance-details": "Instance details" }, + "no-firing-or-pending-instances": "You have no alert instances in a firing or pending state for the selected time range.", "no-instances-found": "No alert instances found for rule: {{ruleUID}}", "no-labels": "No labels", + "no-matching-instances-with-filters": "No alert instances match your current set of filters for the selected time range.", "open-in-sidebar": "Open in sidebar", "open-rule-details": "Open rule details", "rule-details": { From 98ec655f3304434bc0343dff891c977fb1c087cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Wed, 5 Nov 2025 10:12:44 +0100 Subject: [PATCH 018/209] fix: add resource type to not empty log (#113432) --- pkg/registry/apis/folders/validate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index 53c32a356fe..f63583b872c 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -146,7 +146,7 @@ func validateOnDelete(ctx context.Context, for _, v := range resp.Stats { if v.Count > 0 { - return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d resources", v.Count) + return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s.%s", v.Count, v.Group, v.Resource) } } return nil From 1bd5b2996344fafe81d498571f9c1ea288b12b83 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:24:48 +0000 Subject: [PATCH 019/209] Alerting: Bug fix for regex matching in Alerts page (#113400) * Alerting: bug fix for regex matching in Alerts page * remove test --- .../unified/triage/scene/TriageScene.tsx | 2 + .../triage/scene/expressionBuilder.test.ts | 121 ++++++++++++++++++ .../unified/triage/scene/expressionBuilder.ts | 73 +++++++++++ 3 files changed, 196 insertions(+) create mode 100644 public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts create mode 100644 public/app/features/alerting/unified/triage/scene/expressionBuilder.ts diff --git a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx index 0ac90d8a066..d5c7c96d71b 100644 --- a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx +++ b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx @@ -16,6 +16,7 @@ import { EmbeddedSceneWithContext } from '@grafana/scenes-react'; import { DATASOURCE_UID } from '../constants'; import { WorkbenchSceneObject } from './Workbench'; +import { prometheusExpressionBuilder } from './expressionBuilder'; import { defaultTimeRange } from './utils'; const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair }); @@ -56,6 +57,7 @@ export const triageScene = new EmbeddedSceneWithContext({ filters: [], baseFilters: [], layout: 'combobox', + expressionBuilder: prometheusExpressionBuilder, }), ], }), diff --git a/public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts b/public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts new file mode 100644 index 00000000000..5ee5055eab6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts @@ -0,0 +1,121 @@ +import { AdHocFilterWithLabels } from '@grafana/scenes'; + +import { prometheusExpressionBuilder } from './expressionBuilder'; + +describe('prometheusExpressionBuilder', () => { + it('should handle exact match operators', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=', value: 'foo' }]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo"'); + }); + + it('should handle exact not-match operators', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '!=', value: 'foo' }]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname!="foo"'); + }); + + it('should handle regex match operator without escaping metacharacters', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo.*' }]; + // Should NOT escape the .* regex pattern + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo.*"'); + }); + + it('should handle regex match with complex patterns', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'test[0-9]+' }]; + // Should preserve the regex pattern + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"test[0-9]+"'); + }); + + it('should handle regex not-match operator without escaping metacharacters', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '!~', value: 'foo.*' }]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname!~"foo.*"'); + }); + + it('should escape quotes in regex values', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo"bar.*' }]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo\\"bar.*"'); + }); + + it('should escape backslashes in regex values', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo\\bar.*' }]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo\\\\bar.*"'); + }); + + it('should handle multi-value equals operator', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'alertname', operator: '=|', value: 'foo', values: ['foo', 'bar', 'baz'] }, + ]; + // Multi-value should escape regex metacharacters since we're building literal matches + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo|bar|baz"'); + }); + + it('should handle multi-value not-equals operator', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'alertname', operator: '!=|', value: 'foo', values: ['foo', 'bar', 'baz'] }, + ]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname!~"foo|bar|baz"'); + }); + + it('should escape metacharacters in multi-value operators', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'alertname', operator: '=|', value: 'foo.*', values: ['foo.*', 'bar+'] }, + ]; + // These should be escaped because we want literal matches + // The backslashes themselves are escaped in the string literal + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo\\\\.\\\\*|bar\\\\+"'); + }); + + it('should handle multiple filters', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'alertname', operator: '=~', value: 'foo.*' }, + { key: 'severity', operator: '=', value: 'critical' }, + { key: 'team', operator: '!=', value: 'test' }, + ]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo.*",severity="critical",team!="test"'); + }); + + it('should filter out non-applicable filters', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'alertname', operator: '=', value: 'foo' }, + { key: 'severity', operator: '=', value: 'critical', nonApplicable: true }, + ]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo"'); + }); + + it('should filter out hidden filters', () => { + const filters: AdHocFilterWithLabels[] = [ + { key: 'alertname', operator: '=', value: 'foo' }, + { key: 'severity', operator: '=', value: 'critical', hidden: true }, + ]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo"'); + }); + + it('should escape special characters in exact match values', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=', value: 'foo"bar\nbaz' }]; + expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo\\"bar\\nbaz"'); + }); + + it('should handle empty filter array', () => { + const filters: AdHocFilterWithLabels[] = []; + expect(prometheusExpressionBuilder(filters)).toBe(''); + }); + + describe('reported bug test cases', () => { + it('should match alerts starting with foo using foo.*', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo.*' }]; + // This should produce a valid regex that matches anything starting with foo + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo.*"'); + }); + + it('should match exact alert with regex operator', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'fooalert' }]; + // This should work for exact matches too (regex matching literal string) + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"fooalert"'); + }); + + it('should handle foo as a prefix pattern', () => { + const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo' }]; + // Just 'foo' as a regex should match anything containing 'foo' + expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo"'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/triage/scene/expressionBuilder.ts b/public/app/features/alerting/unified/triage/scene/expressionBuilder.ts new file mode 100644 index 00000000000..ff3d01f73e4 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/expressionBuilder.ts @@ -0,0 +1,73 @@ +import { AdHocFilterWithLabels } from '@grafana/scenes'; + +/** + * Custom expression builder for Prometheus that properly handles regex operators. + * Unlike the default builder, this doesn't escape regex metacharacters when using =~ or !~ + * operators, allowing users to enter raw regex patterns. + */ +export function prometheusExpressionBuilder(filters: AdHocFilterWithLabels[]): string { + const applicableFilters = filters.filter((f) => !f.nonApplicable && !f.hidden); + return applicableFilters.map(renderFilter).join(','); +} + +// Map multi-value operators to their regex equivalents +const MULTI_VALUE_OPERATOR_MAP: Record = { + '=|': '=~', + '!=|': '!~', +}; + +function renderFilter(filter: AdHocFilterWithLabels): string { + const { key, operator: rawOperator, value, values } = filter; + + // Transform multi-value operators to regex operators + const operator = MULTI_VALUE_OPERATOR_MAP[rawOperator] ?? rawOperator; + + // Determine the escaped value based on operator type + const escapedValue = getEscapedValue(rawOperator, value, values); + + return `${key}${operator}"${escapedValue}"`; +} + +function getEscapedValue(operator: string, value: string | undefined, values: string[] | undefined): string { + // Multi-value operators: escape each value as literal and join with | + if (operator === '=|' || operator === '!=|') { + return values?.map(escapeAsLiteral).join('|') ?? ''; + } + + // Regex operators: preserve regex metacharacters + if (operator === '=~' || operator === '!~') { + return escapeStringLiteral(value); + } + + // Exact match operators: escape string literal only + return escapeStringLiteral(value); +} + +/** + * Escapes a value for use in PromQL string literals. + * Only escapes backslashes, newlines, and double quotes. + * Does NOT escape regex metacharacters. + */ +function escapeStringLiteral(value: string | undefined): string { + if (!value) { + return ''; + } + return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); +} + +/** + * Escapes a value for literal matching in multi-value selectors. + * Escapes both string literal characters AND regex metacharacters. + */ +function escapeAsLiteral(value: string): string { + return escapeStringLiteral(escapeRegexMetacharacters(value)); +} + +/** + * Escapes regex metacharacters for literal matching. + * Used when building multi-value selectors where each value should be matched literally. + */ +const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g; +function escapeRegexMetacharacters(value: string): string { + return value.replace(RE2_METACHARACTERS, '\\$&'); +} From 2e507d50422b00a2a73e4446445cdec5a0fee6b6 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 5 Nov 2025 10:33:45 +0100 Subject: [PATCH 020/209] Advisor: Add mock checks to standalone setup (#113406) --- apps/advisor/Makefile | 17 +++ apps/advisor/README.md | 14 +++ .../checkregistry/mockchecks/checkregistry.go | 51 +++++++- .../mockchecks/mocksvcs/datasourcesvc.go | 44 +++++++ .../mockchecks/mocksvcs/pluginclient.go | 19 +++ .../mocksvcs/plugincontextprovider.go | 53 ++++++++ .../mocksvcs/pluginerrorresolver.go | 19 +++ .../mockchecks/mocksvcs/pluginrepo.go | 26 ++++ .../mockchecks/mocksvcs/pluginstore.go | 114 ++++++++++++++++++ .../mockchecks/mocksvcs/updatechecker.go | 18 +++ .../pkg/app/checks/datasourcecheck/check.go | 6 +- .../datasourcecheck/health_check_step.go | 2 +- apps/advisor/pkg/standalone/server.go | 2 +- 13 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go create mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go diff --git a/apps/advisor/Makefile b/apps/advisor/Makefile index 010b283b65a..7ea492797fe 100644 --- a/apps/advisor/Makefile +++ b/apps/advisor/Makefile @@ -15,3 +15,20 @@ generate: install-app-sdk update-app-sdk .PHONY: run run: @go run ./pkg/standalone/server.go --etcd-servers=http://127.0.0.1:22379 --secure-port 7445 + +.PHONY: create-checks +create-checks: + @echo "Creating plugin check..." + @curl -k -X POST https://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks \ + -H "Content-Type: application/json" \ + -d '{"kind":"Check","apiVersion":"advisor.grafana.app/v0alpha1","spec":{"data":{}},"metadata":{"generateName":"check-","labels":{"advisor.grafana.app/type":"plugin"},"namespace":"stacks-1"},"status":{"report":{"count":0,"failures":[]}}}' \ + && echo "Plugin check created successfully" + @echo "Creating datasource check..." + @curl -k -X POST https://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks \ + -H "Content-Type: application/json" \ + -d '{"kind":"Check","apiVersion":"advisor.grafana.app/v0alpha1","spec":{"data":{}},"metadata":{"generateName":"check-","labels":{"advisor.grafana.app/type":"datasource"},"namespace":"stacks-1"},"status":{"report":{"count":0,"failures":[]}}}' \ + && echo "Datasource check created successfully" + +delete-checks: + @curl -k -X DELETE https://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks \ + && echo "All checks deleted successfully" diff --git a/apps/advisor/README.md b/apps/advisor/README.md index aa48ef8e4e4..410a54f89e3 100644 --- a/apps/advisor/README.md +++ b/apps/advisor/README.md @@ -163,3 +163,17 @@ make run # Start the advisor app in standalone mode ``` This will start the advisor app on port 7445. You can then access the advisor app at `http://localhost:7445`. + +To see some sample checks, you can run the following command: + +```bash +make create-checks +``` + +Then you can see list in the URL: `http://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks` + +To delete all checks, you can run the following command: + +```bash +make delete-checks +``` diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go b/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go index b751b606cd8..e1b9cf88bf4 100644 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go @@ -1,12 +1,59 @@ package mockchecks -import "github.com/grafana/grafana/apps/advisor/pkg/app/checks" +import ( + "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs" + "github.com/grafana/grafana/apps/advisor/pkg/app/checks" + "github.com/grafana/grafana/apps/advisor/pkg/app/checks/datasourcecheck" + "github.com/grafana/grafana/apps/advisor/pkg/app/checks/plugincheck" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/repo" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) // mockchecks.CheckRegistry is a mock implementation of the checkregistry.CheckService interface // TODO: Add mocked checks here type CheckRegistry struct { + datasourceSvc datasources.DataSourceService + pluginStore pluginstore.Store + pluginClient plugins.Client + pluginRepo repo.Service + GrafanaVersion string + pluginContextProvider datasourcecheck.PluginContextProvider + updateChecker pluginchecker.PluginUpdateChecker + pluginErrorResolver plugins.ErrorResolver } func (m *CheckRegistry) Checks() []checks.Check { - return []checks.Check{} + return []checks.Check{ + datasourcecheck.New( + m.datasourceSvc, + m.pluginStore, + m.pluginContextProvider, + m.pluginClient, + m.pluginRepo, + m.GrafanaVersion, + ), + plugincheck.New( + m.pluginStore, + m.pluginRepo, + m.updateChecker, + m.pluginErrorResolver, + m.GrafanaVersion, + ), + } +} + +func New() *CheckRegistry { + return &CheckRegistry{ + datasourceSvc: &mocksvcs.DatasourceSvc{}, + pluginStore: &mocksvcs.PluginStore{}, + pluginClient: &mocksvcs.PluginClient{}, + pluginRepo: &mocksvcs.PluginRepo{}, + pluginContextProvider: &mocksvcs.PluginContextProvider{}, + updateChecker: &mocksvcs.UpdateChecker{}, + pluginErrorResolver: &mocksvcs.PluginErrorResolver{}, + GrafanaVersion: "1.0.0", + } } diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go new file mode 100644 index 00000000000..73122e53adb --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go @@ -0,0 +1,44 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/datasources" +) + +var dss = map[string]*datasources.DataSource{ + "prometheus-uid": { + ID: 1, + UID: "prometheus-uid", + Name: "Prometheus", + Type: "prometheus", + }, + "mysql-uid": { + ID: 2, + UID: "mysql-uid", + Name: "MySQL", + Type: "mysql", + }, + "unknown-uid": { + ID: 3, + UID: "unknown-uid", + Name: "Unknown", + Type: "unknown", + }, +} + +type DatasourceSvc struct { + datasources.DataSourceService +} + +func (m *DatasourceSvc) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) { + sources := make([]*datasources.DataSource, 0, len(dss)) + for _, ds := range dss { + sources = append(sources, ds) + } + return sources, nil +} + +func (m *DatasourceSvc) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { + return dss[query.UID], nil +} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go new file mode 100644 index 00000000000..a8ce175a687 --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go @@ -0,0 +1,19 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/plugins" +) + +type PluginClient struct { + plugins.Client +} + +func (m *PluginClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusOk, + Message: "Plugin is healthy", + }, nil +} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go new file mode 100644 index 00000000000..b5bb6f88e8e --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go @@ -0,0 +1,53 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/datasources" +) + +type PluginContextProvider struct { +} + +// ACTUALLY USED by datasourcecheck +func (m *PluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) { + // Create a plugin context with sample data based on the datasource + pluginContext := backend.PluginContext{ + PluginID: pluginID, + PluginVersion: "1.0.0", + OrgID: 1, + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + ID: ds.ID, + UID: ds.UID, + Name: ds.Name, + URL: ds.URL, + JSONData: []byte(`{ + "httpMethod": "GET", + "timeout": "30s", + "keepCookies": [] + }`), + DecryptedSecureJSONData: map[string]string{ + "password": "sample-password", + "apiKey": "sample-api-key", + }, + }, + GrafanaConfig: backend.NewGrafanaCfg(map[string]string{ + "app_url": "http://localhost:3000", + "default_timezone": "UTC", + }), + } + + // Add user context if provided + if user != nil && !user.IsNil() { + pluginContext.User = &backend.User{ + Login: user.GetLogin(), + Name: user.GetName(), + Email: user.GetEmail(), + Role: string(user.GetOrgRole()), + } + } + + return pluginContext, nil +} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go new file mode 100644 index 00000000000..db545827991 --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go @@ -0,0 +1,19 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana/pkg/plugins" +) + +type PluginErrorResolver struct { +} + +// Assume no plugin with errors +func (m *PluginErrorResolver) PluginErrors(ctx context.Context) []*plugins.Error { + return nil +} + +func (m *PluginErrorResolver) PluginError(ctx context.Context, pluginID string) *plugins.Error { + return nil +} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go new file mode 100644 index 00000000000..0ab8d225314 --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go @@ -0,0 +1,26 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana/pkg/plugins/repo" +) + +type PluginRepo struct { + repo.Service +} + +func (m *PluginRepo) GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error) { + return []repo.PluginInfo{ + { + ID: 1, + Slug: "grafana-piechart-panel", + Version: "1.6.0", + }, + { + ID: 2, + Slug: "prometheus", + Version: "10.0.0", + }, + }, nil +} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go new file mode 100644 index 00000000000..782d93024f3 --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go @@ -0,0 +1,114 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) + +type PluginStore struct { +} + +var ps = map[string]pluginstore.Plugin{ + "prometheus": { + JSONData: plugins.JSONData{ + ID: "prometheus", + Type: plugins.TypeDataSource, + Name: "Prometheus", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Grafana Labs", + }, + Version: "10.0.0", + }, + Category: "Time series databases", + State: plugins.ReleaseStateAlpha, + Backend: true, + Metrics: true, + Logs: true, + Alerting: true, + Explore: true, + }, + Class: plugins.ClassCore, + Signature: plugins.SignatureStatusInternal, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "grafana.com", + }, + "test-datasource": { + JSONData: plugins.JSONData{ + ID: "grafana-piechart-panel", + Type: plugins.TypePanel, + Name: "Pie Chart", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Grafana Labs", + }, + Version: "1.6.0", + }, + Category: "Visualization", + State: plugins.ReleaseStateAlpha, + }, + Class: plugins.ClassCore, + Signature: plugins.SignatureStatusInternal, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "grafana.com", + }, + "grafana-piechart-panel": { + JSONData: plugins.JSONData{ + ID: "prometheus", + Type: plugins.TypeDataSource, + Name: "Prometheus", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Grafana Labs", + }, + Version: "10.0.0", + }, + Category: "Time series databases", + State: plugins.ReleaseStateAlpha, + Backend: true, + Metrics: true, + Logs: true, + Alerting: true, + Explore: true, + }, + Class: plugins.ClassCore, + Signature: plugins.SignatureStatusInternal, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "grafana.com", + }, + "test-app": { + JSONData: plugins.JSONData{ + ID: "test-app", + Type: plugins.TypeApp, + Name: "Test App", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Test Author", + }, + Version: "2.0.0", + }, + Category: "Application", + State: plugins.ReleaseStateAlpha, + AutoEnabled: true, + }, + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypeCommercial, + SignatureOrg: "test.com", + }, +} + +func (s *PluginStore) Plugin(ctx context.Context, pluginID string) (pluginstore.Plugin, bool) { + p, ok := ps[pluginID] + return p, ok +} + +func (s *PluginStore) Plugins(ctx context.Context, pluginTypes ...plugins.Type) []pluginstore.Plugin { + plugins := make([]pluginstore.Plugin, 0, len(ps)) + for _, p := range ps { + plugins = append(plugins, p) + } + return plugins +} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go new file mode 100644 index 00000000000..efbeb217e74 --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go @@ -0,0 +1,18 @@ +package mocksvcs + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) + +type UpdateChecker struct { +} + +func (m *UpdateChecker) IsUpdatable(ctx context.Context, plugin pluginstore.Plugin) bool { + return true +} + +func (m *UpdateChecker) CanUpdate(pluginId string, currentVersion string, targetVersion string, onlyMinor bool) bool { + return true +} diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index 524dae04268..cae29e181fd 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -26,7 +26,7 @@ const ( type check struct { DatasourceSvc datasources.DataSourceService PluginStore pluginstore.Store - PluginContextProvider pluginContextProvider + PluginContextProvider PluginContextProvider PluginClient plugins.Client PluginRepo repo.Service GrafanaVersion string @@ -37,7 +37,7 @@ type check struct { func New( datasourceSvc datasources.DataSourceService, pluginStore pluginstore.Store, - pluginContextProvider pluginContextProvider, + pluginContextProvider PluginContextProvider, pluginClient plugins.Client, pluginRepo repo.Service, grafanaVersion string, @@ -168,6 +168,6 @@ func (c *check) canBeInstalled(ctx context.Context, pluginType string) (bool, er return isAvailableInRepo, nil } -type pluginContextProvider interface { +type PluginContextProvider interface { GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) } diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/health_check_step.go b/apps/advisor/pkg/app/checks/datasourcecheck/health_check_step.go index c020d0ba82a..7e88e872e05 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/health_check_step.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/health_check_step.go @@ -15,7 +15,7 @@ import ( ) type healthCheckStep struct { - PluginContextProvider pluginContextProvider + PluginContextProvider PluginContextProvider PluginClient plugins.Client } diff --git a/apps/advisor/pkg/standalone/server.go b/apps/advisor/pkg/standalone/server.go index 6e2ad32cc8b..80dd82418ef 100644 --- a/apps/advisor/pkg/standalone/server.go +++ b/apps/advisor/pkg/standalone/server.go @@ -29,7 +29,7 @@ func main() { KubeConfig: rest.Config{}, // this will be replaced by the apiserver loopback config ManifestData: *apis.LocalManifest().ManifestData, SpecificConfig: checkregistry.AdvisorAppConfig{ - CheckRegistry: &mockchecks.CheckRegistry{}, + CheckRegistry: mockchecks.New(), PluginConfig: map[string]string{}, StackID: "1", // Numeric stack ID for standalone mode OrgService: nil, // Not needed when StackID is set From 4ceb7eec52f2a61e93f733777ce592c5554a205f Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 5 Nov 2025 10:47:19 +0100 Subject: [PATCH 021/209] Dynamic Dashboards: Change dragging to using grid items instead of viz panels. (#113343) * Preserve grid item size and repeat options when dragging between grids * push gridItem usage all the way * do console.warn and log to faro instead --- .../scene/DashboardLayoutOrchestrator.tsx | 42 ++++++++++----- .../scene/layout-auto-grid/AutoGridLayout.tsx | 17 +++--- .../AutoGridLayoutManager.tsx | 36 ++++++++++++- .../DefaultGridLayoutManager.tsx | 54 +++++++++++++++++-- .../scene/layout-rows/RowItem.tsx | 39 +++++++++++--- .../scene/layout-tabs/TabItem.tsx | 40 +++++++++++--- .../scene/types/DashboardDropTarget.ts | 6 +-- .../scene/types/DashboardLayoutGrid.ts | 8 ++- 8 files changed, 203 insertions(+), 39 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx index ff46c11cc0e..94484509991 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx @@ -1,13 +1,21 @@ import { PointerEvent as ReactPointerEvent } from 'react'; -import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { logWarning } from '@grafana/runtime'; +import { + sceneGraph, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + VizPanel, + SceneGridItemLike, +} from '@grafana/scenes'; import { createPointerDistance } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget'; interface DashboardLayoutOrchestratorState extends SceneObjectState { - draggingPanel?: SceneObjectRef; + draggingGridItem?: SceneObjectRef; } export class DashboardLayoutOrchestrator extends SceneObjectBase { @@ -32,11 +40,11 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase { - this._sourceDropTarget?.draggedPanelOutside?.(panel!); - this._lastDropTarget?.draggedPanelInside?.(panel!); + if (gridItem) { + // Always use grid item dragging + this._sourceDropTarget?.draggedGridItemOutside?.(gridItem); + this._lastDropTarget?.draggedGridItemInside?.(gridItem); + } else { + const warningMessage = 'No grid item to drag'; + console.warn(warningMessage); + logWarning(warningMessage); + } }); } document.body.removeEventListener('pointermove', this._onPointerMove); document.body.removeEventListener('pointerup', this._stopDraggingSync); - this.setState({ draggingPanel: undefined }); + this.setState({ draggingGridItem: undefined }); } private _onPointerMove(evt: PointerEvent) { - if (!this._isSelectedObject && this.state.draggingPanel && this._pointerDistance.check(evt)) { + if (!this._isSelectedObject && this.state.draggingGridItem && this._pointerDistance.check(evt)) { this._isSelectedObject = true; - const panel = this.state.draggingPanel?.resolve(); - this._getDashboard().state.editPane.selectObject(panel, panel.state.key!, { force: true, multi: false }); + const gridItem = this.state.draggingGridItem?.resolve(); + if (gridItem && 'state' in gridItem && 'body' in gridItem.state && gridItem.state.body instanceof VizPanel) { + const panel = gridItem.state.body; + this._getDashboard().state.editPane.selectObject(panel, panel.state.key!, { force: true, multi: false }); + } } const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx index 7ba318f2f4b..7e8570a3eec 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx @@ -1,6 +1,6 @@ import { createRef, CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; -import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel, SceneGridItemLike } from '@grafana/scenes'; import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { getLayoutOrchestratorFor } from '../../utils/utils'; @@ -110,7 +110,12 @@ export class AutoGridLayout extends SceneObjectBase impleme public getDragHooks() { return { - onDragStart: this._onDragStart, + onDragStart: (evt: ReactPointerEvent, panel: VizPanel) => { + const gridItem = panel.parent; + if (gridItem instanceof AutoGridItem) { + this._onDragStart(evt, gridItem); + } + }, }; } @@ -127,7 +132,7 @@ export class AutoGridLayout extends SceneObjectBase impleme } // Start inside dragging - private _onDragStart(evt: ReactPointerEvent, panel: VizPanel) { + private _onDragStart(evt: ReactPointerEvent, gridItem: SceneGridItemLike) { if (!this._canDrag(evt)) { return; } @@ -135,11 +140,11 @@ export class AutoGridLayout extends SceneObjectBase impleme evt.preventDefault(); evt.stopPropagation(); - if (!(panel.parent instanceof AutoGridItem)) { + if (!(gridItem instanceof AutoGridItem)) { throw new Error('Dragging wrong item'); } - this._draggedGridItem = panel.parent; + this._draggedGridItem = gridItem; const { top, left, width, height } = this._draggedGridItem.getBoundingBox(); this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left }; @@ -152,7 +157,7 @@ export class AutoGridLayout extends SceneObjectBase impleme document.body.addEventListener('pointerup', this._onDragEnd); document.body.classList.add('dashboard-draggable-transparent-selection'); - getLayoutOrchestratorFor(this)?.startDraggingSync(evt, panel); + getLayoutOrchestratorFor(this)?.startDraggingSync(evt, this._draggedGridItem); } // Stop inside dragging diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index 737629562fc..74ce363a970 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -1,6 +1,13 @@ import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { + SceneComponentProps, + SceneObject, + SceneObjectBase, + SceneObjectState, + VizPanel, + SceneGridItemLike, +} from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { GRID_CELL_VMARGIN } from 'app/core/constants'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -325,6 +332,33 @@ export class AutoGridLayoutManager extends SceneObjectBase) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 294dc4230f7..44a69b8fb23 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -125,9 +125,12 @@ export class DefaultGridLayoutManager private _activationHandler() { if (config.featureToggles.dashboardNewLayouts) { this._subs.add( - this.subscribeToEvent(SceneGridLayoutDragStartEvent, ({ payload: { evt, panel } }) => - getLayoutOrchestratorFor(this)?.startDraggingSync(evt, panel) - ) + this.subscribeToEvent(SceneGridLayoutDragStartEvent, ({ payload: { evt, panel } }) => { + const gridItem = panel.parent; + if (gridItem instanceof DashboardGridItem) { + getLayoutOrchestratorFor(this)?.startDraggingSync(evt, gridItem); + } + }) ); } @@ -515,6 +518,51 @@ export class DefaultGridLayoutManager }); } + public addGridItem(gridItem: SceneGridItemLike): void { + if (!(gridItem instanceof DashboardGridItem)) { + // If it's an AutoGridItem, convert it to DashboardGridItem + if (gridItem instanceof AutoGridItem) { + if (!(gridItem.state.body instanceof VizPanel)) { + throw new Error('AutoGridItem body is not a VizPanel'); + } + const panel = gridItem.state.body; + panel.clearParent(); + + const emptySpace = findSpaceForNewPanel(this.state.grid); + const newGridItem = new DashboardGridItem({ + x: emptySpace?.x ?? 0, + y: emptySpace?.y ?? 0, + width: emptySpace?.width ?? NEW_PANEL_WIDTH, + height: emptySpace?.height ?? NEW_PANEL_HEIGHT, + itemHeight: emptySpace?.height ?? NEW_PANEL_HEIGHT, + body: panel, + variableName: gridItem.state.variableName, + }); + + this.state.grid.setState({ children: [...this.state.grid.state.children, newGridItem] }); + return; + } + throw new Error('Grid item must be a DashboardGridItem or AutoGridItem'); + } + + // Move the whole grid item to another CustomGrid + // Clear parent before moving + gridItem.clearParent(); + + // Find empty space for the grid item, preserving its size + const emptySpace = findSpaceForNewPanel(this.state.grid); + if (emptySpace) { + // Update position to empty space, but keep original size + gridItem.setState({ + x: emptySpace.x, + y: emptySpace.y, + // Keep original width and height + }); + } + + this.state.grid.setState({ children: [...this.state.grid.state.children, gridItem] }); + } + public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager { const panels = currentLayout.getVizPanels(); const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index c9f7b91bf28..49a76045502 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { t } from '@grafana/i18n'; +import { logWarning } from '@grafana/runtime'; import { sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, VariableDependencyConfig, - VizPanel, + SceneGridItemLike, + SceneGridLayout, } from '@grafana/scenes'; import { RowsLayoutRowKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import appEvents from 'app/core/app_events'; @@ -20,11 +22,15 @@ import { ConditionalRenderingGroup } from '../../conditional-rendering/group/Con import { serializeRow } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; import { getElements } from '../../serialization/layoutSerializers/utils'; import { getDashboardSceneFor } from '../../utils/utils'; +import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; +import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout'; import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; +import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { clearClipboard } from '../layouts-shared/paste'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { BulkActionElement } from '../types/BulkActionElement'; import { DashboardDropTarget } from '../types/DashboardDropTarget'; +import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement'; import { LayoutParent } from '../types/LayoutParent'; @@ -168,14 +174,35 @@ export class RowItem } } - public draggedPanelOutside(panel: VizPanel) { - this.getLayout().removePanel?.(panel); + public draggedGridItemOutside?(gridItem: SceneGridItemLike): void { + // Remove from source layout + if (gridItem instanceof DashboardGridItem || gridItem instanceof AutoGridItem) { + const layout = gridItem.parent; + if (gridItem instanceof DashboardGridItem && layout instanceof SceneGridLayout) { + const newChildren = layout.state.children.filter((child) => child !== gridItem); + layout.setState({ children: newChildren }); + } else if (gridItem instanceof AutoGridItem && layout instanceof AutoGridLayout) { + const newChildren = layout.state.children.filter((child) => child !== gridItem); + layout.setState({ children: newChildren }); + } else { + const warningMessage = 'Grid item has unexpected parent type'; + console.warn(warningMessage); + logWarning(warningMessage); + } + } this.setIsDropTarget(false); } - public draggedPanelInside(panel: VizPanel) { - panel.clearParent(); - this.getLayout().addPanel(panel); + public draggedGridItemInside(gridItem: SceneGridItemLike): void { + const layout = this.getLayout(); + + if (isDashboardLayoutGrid(layout)) { + layout.addGridItem(gridItem); + } else { + const warningMessage = 'Layout manager does not support addGridItem'; + console.warn(warningMessage); + logWarning(warningMessage); + } this.setIsDropTarget(false); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index f49e2a37746..5db9caebbf6 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { t } from '@grafana/i18n'; +import { logWarning } from '@grafana/runtime'; import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject, - VizPanel, + SceneGridItemLike, + SceneGridLayout, } from '@grafana/scenes'; import { TabsLayoutTabKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { LS_TAB_COPY_KEY } from 'app/core/constants'; @@ -20,11 +22,15 @@ import { ConditionalRenderingGroup } from '../../conditional-rendering/group/Con import { serializeTab } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; import { getElements } from '../../serialization/layoutSerializers/utils'; import { getDashboardSceneFor } from '../../utils/utils'; +import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; +import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout'; import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; +import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { clearClipboard } from '../layouts-shared/paste'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { BulkActionElement } from '../types/BulkActionElement'; import { DashboardDropTarget } from '../types/DashboardDropTarget'; +import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement'; import { LayoutParent } from '../types/LayoutParent'; @@ -186,18 +192,38 @@ export class TabItem } } - public draggedPanelOutside(panel: VizPanel) { - this.getLayout().removePanel?.(panel); + public draggedGridItemOutside?(gridItem: SceneGridItemLike): void { + // Remove from source layout + if (gridItem instanceof DashboardGridItem || gridItem instanceof AutoGridItem) { + const layout = gridItem.parent; + if (gridItem instanceof DashboardGridItem && layout instanceof SceneGridLayout) { + const newChildren = layout.state.children.filter((child) => child !== gridItem); + layout.setState({ children: newChildren }); + } else if (gridItem instanceof AutoGridItem && layout instanceof AutoGridLayout) { + const newChildren = layout.state.children.filter((child) => child !== gridItem); + layout.setState({ children: newChildren }); + } else { + const warningMessage = 'Grid item has unexpected parent type'; + console.warn(warningMessage); + logWarning(warningMessage); + } + } this.setIsDropTarget(false); } - public draggedPanelInside(panel: VizPanel) { - panel.clearParent(); - this.getLayout().addPanel(panel); + public draggedGridItemInside(gridItem: SceneGridItemLike): void { + const layout = this.getLayout(); + + if (isDashboardLayoutGrid(layout)) { + layout.addGridItem(gridItem); + } else { + const warningMessage = 'Layout manager does not support addGridItem'; + console.warn(warningMessage); + logWarning(warningMessage); + } this.setIsDropTarget(false); const parentLayout = this.getParentLayout(); - if (parentLayout.state.currentTabSlug !== this.getSlug()) { parentLayout.setState({ currentTabSlug: this.getSlug() }); } diff --git a/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts b/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts index de588c5efbd..8e28d5be9f0 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts @@ -1,10 +1,10 @@ -import { SceneObject, VizPanel } from '@grafana/scenes'; +import { SceneObject, SceneGridItemLike } from '@grafana/scenes'; export interface DashboardDropTarget extends SceneObject { isDashboardDropTarget: Readonly; setIsDropTarget?(isDropTarget: boolean): void; - draggedPanelOutside?(panel: VizPanel): void; - draggedPanelInside?(panel: VizPanel): void; + draggedGridItemOutside?(gridItem: SceneGridItemLike): void; + draggedGridItemInside?(gridItem: SceneGridItemLike): void; } export function isDashboardDropTarget(scene: SceneObject): scene is DashboardDropTarget { diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutGrid.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutGrid.ts index 3f6bcb2bcc6..877a84893aa 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutGrid.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutGrid.ts @@ -1,3 +1,5 @@ +import { SceneGridItemLike } from '@grafana/scenes'; + import { DashboardLayoutManager } from './DashboardLayoutManager'; export interface DashboardLayoutGrid extends DashboardLayoutManager { @@ -5,8 +7,12 @@ export interface DashboardLayoutGrid extends DashboardLayoutManager { * Merge the layout with another layout */ mergeGrid(other: DashboardLayoutGrid): void; + /** + * Add a grid item to the layout + */ + addGridItem(gridItem: SceneGridItemLike): void; } export function isDashboardLayoutGrid(obj: DashboardLayoutManager): obj is DashboardLayoutGrid { - return 'mergeGrid' in obj; + return 'mergeGrid' in obj && 'addGridItem' in obj; } From 571e5c2e3c128b8d0f3f2d3f477eaae8d42782df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Wed, 5 Nov 2025 11:07:21 +0100 Subject: [PATCH 022/209] Provisioning: Fix data race in job progress and leasing (#113157) * Fix data race in provisioning job execution * Fix TODO * Update pkg/registry/apis/provisioning/jobs/driver.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/registry/apis/provisioning/jobs/driver.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix unlocking issue on panic --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/registry/apis/provisioning/jobs/driver.go | 106 ++++++++++++------ .../jobs/job_progress_recorder_mock.go | 51 ++++++++- .../apis/provisioning/jobs/progress.go | 4 + pkg/registry/apis/provisioning/jobs/queue.go | 2 + 4 files changed, 129 insertions(+), 34 deletions(-) diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index bad1df13759..7902aad12fc 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "sync" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -74,6 +75,11 @@ type jobDriver struct { // notifications channel for job create events notifications chan struct{} + + // Mutex to protect concurrent access to job processing + mu sync.Mutex + // currentJob is the job currently being processed + currentJob *provisioning.Job } func NewJobDriver( @@ -142,7 +148,7 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { logger := logging.FromContext(ctx) // Claim a job to work on. - job, rollback, err := d.store.Claim(ctx) + claimedJob, rollback, err := d.store.Claim(ctx) if err != nil { return apifmt.Errorf("failed to claim job: %w", err) } @@ -150,14 +156,16 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { // The rollback function does not care about cancellations. defer rollback() - logger = logger.With("job", job.GetName(), "namespace", job.GetNamespace()) + namespace := claimedJob.GetNamespace() + logger = logger.With("job", claimedJob.GetName(), "namespace", namespace) ctx = logging.Context(ctx, logger) logger.Debug("claimed a job") + d.currentJob = claimedJob // Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it. // Incidentally, this also limits our permissions to only the namespace of the job. - ctx = request.WithNamespace(ctx, job.GetNamespace()) - ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace()) + ctx = request.WithNamespace(ctx, namespace) + ctx, _, err = identity.WithProvisioningIdentity(ctx, namespace) if err != nil { return apifmt.Errorf("failed to grant provisioning identity: %w", err) } @@ -169,37 +177,42 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { leaseRenewalCtx, cancelLeaseRenewal := context.WithCancel(jobctx) leaseExpired := make(chan struct{}) - go d.leaseRenewalLoop(leaseRenewalCtx, job, logger, leaseExpired) + go d.leaseRenewalLoop(leaseRenewalCtx, logger, leaseExpired) defer cancelLeaseRenewal() - recorder := newJobProgressRecorder(d.onProgress(job)) + recorder := newJobProgressRecorder(d.onProgress()) + recorder.SetMessage(ctx, "start job") // Process the job with lease loss detection - start := time.Now() - job.Status.Started = start.UnixMilli() - err = d.processJobWithLeaseCheck(jobctx, job, recorder, leaseExpired) + err = d.processJobWithLeaseCheck(jobctx, recorder, leaseExpired) end := time.Now() - logger.Debug("job processed", "duration", end.Sub(start), "error", err) + logger.Debug("job processed", "duration", end.Sub(recorder.Started()), "error", err) // Capture job timeout if jobctx.Err() != nil && err == nil { err = jobctx.Err() } - job.Status = recorder.Complete(ctx, err) + // Complete the job + d.mu.Lock() + d.currentJob.Status = recorder.Complete(ctx, err) + defer func() { + d.currentJob = nil + d.mu.Unlock() + }() // Save the finished job - err = d.historicJobs.WriteJob(ctx, job.DeepCopy()) + err = d.historicJobs.WriteJob(ctx, d.currentJob.DeepCopy()) if err != nil { // We're not going to return this as it is not critical. Not ideal, but not critical. - logger.Warn("failed to create historic job", "historic_job", *job, "error", err) + logger.Warn("failed to create historic job", "historic_job", *d.currentJob, "error", err) } else { - logger.Debug("created historic job", "historic_job", *job) + logger.Debug("created historic job", "historic_job", *d.currentJob) } // Mark the job as completed. - if err := d.store.Complete(ctx, job); err != nil { - return apifmt.Errorf("failed to complete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) + if err := d.store.Complete(ctx, d.currentJob); err != nil { + return apifmt.Errorf("failed to complete job '%s' in '%s': %w", d.currentJob.GetName(), d.currentJob.GetNamespace(), err) } logger.Debug("job completed") @@ -208,7 +221,7 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { // leaseRenewalLoop continuously renews the lease for a job until the context is cancelled. // If lease renewal fails persistently, it signals via the leaseExpired channel. -func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job, logger logging.Logger, leaseExpired chan struct{}) { +func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, leaseExpired chan struct{}) { ticker := time.NewTicker(d.leaseRenewalInterval) defer ticker.Stop() @@ -223,7 +236,15 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job, logger.Debug("lease renewal loop stopping") return case <-ticker.C: - err := d.store.RenewLease(ctx, job) + d.mu.Lock() + if d.currentJob == nil { + d.mu.Unlock() + return + } + + err := d.store.RenewLease(ctx, d.currentJob) + d.mu.Unlock() + if err != nil { consecutiveFailures++ if apierrors.IsNotFound(err) || @@ -253,11 +274,11 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job, } // processJobWithLeaseCheck processes a job but aborts if the lease expires. -func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error { +func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error { // Run the job processing in a goroutine so we can monitor lease expiry resultChan := make(chan error, 1) go func() { - resultChan <- d.processJob(ctx, job, recorder) + resultChan <- d.processJob(ctx, recorder) }() select { @@ -270,16 +291,28 @@ func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provision } } -func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder) error { +func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder) error { logger := logging.FromContext(ctx) + d.mu.Lock() + if d.currentJob == nil { + d.mu.Unlock() + return nil + } + + // Here it's safe to copy as only job spec is used for processing + job := d.currentJob.DeepCopy() + repoName := d.currentJob.Spec.Repository + namespace := d.currentJob.Namespace + d.mu.Unlock() + for _, worker := range d.workers { if !worker.IsSupported(ctx, *job) { continue } - repo, err := d.repoGetter.GetRepository(ctx, job.Namespace, job.Spec.Repository) + repo, err := d.repoGetter.GetRepository(ctx, namespace, repoName) if err != nil { - return apifmt.Errorf("failed to get repository '%s': %w", job.Spec.Repository, err) + return apifmt.Errorf("failed to get repository '%s': %w", repoName, err) } r := repo.Config() @@ -298,42 +331,51 @@ func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recor return apifmt.Errorf("no workers were registered to handle the job") } -func (d *jobDriver) onProgress(job *provisioning.Job) ProgressFn { +func (d *jobDriver) onProgress() ProgressFn { return func(ctx context.Context, status provisioning.JobStatus) error { logging.FromContext(ctx).Debug("job progress", "status", status) const maxRetries = 3 for attempt := 0; attempt < maxRetries; attempt++ { - // Use the current job for the first attempt, fetch fresh for retries - currentJob := job + d.mu.Lock() + if d.currentJob == nil { + d.mu.Unlock() + return nil + } + + // Use the current job for the first attempt; on retry attempts, fetch fresh data from the store to resolve conflicts if attempt > 0 { // Fetch the latest version to resolve conflicts - latest, err := d.store.Get(ctx, job.GetNamespace(), job.GetName()) + latest, err := d.store.Get(ctx, d.currentJob.GetNamespace(), d.currentJob.GetName()) if err != nil { + d.mu.Unlock() if apierrors.IsNotFound(err) { // Job was completed/deleted, nothing to update return nil } return apifmt.Errorf("failed to fetch job for progress update: %w", err) } - currentJob = latest + + *d.currentJob = *latest } + job := d.currentJob // Update status on the current job - currentJob.Status = status - - updated, err := d.store.Update(ctx, currentJob) + job.Status = status + updated, err := d.store.Update(ctx, job) if err != nil { if apierrors.IsConflict(err) && attempt < maxRetries-1 { // Conflict detected, retry with fresh data logging.FromContext(ctx).Debug("progress update conflict, retrying", "attempt", attempt+1) continue } + d.mu.Unlock() return apifmt.Errorf("failed to update job progress: %w", err) } // Update succeeded, update our local copy - *job = *updated + *d.currentJob = *updated + d.mu.Unlock() return nil } diff --git a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go index 3cefca9e5af..45d8572e94a 100644 --- a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go +++ b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go @@ -1,12 +1,14 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package jobs import ( context "context" + time "time" + + mock "github.com/stretchr/testify/mock" v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - mock "github.com/stretchr/testify/mock" ) // MockJobProgressRecorder is an autogenerated mock type for the JobProgressRecorder type @@ -271,6 +273,51 @@ func (_c *MockJobProgressRecorder_SetTotal_Call) RunAndReturn(run func(context.C return _c } +// Started provides a mock function with no fields +func (_m *MockJobProgressRecorder) Started() time.Time { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Started") + } + + var r0 time.Time + if rf, ok := ret.Get(0).(func() time.Time); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(time.Time) + } + + return r0 +} + +// MockJobProgressRecorder_Started_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Started' +type MockJobProgressRecorder_Started_Call struct { + *mock.Call +} + +// Started is a helper method to define mock.On call +func (_e *MockJobProgressRecorder_Expecter) Started() *MockJobProgressRecorder_Started_Call { + return &MockJobProgressRecorder_Started_Call{Call: _e.mock.On("Started")} +} + +func (_c *MockJobProgressRecorder_Started_Call) Run(run func()) *MockJobProgressRecorder_Started_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockJobProgressRecorder_Started_Call) Return(_a0 time.Time) *MockJobProgressRecorder_Started_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_Started_Call) RunAndReturn(run func() time.Time) *MockJobProgressRecorder_Started_Call { + _c.Call.Return(run) + return _c +} + // StrictMaxErrors provides a mock function with given fields: maxErrors func (_m *MockJobProgressRecorder) StrictMaxErrors(maxErrors int) { _m.Called(maxErrors) diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 95b99ea704f..97a293a5d8c 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -69,6 +69,10 @@ func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder { } } +func (r *jobProgressRecorder) Started() time.Time { + return r.started +} + func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResult) { var shouldLogError bool var logErr error diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go index 14f6b8bd9c7..e1992395efd 100644 --- a/pkg/registry/apis/provisioning/jobs/queue.go +++ b/pkg/registry/apis/provisioning/jobs/queue.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "time" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" @@ -18,6 +19,7 @@ type RepoGetter interface { // //go:generate mockery --name JobProgressRecorder --structname MockJobProgressRecorder --inpackage --filename job_progress_recorder_mock.go --with-expecter type JobProgressRecorder interface { + Started() time.Time Record(ctx context.Context, result JobResourceResult) ResetResults() SetFinalMessage(ctx context.Context, msg string) From 505e025d18a3212710aac6c65bce66a9a3117f53 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 5 Nov 2025 11:12:41 +0100 Subject: [PATCH 023/209] Zanzana: Fix namespace in remote client (#113433) --- pkg/services/authz/zanzana.go | 2 +- pkg/setting/settings_zanzana.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 21199b502cb..3be1b18dc1d 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -110,7 +110,7 @@ func ProvideStandaloneZanzanaClient(cfg *setting.Cfg, features featuremgmt.Featu ServerCertFile: cfg.ZanzanaClient.ServerCertFile, } - return NewRemoteZanzanaClient(fmt.Sprintf("stacks-%s", cfg.StackID), zanzanaConfig) + return NewRemoteZanzanaClient(cfg.ZanzanaClient.TokenNamespace, zanzanaConfig) } type ZanzanaClientConfig struct { diff --git a/pkg/setting/settings_zanzana.go b/pkg/setting/settings_zanzana.go index be7121f3b0d..1a06ea034ef 100644 --- a/pkg/setting/settings_zanzana.go +++ b/pkg/setting/settings_zanzana.go @@ -27,6 +27,8 @@ type ZanzanaClientSettings struct { // URL called to perform exchange request. // Only used when mode is set to client. TokenExchangeURL string + // Namespace to use for the token. + TokenNamespace string } type ZanzanaServerSettings struct { @@ -113,6 +115,10 @@ func (cfg *Cfg) readZanzanaSettings() { zc.Addr = clientSec.Key("address").MustString("") zc.ServerCertFile = clientSec.Key("tls_cert").MustString("") + // TODO: read Token and TokenExchangeURL from grpc_client_authentication section + grpcClientAuthSection := cfg.SectionWithEnvOverrides("grpc_client_authentication") + zc.TokenNamespace = grpcClientAuthSection.Key("token_namespace").MustString("stacks-" + cfg.StackID) + cfg.ZanzanaClient = zc zs := ZanzanaServerSettings{} From d1334a6dff01f0aa436c4690a08730182f11e125 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 5 Nov 2025 11:13:08 +0100 Subject: [PATCH 024/209] Zanzana: Log token namespace in case of error (#113437) --- pkg/services/authz/zanzana/server/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/authz/zanzana/server/auth.go b/pkg/services/authz/zanzana/server/auth.go index e86aaa81ebb..f137bdca47f 100644 --- a/pkg/services/authz/zanzana/server/auth.go +++ b/pkg/services/authz/zanzana/server/auth.go @@ -26,7 +26,7 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe return status.Errorf(codes.Unauthenticated, "unauthenticated") } if !claims.NamespaceMatches(c.GetNamespace(), namespace) { - return status.Errorf(codes.PermissionDenied, "namespace does not match") + return status.Errorf(codes.PermissionDenied, "token namespace %s does not match request namespace", c.GetNamespace()) } return nil } From 6376484b135fbcca94b276d9ea0b26cc2b731c0e Mon Sep 17 00:00:00 2001 From: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed, 5 Nov 2025 05:18:14 -0500 Subject: [PATCH 025/209] Card: apply grid fractional unit to description instead of heading (#113424) --- packages/grafana-ui/src/components/Card/CardContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index 270ac37931c..f970356ec80 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -93,7 +93,7 @@ export const getCardContainerStyles = ( display: 'grid', position: 'relative', gridTemplateColumns: 'auto 1fr auto', - gridTemplateRows: '1fr auto auto auto', + gridTemplateRows: 'auto auto 1fr auto', gridAutoColumns: '1fr', gridAutoFlow: 'row', gridTemplateAreas: ` From 7c2f38641a4f7476200bbea8f76e5a6898db12ad Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Wed, 5 Nov 2025 11:24:58 +0000 Subject: [PATCH 026/209] Chore: Add gitattributes config for generated files (#113402) * Add gitattributes config for generated files * Add snapshots and manifest files --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitattributes b/.gitattributes index 6313b56c578..e4abcc5121d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,6 @@ * text=auto eol=lf +*.gen.ts linguist-generated +*_gen.ts linguist-generated +*_gen.go linguist-generated +**/openapi_snapshots/*.json linguist-generated +apps/**/pkg/apis/*_manifest.go linguist-generated From 93f1c24b82eec82103766c634929ea1eb63a7ef3 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 5 Nov 2025 12:24:45 +0000 Subject: [PATCH 027/209] FS: Update devenv docker images with renovate (#112943) * FS: Use renovate to update devenv docker images * add gha check to test tiltfile * setup nodejs * create empty config files --- .github/actions/change-detection/action.yml | 8 +++++++ .github/renovate.json5 | 4 ++-- .github/workflows/pr-frontend-unit-tests.yml | 24 ++++++++++++++++++++ devenv/frontend-service/docker-compose.yaml | 10 ++++---- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml index 49c1f44477b..2c6b46606f4 100644 --- a/.github/actions/change-detection/action.yml +++ b/.github/actions/change-detection/action.yml @@ -31,6 +31,9 @@ outputs: dockerfile: description: Whether the dockerfile or self have changed in any way value: ${{ steps.changed-files.outputs.dockerfile_any_changed || 'true' }} + devenv: + description: Whether the devenv or self have changed in any way + value: ${{ steps.changed-files.outputs.devenv_any_changed || 'true' }} runs: using: composite steps: @@ -136,6 +139,9 @@ runs: - '.vale.ini' - '.github/actions/change-detection/**' - '${{ inputs.self }}' + devenv: + - 'devenv/**' + - '${{ inputs.self }}' - name: Print all change groups shell: bash run: | @@ -157,3 +163,5 @@ runs: echo " --> ${{ steps.changed-files.outputs.docs_all_changed_files }}" echo "Dockerfile: ${{ steps.changed-files.outputs.dockerfile_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.dockerfile_all_changed_files }}" + echo "devenv: ${{ steps.changed-files.outputs.devenv_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.devenv_all_changed_files }}" diff --git a/.github/renovate.json5 b/.github/renovate.json5 index aa33961bf95..4eb9f7b8ddf 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,6 +1,6 @@ { extends: ["config:recommended"], - enabledManagers: ["npm"], + enabledManagers: ["npm", "docker-compose"], ignorePresets: [ "github>grafana/grafana-renovate-config//presets/labels", ], @@ -26,7 +26,7 @@ "@types/slate-react", // we don't want to continue using this on the long run, use Monaco editor instead of Slate "@types/slate", // we don't want to continue using this on the long run, use Monaco editor instead of Slate ], - includePaths: ["package.json", "packages/**", "public/app/plugins/**"], + includePaths: ["package.json", "packages/**", "public/app/plugins/**", "devenv/frontend-service/docker-compose.yaml"], ignorePaths: ["emails/**", "**/mocks/**"], labels: ["area/frontend", "dependencies", "no-changelog"], postUpdateOptions: ["yarnDedupeHighest"], diff --git a/.github/workflows/pr-frontend-unit-tests.yml b/.github/workflows/pr-frontend-unit-tests.yml index 9c6c0b32daa..1e4fa7790a6 100644 --- a/.github/workflows/pr-frontend-unit-tests.yml +++ b/.github/workflows/pr-frontend-unit-tests.yml @@ -18,6 +18,7 @@ jobs: contents: read outputs: changed: ${{ steps.detect-changes.outputs.frontend }} + devenv-changed: ${{ steps.detect-changes.outputs.devenv }} steps: - uses: actions/checkout@v5 with: @@ -169,3 +170,26 @@ jobs: needs: ${{ toJson(needs) }} failure-message: "One or more unit test jobs have failed" success-message: "All unit tests completed successfully" + + devenv: + needs: + - detect-changes + if: needs.detect-changes.outputs.devenv-changed == 'true' + runs-on: ubuntu-x64-large + name: "Devenv frontend-service build" + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup Docker + uses: docker/setup-docker-action@3fb92d6d9c634363128c8cce4bc3b2826526370a # v4 + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Install Tilt + run: curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash + - name: Create empty config files # TODO: the tiltfile should conditionally mount these only if they exist, like the enterprise license + run: | + touch devenv/frontend-service/configs/grafana-api.local.ini + touch devenv/frontend-service/configs/frontend-service.local.ini + - name: Test frontend-service Tiltfile + run: tilt ci --file devenv/frontend-service/Tiltfile diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index 0d65b27c250..c5f924f079b 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -86,7 +86,7 @@ services: - 'alloy.logs=true' alloy: - image: grafana/alloy:latest + image: grafana/alloy:v1.11.2 volumes: - ./configs/alloy:/alloy-config - /var/run/docker.sock:/var/run/docker.sock # To scrape Docker container logs @@ -104,7 +104,7 @@ services: - 'alloy.logs=true' prometheus: - image: prom/prometheus + image: prom/prometheus:v3.7.2 volumes: - prometheus-data:/prometheus command: @@ -116,7 +116,7 @@ services: - 'alloy.logs=true' loki: - image: grafana/loki + image: grafana/loki:3.5.7 volumes: - loki-data:/loki command: -config.file=/etc/loki/local-config.yaml @@ -124,7 +124,7 @@ services: - 'alloy.logs=true' tempo-init: - image: busybox + image: busybox:1.37.0 user: root entrypoint: - 'chown' @@ -134,7 +134,7 @@ services: - tempo-data:/var/tempo tempo: - image: grafana/tempo + image: grafana/tempo:2.9.0 volumes: - tempo-data:/var/lib/tempo - ./configs/tempo.yaml:/etc/tempo/tempo.yaml From d9d227ec4d181e4a43626435e9bf1f3db8747fa0 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Wed, 5 Nov 2025 14:38:31 +0200 Subject: [PATCH 028/209] Dashboard: Hide add variable button in edit views (#113438) --- .../dashboard-scene/scene/VariableControls.tsx | 6 +----- .../scene/VariableControlsAddButton.tsx | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 45c48610f1a..f62d8b06c41 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -27,11 +27,7 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { .map((variable) => ( ))} - {config.featureToggles.dashboardNewLayouts ? ( -
- -
- ) : null} + {config.featureToggles.dashboardNewLayouts ? : null} ); } diff --git a/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx b/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx index 9d20b740030..8bdabd397d1 100644 --- a/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx @@ -9,7 +9,7 @@ import { DashboardInteractions } from '../utils/interactions'; import { DashboardScene } from './DashboardScene'; export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) { - const { isEditing } = dashboard.useState(); + const { editview, editPanel, isEditing, viewPanel } = dashboard.useState(); const handlePointerDown: PointerEventHandler = useCallback( (evt) => { @@ -20,13 +20,20 @@ export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) [dashboard] ); - if (!isEditing) { + // Hide the button if: + // - the dashboard is not in edit mode + // - the dashboard is in an edit view mode + // - the dashboard is in a view panel mode + // - the dashboard is in an edit panel mode + if (!isEditing || !!editview || !!viewPanel || !!editPanel) { return null; } return ( - +
+ +
); } From b97fb638adac42c1d76f6d39648cea23b1aa410b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:04:43 +0100 Subject: [PATCH 029/209] fix: only validate allowed descendants for folder deletion (#113440) --- pkg/registry/apis/folders/register_test.go | 82 +++++++++++++-- pkg/registry/apis/folders/validate.go | 6 +- pkg/registry/apis/folders/validate_test.go | 114 ++++++++++++++++++++- 3 files changed, 188 insertions(+), 14 deletions(-) diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index fe472db4b9f..066f8793776 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -183,17 +183,85 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) { func TestFolderAPIBuilder_Validate_Delete(t *testing.T) { tests := []struct { name string - statsResponse *resourcepb.ResourceStatsResponse_Stats + statsResponse []*resourcepb.ResourceStatsResponse_Stats wantErr bool }{ { - name: "should allow deletion when folder is empty", - statsResponse: &resourcepb.ResourceStatsResponse_Stats{Count: 0}, + name: "should allow deletion when folder is empty", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 0, Resource: "dashboards"}, + }, }, { - name: "should return folder not empty when the folder is not empty", - statsResponse: &resourcepb.ResourceStatsResponse_Stats{Count: 2}, - wantErr: true, + name: "should return folder not empty when folder contains dashboards", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 2, Resource: "dashboards", Group: "dashboard.grafana.app"}, + }, + wantErr: true, + }, + { + name: "should return folder not empty when folder contains alertrules", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 3, Resource: "alertrules", Group: "alerting.grafana.app"}, + }, + wantErr: true, + }, + { + name: "should return folder not empty when folder contains library_elements", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 1, Resource: "library_elements", Group: "library.grafana.app"}, + }, + wantErr: true, + }, + { + name: "should return folder not empty when folder contains folders", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 2, Resource: "folders", Group: "folders.grafana.app"}, + }, + wantErr: true, + }, + { + name: "should return folder not empty when folder has mixed resources with validated types", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 10, Resource: "folders", Group: "folders.grafana.app"}, + {Count: 2, Resource: "dashboards", Group: "dashboard.grafana.app"}, + {Count: 5, Resource: "playlists", Group: "playlist.grafana.app"}, + }, + wantErr: true, + }, + { + name: "should return folder not empty when folder has multiple validated resource types", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 1, Resource: "dashboards", Group: "dashboard.grafana.app"}, + {Count: 2, Resource: "alertrules", Group: "alerting.grafana.app"}, + {Count: 1, Resource: "library_elements", Group: "library.grafana.app"}, + {Count: 1, Resource: "folders", Group: "folders.grafana.app"}, + }, + wantErr: true, + }, + { + name: "should allow deletion when all validated resource types are empty", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 0, Resource: "dashboards", Group: "dashboard.grafana.app"}, + {Count: 0, Resource: "alertrules", Group: "alerting.grafana.app"}, + {Count: 0, Resource: "library_elements", Group: "library.grafana.app"}, + {Count: 0, Resource: "folders", Group: "folders.grafana.app"}, + {Count: 10, Resource: "playlists", Group: "playlist.grafana.app"}, + }, + wantErr: false, + }, + { + name: "should allow deletion when folder only contains non-validated resource types", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{ + {Count: 5, Resource: "playlists", Group: "playlist.grafana.app"}, + {Count: 3, Resource: "other", Group: "other.grafana.app"}, + }, + wantErr: false, + }, + { + name: "should allow deletion when stats array is empty", + statsResponse: []*resourcepb.ResourceStatsResponse_Stats{}, + wantErr: false, }, } @@ -212,7 +280,7 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) { us := grafanarest.NewMockStorage(t) sm := resource.NewMockResourceClient(t) sm.On("GetStats", mock.Anything, &resourcepb.ResourceStatsRequest{Namespace: obj.Namespace, Folder: obj.Name}).Return( - &resourcepb.ResourceStatsResponse{Stats: []*resourcepb.ResourceStatsResponse_Stats{tt.statsResponse}}, + &resourcepb.ResourceStatsResponse{Stats: tt.statsResponse}, nil, ).Once() diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index f63583b872c..db8c3b7e0e8 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -144,9 +144,11 @@ func validateOnDelete(ctx context.Context, return fmt.Errorf("could not verify if folder is empty: %v", resp.Error) } + allowedResourceTypes := []string{"alertrules", "dashboards", "library_elements", "folders"} + for _, v := range resp.Stats { - if v.Count > 0 { - return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s.%s", v.Count, v.Group, v.Resource) + if slices.Contains(allowedResourceTypes, v.Resource) && v.Count > 0 { + return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s", v.Count, v.Resource) } } return nil diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index 2bed196ddce..33fda7ea30f 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -320,7 +320,7 @@ func TestValidateDelete(t *testing.T) { }, }, }, { - name: "stats error", + name: "stats error - nil stats", folder: &folders.Folder{ ObjectMeta: metav1.ObjectMeta{ Name: "nnn", @@ -331,7 +331,7 @@ func TestValidateDelete(t *testing.T) { }, expectedErr: "could not verify if folder is empty", }, { - name: "stats error", + name: "stats error - search error", folder: &folders.Folder{ ObjectMeta: metav1.ObjectMeta{ Name: "nnn", @@ -342,7 +342,7 @@ func TestValidateDelete(t *testing.T) { }, expectedErr: "error running stats", }, { - name: "stats error", + name: "stats error - error result", folder: &folders.Folder{ ObjectMeta: metav1.ObjectMeta{ Name: "nnn", @@ -357,7 +357,64 @@ func TestValidateDelete(t *testing.T) { }, expectedErr: "could not verify if folder is empty", }, { - name: "folder not empty", + name: "folder not empty - contains dashboards", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nnn", + }, + }, + searcher: &mockSearchClient{ + stats: &resourcepb.ResourceStatsResponse{ + Stats: []*resourcepb.ResourceStatsResponse_Stats{ + { + Group: "dashboard.grafana.app", + Resource: "dashboards", + Count: 10, // not empty + }, + }, + }, + }, + expectedErr: "[folder.not-empty]", + }, { + name: "folder not empty - contains alertrules", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nnn", + }, + }, + searcher: &mockSearchClient{ + stats: &resourcepb.ResourceStatsResponse{ + Stats: []*resourcepb.ResourceStatsResponse_Stats{ + { + Group: "alerting.grafana.app", + Resource: "alertrules", + Count: 5, // not empty + }, + }, + }, + }, + expectedErr: "[folder.not-empty]", + }, { + name: "folder not empty - contains library_elements", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nnn", + }, + }, + searcher: &mockSearchClient{ + stats: &resourcepb.ResourceStatsResponse{ + Stats: []*resourcepb.ResourceStatsResponse_Stats{ + { + Group: "library.grafana.app", + Resource: "library_elements", + Count: 3, // not empty + }, + }, + }, + }, + expectedErr: "[folder.not-empty]", + }, { + name: "folder not empty - contains folders", folder: &folders.Folder{ ObjectMeta: metav1.ObjectMeta{ Name: "nnn", @@ -369,7 +426,54 @@ func TestValidateDelete(t *testing.T) { { Group: "folders.grafana.app", Resource: "folders", - Count: 10, // not empty + Count: 2, // not empty + }, + }, + }, + }, + expectedErr: "[folder.not-empty]", + }, { + name: "folder can be deleted when it only contains non-validated resource types", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nnn", + }, + }, + searcher: &mockSearchClient{ + stats: &resourcepb.ResourceStatsResponse{ + Stats: []*resourcepb.ResourceStatsResponse_Stats{ + { + Group: "playlist.grafana.app", + Resource: "playlists", + Count: 10, // has content but not a validated resource type + }, + { + Group: "other.grafana.app", + Resource: "other", + Count: 5, // has content but not a validated resource type + }, + }, + }, + }, + }, { + name: "folder not empty - mixed resources with validated types", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nnn", + }, + }, + searcher: &mockSearchClient{ + stats: &resourcepb.ResourceStatsResponse{ + Stats: []*resourcepb.ResourceStatsResponse_Stats{ + { + Group: "folders.grafana.app", + Resource: "folders", + Count: 10, // now validated + }, + { + Group: "dashboard.grafana.app", + Resource: "dashboards", + Count: 2, // validated and has content }, }, }, From 2ccb7f618db06b1fc8082c30ec5ad928d51309c9 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 5 Nov 2025 08:59:07 -0500 Subject: [PATCH 030/209] ConfigFormGithubCollapse: Hide github features section if nothing is available (#113410) ConfigFormGithubCollapse: Hide github features section if nothing is available. Added unit tests --- eslint-suppressions.json | 5 - .../Config/ConfigFormGithubCollapse.test.tsx | 110 ++++++++++++++++++ .../Config/ConfigFormGithubCollapse.tsx | 96 ++++++++------- 3 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 public/app/features/provisioning/Config/ConfigFormGithubCollapse.test.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a3292e1a102..6392492ae77 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3313,11 +3313,6 @@ "count": 3 } }, - "public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx": { - "no-restricted-syntax": { - "count": 2 - } - }, "public/app/features/provisioning/Shared/BranchValidationError.tsx": { "react/no-unescaped-entities": { "count": 26 diff --git a/public/app/features/provisioning/Config/ConfigFormGithubCollapse.test.tsx b/public/app/features/provisioning/Config/ConfigFormGithubCollapse.test.tsx new file mode 100644 index 00000000000..a3679340204 --- /dev/null +++ b/public/app/features/provisioning/Config/ConfigFormGithubCollapse.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from '@testing-library/react'; +import { UseFormRegister } from 'react-hook-form'; +import { MemoryRouter } from 'react-router-dom-v5-compat'; + +import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; + +import { checkImageRenderer, checkImageRenderingAllowed, checkPublicAccess } from '../GettingStarted/features'; +import { RepositoryFormData } from '../types'; + +import { ConfigFormGithubCollapse } from './ConfigFormGithubCollapse'; + +jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ + useGetFrontendSettingsQuery: jest.fn(), +})); + +jest.mock('../GettingStarted/features', () => ({ + checkImageRenderer: jest.fn(), + checkPublicAccess: jest.fn(), + checkImageRenderingAllowed: jest.fn(), +})); + +const mockUseGetFrontendSettingsQuery = useGetFrontendSettingsQuery as jest.MockedFunction< + typeof useGetFrontendSettingsQuery +>; +const mockCheckImageRenderer = checkImageRenderer as jest.MockedFunction; +const mockCheckPublicAccess = checkPublicAccess as jest.MockedFunction; +const mockCheckImageRenderingAllowed = checkImageRenderingAllowed as jest.MockedFunction< + typeof checkImageRenderingAllowed +>; + +type SetupOptions = { + isPublic?: boolean; + hasImageRenderer?: boolean; + imageRenderingAllowed?: boolean; + settingsData?: unknown; +}; + +function setup(options: SetupOptions = {}) { + const { isPublic = true, hasImageRenderer = true, imageRenderingAllowed = true, settingsData } = options; + + const data = settingsData ?? { allowImageRendering: imageRenderingAllowed }; + + mockCheckPublicAccess.mockReturnValue(isPublic); + mockCheckImageRenderer.mockReturnValue(hasImageRenderer); + mockCheckImageRenderingAllowed.mockReturnValue(imageRenderingAllowed); + mockUseGetFrontendSettingsQuery.mockReturnValue({ data } as never); + + const registerMock = jest.fn().mockReturnValue({}); + + const renderResult = render( + + } /> + + ); + + return { renderResult, registerMock }; +} + +describe('ConfigFormGithubCollapse', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when image rendering is not allowed on a public instance', () => { + const { renderResult } = setup({ imageRenderingAllowed: false, isPublic: true }); + + expect(renderResult.container).toBeEmptyDOMElement(); + expect(screen.queryByText('GitHub features')).not.toBeInTheDocument(); + }); + + it('renders preview checkbox when image rendering is allowed', () => { + const { registerMock } = setup({ imageRenderingAllowed: true, isPublic: true, hasImageRenderer: true }); + + expect(screen.getByText('GitHub features')).toBeInTheDocument(); + const checkbox = screen.getByRole('checkbox', { + name: /Enable dashboard previews in pull requests/i, + }); + expect(checkbox).toBeEnabled(); + expect(registerMock).toHaveBeenCalledWith('generateDashboardPreviews'); + }); + + it('disables preview checkbox when image renderer is unavailable', () => { + setup({ hasImageRenderer: false }); + + const checkbox = screen.getByRole('checkbox', { + name: /Enable dashboard previews in pull requests/i, + }); + expect(checkbox).toBeDisabled(); + }); + + it('disables preview checkbox and shows realtime feedback info on private instances', () => { + setup({ isPublic: false, imageRenderingAllowed: true }); + + const checkbox = screen.getByRole('checkbox', { + name: /Enable dashboard previews in pull requests/i, + }); + expect(checkbox).toBeDisabled(); + expect(screen.getByRole('link', { name: 'Configure webhooks' })).toBeInTheDocument(); + }); + + it('hides preview checkbox when image rendering is not allowed', () => { + setup({ imageRenderingAllowed: false, isPublic: false }); + + expect( + screen.queryByRole('checkbox', { + name: /Enable dashboard previews in pull requests/i, + }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx b/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx index 3c397fefea0..1d77c9ad89b 100644 --- a/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx +++ b/public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx @@ -1,7 +1,7 @@ import { UseFormRegister } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; -import { Checkbox, ControlledCollapse, Field, Text, TextLink } from '@grafana/ui'; +import { Checkbox, ControlledCollapse, Field, Stack, Text, TextLink } from '@grafana/ui'; import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; import { checkImageRenderer, checkPublicAccess, checkImageRenderingAllowed } from '../GettingStarted/features'; @@ -18,54 +18,64 @@ export function ConfigFormGithubCollapse({ register }: ConfigFormGithubCollapseP const hasImageRenderer = checkImageRenderer(); const imageRenderingAllowed = checkImageRenderingAllowed(settings.data); + if (!imageRenderingAllowed && isPublic) { + // don't display the whole collapse if neither feature is applicable + return null; + } + return ( - {imageRenderingAllowed && ( - - - - Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will be - shared in your Git repository and visible to anyone with repository access. - {' '} - - - Requires image rendering.{' '} - - Set up image rendering - - - - - } - {...register('generateDashboardPreviews')} - /> - - )} + + {imageRenderingAllowed && ( + + + + Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will + be shared in your Git repository and visible to anyone with repository access. + {' '} + + + Requires image rendering.{' '} + + Set up image rendering + + + + + } + {...register('generateDashboardPreviews')} + /> + + )} - {!isPublic && ( - - - - - Configure webhooks - {' '} - to get instant updates in Grafana as soon as changes are committed. Review and approve changes using pull - requests before they go live. - - - - )} + {!isPublic && ( + + + + + Configure webhooks + {' '} + to get instant updates in Grafana as soon as changes are committed. Review and approve changes using + pull requests before they go live. + + + + )} + ); } From a17d5a75fefd4ea47f6dc709a61e7bed973755aa Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 5 Nov 2025 08:59:26 -0500 Subject: [PATCH 031/209] FolderActionsButton: Provisioned folder should hide "Manage Permission" folder action (#113367) * FolderActionsButton: Hide Manage permission option when folder is git provisioned --- .../components/FolderActionsButton.test.tsx | 44 +++++++++++++++++++ .../components/FolderActionsButton.tsx | 4 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.test.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.test.tsx index 2a546571934..15c9470edf0 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.test.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { TestProvider } from 'test/helpers/TestProvider'; import { appEvents } from 'app/core/core'; +import { ManagerKind } from 'app/features/apiserver/types'; import { ShowModalReactEvent } from 'app/types/events'; import { mockFolderDTO } from '../fixtures/folder.fixture'; @@ -152,4 +153,47 @@ describe('browse-dashboards FolderActionsButton', () => { ) ); }); + + // Git sync related tests + it('does not render the "Manage permissions" option if folder is provisioned', async () => { + jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => { + return { + ...mockPermissions, + canViewPermissions: false, + }; + }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Folder actions' })); + expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('does not render the "Move" option if folder is provisioned and is root repo folder', async () => { + jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => { + return { + ...mockPermissions, + canViewPermissions: false, + }; + }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Folder actions' })); + expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('does render the "Move" option if folder is provisioned and is NOT root repo folder', async () => { + jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => { + return { + ...mockPermissions, + canViewPermissions: false, + }; + }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Folder actions' })); + expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument(); + }); }); diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index 2fc2a30a32f..03e54a24122 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -131,7 +131,9 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) const menu = ( - {canViewPermissions && setShowPermissionsDrawer(true)} label={managePermissionsLabel} />} + {canViewPermissions && !isProvisionedFolder && ( + setShowPermissionsDrawer(true)} label={managePermissionsLabel} /> + )} {canMoveFolder && !isReadOnlyRepo && ( Date: Wed, 5 Nov 2025 14:49:47 +0000 Subject: [PATCH 032/209] Trace View: Span tree style updates (#113095) * Style tweaks of span bar row * More tweaks * More tweaks to span style * 2px width for the service color border * Refactor SpanBarRow to function component * Refactor SpanDetailRow to function component * Refactor SpanTreeOffset to function component * eslint ignore * Service name font weight to 500 * Don't render the last indentGuide if the span doens't have children * Fix tests --- .../fixtures/long-trace-response-backend.json | 64 ++- .../trace-view-scrolling.spec.ts | 14 +- eslint-suppressions.json | 20 - .../TraceTimelineViewer/SpanBar.tsx | 8 +- .../TraceTimelineViewer/SpanBarRow.tsx | 535 +++++++++--------- .../SpanDetailRow.test.tsx | 4 +- .../TraceTimelineViewer/SpanDetailRow.tsx | 172 +++--- .../TraceTimelineViewer/SpanLinks.tsx | 5 +- .../SpanTreeOffset.test.tsx | 33 +- .../TraceTimelineViewer/SpanTreeOffset.tsx | 165 +++--- 10 files changed, 550 insertions(+), 470 deletions(-) diff --git a/e2e/cypress/fixtures/long-trace-response-backend.json b/e2e/cypress/fixtures/long-trace-response-backend.json index 5605ab1dd7c..56163b4b3d5 100644 --- a/e2e/cypress/fixtures/long-trace-response-backend.json +++ b/e2e/cypress/fixtures/long-trace-response-backend.json @@ -167,6 +167,16 @@ "3fa414edcef6ad90", "3fa414edcef6ad90", "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", + "3fa414edcef6ad90", "3fa414edcef6ad90" ], [ @@ -289,7 +299,17 @@ "0000000000000074", "0000000000000075", "0000000000000076", - "0000000000000077" + "0000000000000077", + "0000000000000078", + "0000000000000079", + "000000000000007a", + "000000000000007b", + "000000000000007c", + "000000000000007d", + "000000000000007e", + "000000000000007f", + "0000000000000080", + "0000000000000081" ], [ "", @@ -411,7 +431,17 @@ "0000000000000039", "000000000000003a", "000000000000003a", - "000000000000003b" + "000000000000003b", + "000000000000003b", + "0000000000000078", + "0000000000000078", + "0000000000000079", + "0000000000000079", + "000000000000007a", + "000000000000007a", + "000000000000007b", + "000000000000007b", + "000000000000007c" ], [ "GET /api/health", @@ -533,7 +563,17 @@ "GET /rollback/prepare", "POST /commit/save", "GET /branch/merge", - "POST /tag/create" + "POST /tag/create", + "GET /release/notes", + "POST /changelog/generate", + "GET /documentation/build", + "POST /test/run", + "GET /coverage/report", + "POST /artifact/publish", + "GET /registry/pull", + "POST /image/push", + "GET /manifest/inspect", + "POST /deployment/verify" ], [ "api-gateway", @@ -655,7 +695,17 @@ "rollback-mgr", "git-api", "merge-api", - "tag-api" + "tag-api", + "release-api", + "changelog-gen", + "doc-builder", + "test-runner", + "coverage-tool", + "artifact-mgr", + "registry-client", + "image-pusher", + "manifest-parser", + "deploy-verifier" ], [ 1579270400000, 1579270401000, 1579270402000, 1579270403000, 1579270404000, 1579270405000, 1579270406000, @@ -675,7 +725,8 @@ 1579270498000, 1579270499000, 1579270500000, 1579270501000, 1579270502000, 1579270503000, 1579270504000, 1579270505000, 1579270506000, 1579270507000, 1579270508000, 1579270509000, 1579270510000, 1579270511000, 1579270512000, 1579270513000, 1579270514000, 1579270515000, 1579270516000, 1579270517000, 1579270518000, - 1579270519000 + 1579270519000, 1579270520000, 1579270521000, 1579270522000, 1579270523000, 1579270524000, 1579270525000, + 1579270526000, 1579270527000, 1579270528000, 1579270529000 ], [ 100000, 50000, 75000, 200000, 25000, 150000, 80000, 300000, 45000, 120000, 60000, 180000, 35000, 250000, @@ -686,7 +737,8 @@ 190000, 58000, 145000, 92000, 220000, 48000, 175000, 85000, 205000, 65000, 180000, 72000, 195000, 55000, 160000, 98000, 225000, 62000, 140000, 78000, 200000, 48000, 165000, 85000, 215000, 58000, 150000, 88000, 230000, 45000, 175000, 72000, 185000, 62000, 155000, 95000, 210000, 52000, 170000, 82000, 195000, 68000, - 145000, 92000, 235000, 58000, 160000, 75000, 180000, 65000 + 145000, 92000, 235000, 58000, 160000, 75000, 180000, 65000, 125000, 78000, 190000, 52000, 165000, 88000, + 215000, 62000, 155000, 95000 ] ] } diff --git a/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts b/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts index f73c3c6f2c1..372c2d11032 100644 --- a/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts +++ b/e2e/old-arch/various-suite/trace-view-scrolling.spec.ts @@ -33,15 +33,15 @@ describe('Trace view', () => { e2e.components.TraceViewer.spanBar().should('be.visible'); + e2e.components.TraceViewer.spanBar().its('length').should('be.equal', 100); + + e2e.pages.Explore.General.scrollView().children().first().scrollTo('bottom'); + + // After scrolling we should see 50 spans e2e.components.TraceViewer.spanBar() .its('length') - .then((oldLength) => { - e2e.pages.Explore.General.scrollView().children().first().scrollTo('center'); - - // After scrolling we should load more spans - e2e.components.TraceViewer.spanBar().should(($span) => { - expect($span.length).to.be.gt(oldLength); - }); + .should(($span) => { + expect($span).to.be.equal(50); }); }); }); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 6392492ae77..5456b0d38dd 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2886,31 +2886,11 @@ "count": 1 } }, - "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx": { "react-hooks/rules-of-hooks": { "count": 1 } }, - "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, - "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx index 0e447aa0ce0..77981605295 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBar.tsx @@ -45,11 +45,11 @@ const getStyles = (theme: GrafanaTheme2) => { }), bar: css({ label: 'bar', - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, minWidth: '2px', position: 'absolute', - height: '36%', - top: '32%', + height: '40%', + top: '30%', }), rpc: css({ label: 'rpc', @@ -93,7 +93,7 @@ const getStyles = (theme: GrafanaTheme2) => { }), criticalPath: css({ position: 'absolute', - top: '45%', + top: '44%', height: '11%', zIndex: 2, overflow: 'hidden', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx index 778743894cb..4a27ce2105e 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx @@ -41,7 +41,7 @@ const nameWrapperMatchingFilterClassName = 'nameWrapperMatchingFilter'; const viewClassName = 'jaegerView'; const nameColumnClassName = 'nameColumn'; -const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly: boolean) => { +const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly: boolean, serviceColor: string) => { const animations = { flash: keyframes` from { @@ -60,10 +60,18 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly lineHeight: '27px', overflow: 'hidden', display: 'flex', + + [`& > *`]: { + background: theme.colors.background.secondary, + }, }), nameWrapperMatchingFilter: css({ label: 'nameWrapperMatchingFilter', backgroundColor: backgroundColor, + + [`& > *`]: { + background: backgroundColor, + }, }), nameColumn: css({ label: 'nameColumn', @@ -96,6 +104,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly row: css({ label: 'row', fontSize: '0.9em', + [`&:hover .${spanBarClassName}`]: { opacity: 1, }, @@ -114,6 +123,11 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly backgroundColor: autoColor(theme, '#f5f5f5'), outline: `1px solid ${autoColor(theme, '#ddd')}`, }, + ['& .icon-wrapper']: { + borderBottomColor: `${serviceColor}CF`, + borderBottomWidth: '2px', + borderBottomStyle: 'solid', + }, }), rowClippingLeft: css({ label: 'rowClippingLeft', @@ -168,7 +182,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly }), rowMatchingFilter: css({ label: 'rowMatchingFilter', - // background-color: ${autoColor(theme, '#fffbde')}; + [`&:hover .${nameWrapperClassName}`]: { background: `linear-gradient( 90deg, @@ -200,9 +214,22 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly [`& .${spanBarLabelClassName}`]: { color: autoColor(theme, '#000'), }, - ['&:hover .${nameWrapperClassName}, :hover .${viewClassName}']: { - background: autoColor(theme, '#d5ebff'), - boxShadow: `0 1px 0 ${autoColor(theme, '#ddd')}`, + }), + + rowError: css({ + label: 'rowError', + backgroundColor: theme.colors.error.transparent, + + [`&:hover .${nameWrapperClassName}`]: { + background: theme.colors.error.borderTransparent, + }, + [`&:hover .${viewClassName}`]: { + backgroundColor: theme.colors.error.borderTransparent, + outline: `1px solid ${theme.colors.error.borderTransparent}`, + }, + + [`& .${nameWrapperClassName} > *`]: { + background: theme.colors.error.transparent, }, }), @@ -221,8 +248,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly outline: 'none', overflowY: 'hidden', overflowX: 'auto', - paddingLeft: '4px', - paddingRight: '0.25em', + padding: '4px', position: 'relative', '-ms-overflow-style': 'none', scrollbarWidth: 'none', @@ -236,9 +262,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly color: autoColor(theme, '#000'), }, textAlign: 'left', - background: 'transparent', border: 'none', - borderBottomWidth: '1px', + borderBottomColor: `${serviceColor}CF`, + borderBottomWidth: '2px', borderBottomStyle: 'solid', }), nameDetailExpanded: css({ @@ -250,27 +276,25 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly svcName: css({ label: 'svcName', fontSize: '0.9em', - fontWeight: 'bold', + fontWeight: '500', marginRight: '0.25rem', }), svcNameChildrenCollapsed: css({ label: 'svcNameChildrenCollapsed', - fontWeight: 'bold', + fontWeight: '500', fontStyle: 'italic', }), errorIcon: css({ label: 'errorIcon', - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: '6.5px', + borderRadius: theme.shape.radius.md, color: autoColor(theme, '#fff'), - fontSize: '0.85em', + fontSize: '0.6em', marginRight: '0.25rem', padding: '1px', }), rpcColorMarker: css({ label: 'rpcColorMarker', - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: '6.5px', + borderRadius: theme.shape.radius.md, display: 'inline-block', fontSize: '0.85em', height: '1em', @@ -335,257 +359,252 @@ export type SpanBarRowProps = { criticalPath: CriticalPathSection[]; }; -/** - * This was originally a stateless function, but changing to a PureComponent - * reduced the render time of expanding a span row detail by ~50%. This is - * even true in the case where the stateless function has the same prop types as - * this class and arrow functions are created in the stateless function as - * handlers to the onClick props. E.g. for now, the PureComponent is more - * performance than the stateless function. - */ -export class UnthemedSpanBarRow extends React.PureComponent { - static displayName = 'UnthemedSpanBarRow'; - static defaultProps: Partial = { - className: '', - rpc: null, - }; +const UnthemedSpanBarRow = React.memo((props) => { + const { + className = '', + color, + spanBarOptions, + columnDivision, + isChildrenExpanded, + isDetailExpanded, + isMatchingFilter, + showSpanFilterMatchesOnly, + isFocused, + numTicks, + rpc = null, + noInstrumentedServer, + showErrorIcon, + getViewedBounds, + traceStartTime, + span, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + clippingLeft, + clippingRight, + theme, + createSpanLink, + datasourceType, + showServiceName, + visibleSpanIds, + criticalPath, + onDetailToggled, + onChildrenToggled, + } = props; - _detailToggle = () => { - this.props.onDetailToggled(this.props.span.spanID); - }; + const { + duration, + hasChildren: isParent, + operationName, + process: { serviceName }, + } = span; + const label = formatDuration(duration); - _childrenToggle = () => { - this.props.onChildrenToggled(this.props.span.spanID); - }; + const viewBounds = getViewedBounds(span.startTime, span.startTime + span.duration); + const viewStart = viewBounds.start; + const viewEnd = viewBounds.end; + const styles = getStyles(theme, showSpanFilterMatchesOnly, color); - render() { - const { - className, - color, - spanBarOptions, - columnDivision, - isChildrenExpanded, - isDetailExpanded, - isMatchingFilter, - showSpanFilterMatchesOnly, - isFocused, - numTicks, - rpc, - noInstrumentedServer, - showErrorIcon, - getViewedBounds, - traceStartTime, - span, - hoverIndentGuideIds, - addHoverIndentGuideId, - removeHoverIndentGuideId, - clippingLeft, - clippingRight, - theme, - createSpanLink, - datasourceType, - showServiceName, - visibleSpanIds, - criticalPath, - } = this.props; - const { - duration, - hasChildren: isParent, - operationName, - process: { serviceName }, - } = span; - const label = formatDuration(duration); - - const viewBounds = getViewedBounds(span.startTime, span.startTime + span.duration); - const viewStart = viewBounds.start; - const viewEnd = viewBounds.end; - const styles = getStyles(theme, showSpanFilterMatchesOnly); - - const labelDetail = `${serviceName}::${operationName}`; - let longLabel; - let hintClassName; - if (viewStart > 1 - viewEnd) { - longLabel = `${labelDetail} | ${label}`; - hintClassName = styles.labelLeft; - } else { - longLabel = `${label} | ${labelDetail}`; - hintClassName = styles.labelRight; - } - - return ( - - -
- - - {createSpanLink && - (() => { - const links = createSpanLink(span); - const count = links?.length || 0; - if (links && count === 1) { - if (!links[0]) { - return null; - } - - return ( - { - if (!(event.ctrlKey || event.metaKey || event.shiftKey) && links[0].onClick) { - event.preventDefault(); - links[0].onClick(event); - } - } - : undefined - } - > - {links[0].content} - - ); - } else if (links && count > 1) { - return ; - } else { - return null; - } - })()} -
-
- - - - -
- ); + const labelDetail = `${serviceName}::${operationName}`; + let longLabel; + let hintClassName; + if (viewStart > 1 - viewEnd) { + longLabel = `${labelDetail} | ${label}`; + hintClassName = styles.labelLeft; + } else { + longLabel = `${label} | ${labelDetail}`; + hintClassName = styles.labelRight; } - getSpanBarLabel = (span: TraceSpan, spanBarOptions: SpanBarOptions | undefined, duration: string) => { - const type = spanBarOptions?.type ?? ''; + const handleDetailToggle = React.useCallback(() => { + onDetailToggled(span.spanID); + }, [onDetailToggled, span.spanID]); - if (type === NONE) { - return ''; - } else if (type === '' || type === DURATION) { - return `(${duration})`; - } else if (type === TAG) { - const tagKey = spanBarOptions?.tag?.trim() ?? ''; - if (tagKey !== '' && span.tags) { - const tag = span.tags?.find((tag: TraceKeyValuePair) => { - return tag.key === tagKey; - }); - if (tag) { - return `(${tag.value})`; - } + const handleChildrenToggle = React.useCallback(() => { + onChildrenToggled(span.spanID); + }, [onChildrenToggled, span.spanID]); - const process = span.process?.tags?.find((process: TraceKeyValuePair) => { - return process.key === tagKey; - }); - if (process) { - return `(${process.value})`; + const getSpanBarLabel = React.useCallback( + (span: TraceSpan, spanBarOptions: SpanBarOptions | undefined, duration: string) => { + const type = spanBarOptions?.type ?? ''; + + if (type === NONE) { + return ''; + } else if (type === '' || type === DURATION) { + return `(${duration})`; + } else if (type === TAG) { + const tagKey = spanBarOptions?.tag?.trim() ?? ''; + if (tagKey !== '' && span.tags) { + const tag = span.tags?.find((tag: TraceKeyValuePair) => { + return tag.key === tagKey; + }); + if (tag) { + return `(${tag.value})`; + } + + const process = span.process?.tags?.find((process: TraceKeyValuePair) => { + return process.key === tagKey; + }); + if (process) { + return `(${process.value})`; + } } } - } - return ''; - }; -} + return ''; + }, + [] + ); + + return ( + + +
+ + + {createSpanLink && + (() => { + const links = createSpanLink(span); + const count = links?.length || 0; + if (links && count === 1) { + if (!links[0]) { + return null; + } + + return ( + { + if (!(event.ctrlKey || event.metaKey || event.shiftKey) && links[0].onClick) { + event.preventDefault(); + links[0].onClick(event); + } + } + : undefined + } + > + {links[0].content} + + ); + } else if (links && count > 1) { + return ; + } else { + return null; + } + })()} +
+
+ + + + +
+ ); +}); + +UnthemedSpanBarRow.displayName = 'UnthemedSpanBarRow'; export default withTheme2(UnthemedSpanBarRow); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx index c4c94b9b102..c4edb95f7d2 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx @@ -18,7 +18,7 @@ import { createTheme, dateTime } from '@grafana/data'; import { setPluginLinksHook } from '@grafana/runtime'; import DetailState from './SpanDetail/DetailState'; -import { UnthemedSpanDetailRow, SpanDetailRowProps } from './SpanDetailRow'; +import SpanDetailRow, { type SpanDetailRowProps } from './SpanDetailRow'; const testSpan = { spanID: 'testSpanID', @@ -57,7 +57,7 @@ const setup = (propOverrides?: SpanDetailRowProps) => { }, ...propOverrides, }; - return render(); + return render(); }; describe('SpanDetailRow tests', () => { diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx index 201f0570b27..7312daaccab 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx @@ -13,7 +13,7 @@ // limitations under the License. import { css } from '@emotion/css'; -import { PureComponent } from 'react'; +import React from 'react'; import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceLog } from '@grafana/data'; import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; @@ -117,95 +117,93 @@ export type SpanDetailRowProps = { app: CoreApp; }; -export class UnthemedSpanDetailRow extends PureComponent { - _detailToggle = () => { - this.props.onDetailToggled(this.props.span.spanID); - }; +const UnthemedSpanDetailRow = React.memo((props) => { + const { + color, + detailState, + logItemToggle, + logsToggle, + processToggle, + referenceItemToggle, + referencesToggle, + warningsToggle, + stackTracesToggle, + span, + traceToProfilesOptions, + timeZone, + tagsToggle, + traceStartTime, + traceDuration, + traceName, + theme, + createSpanLink, + focusedSpanId, + createFocusSpanLink, + datasourceType, + datasourceUid, + traceFlameGraphs, + setTraceFlameGraphs, + setRedrawListView, + timeRange, + app, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + visibleSpanIds, + } = props; - render() { - const { - color, - detailState, - logItemToggle, - logsToggle, - processToggle, - referenceItemToggle, - referencesToggle, - warningsToggle, - stackTracesToggle, - span, - traceToProfilesOptions, - timeZone, - tagsToggle, - traceStartTime, - traceDuration, - traceName, - theme, - createSpanLink, - focusedSpanId, - createFocusSpanLink, - datasourceType, - datasourceUid, - traceFlameGraphs, - setTraceFlameGraphs, - setRedrawListView, - timeRange, - app, - hoverIndentGuideIds, - addHoverIndentGuideId, - removeHoverIndentGuideId, - visibleSpanIds, - } = this.props; - const styles = getStyles(theme); - return ( - - -
- + +
+ +
+
+
+
-
-
- -
-
- - - ); - } -} +
+
+ + ); +}); + +UnthemedSpanDetailRow.displayName = 'UnthemedSpanDetailRow'; export default withTheme2(UnthemedSpanDetailRow); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx index 4f2f68f9c5b..4d653f60360 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx @@ -18,7 +18,8 @@ const renderMenuItems = ( closeMenu: () => void, datasourceType: string ) => { - links.sort(function (linkA, linkB) { + links.sort((linkA, linkB) => { + // eslint-disable-next-line no-restricted-syntax return (linkA.title || 'link').toLowerCase().localeCompare((linkB.title || 'link').toLowerCase()); }); @@ -88,7 +89,7 @@ const getStyles = (color: string) => ({ border: 'none', background: `${color}10`, borderBottom: `1px solid ${color}CF`, - paddingRight: '4px', + paddingInline: '4px', }), button: css({ background: 'transparent', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx index 463b2035841..e9c0ad9f9ad 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.test.tsx @@ -47,15 +47,32 @@ describe('SpanTreeOffset', () => { }); describe('.SpanTreeOffset--indentGuide', () => { - it('renders only one SpanTreeOffset--indentGuide for entire trace if span has no ancestors', () => { + it('renders no indentGuide if span has no ancestors and no children', () => { jest.mocked(spanAncestorIdsSpy).mockReturnValue([]); render(); + const indentGuides = screen.queryAllByTestId('SpanTreeOffset--indentGuide'); + expect(indentGuides.length).toBe(0); + }); + + it('renders only one SpanTreeOffset--indentGuide for entire trace if span has no ancestors but has children', () => { + jest.mocked(spanAncestorIdsSpy).mockReturnValue([]); + props.span.hasChildren = true; + render(); const indentGuide = screen.getByTestId('SpanTreeOffset--indentGuide'); expect(indentGuide).toBeInTheDocument(); expect(indentGuide).toHaveAttribute('data-ancestor-id', specialRootID); }); - it('renders one SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace', () => { + it('renders one SpanTreeOffset--indentGuide per ancestor span when span has no children', () => { + render(); + const indentGuides = screen.getAllByTestId('SpanTreeOffset--indentGuide'); + expect(indentGuides.length).toBe(2); + expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID); + expect(indentGuides[1]).toHaveAttribute('data-ancestor-id', rootSpanID); + }); + + it('renders one SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace when span has children', () => { + props.span.hasChildren = true; render(); const indentGuides = screen.getAllByTestId('SpanTreeOffset--indentGuide'); expect(indentGuides.length).toBe(3); @@ -65,28 +82,28 @@ describe('SpanTreeOffset', () => { }); it('adds .is-active to correct indentGuide', () => { - props.hoverIndentGuideIds = new Set([parentSpanID]); + props.hoverIndentGuideIds = new Set([rootSpanID]); render(); const styles = getStyles(createTheme()); const activeIndentGuide = document.querySelector(`.${styles.indentGuideActive}`); expect(activeIndentGuide).toBeInTheDocument(); - expect(activeIndentGuide).toHaveAttribute('data-ancestor-id', parentSpanID); + expect(activeIndentGuide).toHaveAttribute('data-ancestor-id', rootSpanID); }); it('calls props.addHoverIndentGuideId on mouse enter', async () => { render(); - const span = document.querySelector(`[data-ancestor-id=${parentSpanID}]`); + const span = document.querySelector(`[data-ancestor-id=${rootSpanID}]`); await userEvent.hover(span!); expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1); - expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID); + expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(rootSpanID); }); it('calls props.removeHoverIndentGuideId on mouse leave', async () => { render(); - const span = document.querySelector(`[data-ancestor-id=${parentSpanID}]`); + const span = document.querySelector(`[data-ancestor-id=${rootSpanID}]`); await userEvent.unhover(span!); expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1); - expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(rootSpanID); }); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx index 72a985faad4..1d2bea3105e 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx @@ -64,6 +64,8 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ label: 'iconWrapper', position: 'absolute', right: 0, + height: '100%', + paddingTop: '1px', }), })); @@ -80,26 +82,27 @@ export type TProps = { visibleSpanIds: string[]; }; -export class UnthemedSpanTreeOffset extends React.PureComponent { - static displayName = 'UnthemedSpanTreeOffset'; +const UnthemedSpanTreeOffset = React.memo((props) => { + const { + childrenVisible = false, + showChildrenIcon = true, + onClick, + span, + theme, + visibleSpanIds, + hoverIndentGuideIds, + addHoverIndentGuideId, + removeHoverIndentGuideId, + } = props; - ancestorIds: string[]; - - static defaultProps = { - childrenVisible: false, - showChildrenIcon: true, - }; - - constructor(props: TProps) { - super(props); - - this.ancestorIds = spanAncestorIds(props.span); + const ancestorIds = React.useMemo(() => { + const ids = spanAncestorIds(span); // Some traces have multiple root-level spans, this connects them all under one guideline and adds the // necessary padding for the collapse icon on root-level spans. - this.ancestorIds.push('root'); - - this.ancestorIds.reverse(); - } + ids.push('root'); + ids.reverse(); + return ids; + }, [span]); /** * If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is @@ -109,14 +112,17 @@ export class UnthemedSpanTreeOffset extends React.PureComponent { * the element the user is now hovering. * @param {string} ancestorId - The span id that the user was hovering over. */ - handleMouseLeave = (event: React.MouseEvent, ancestorId: string) => { - if ( - !(event.relatedTarget instanceof HTMLSpanElement) || - _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId - ) { - this.props.removeHoverIndentGuideId(ancestorId); - } - }; + const handleMouseLeave = React.useCallback( + (event: React.MouseEvent, ancestorId: string) => { + if ( + !(event.relatedTarget instanceof HTMLSpanElement) || + _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId + ) { + removeHoverIndentGuideId(ancestorId); + } + }, + [removeHoverIndentGuideId] + ); /** * If the mouse entered this span from anywhere except another span with the same ancestor id, this span's @@ -126,58 +132,65 @@ export class UnthemedSpanTreeOffset extends React.PureComponent { * the last element the user was hovering. * @param {string} ancestorId - The span id that the user is now hovering over. */ - handleMouseEnter = (event: React.MouseEvent, ancestorId: string) => { - if ( - !(event.relatedTarget instanceof HTMLSpanElement) || - _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId - ) { - this.props.addHoverIndentGuideId(ancestorId); - } - }; + const handleMouseEnter = React.useCallback( + (event: React.MouseEvent, ancestorId: string) => { + if ( + !(event.relatedTarget instanceof HTMLSpanElement) || + _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId + ) { + addHoverIndentGuideId(ancestorId); + } + }, + [addHoverIndentGuideId] + ); - render() { - const { childrenVisible, onClick, showChildrenIcon, span, theme, visibleSpanIds } = this.props; - const { hasChildren, spanID } = span; - const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null; - const icon = - showChildrenIcon && - hasChildren && - (childrenVisible ? ( - - ) : ( - - )); - const styles = getStyles(theme); + const { hasChildren, spanID } = span; + const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null; + const icon = + showChildrenIcon && + hasChildren && + (childrenVisible ? ( + + ) : ( + + )); + const styles = getStyles(theme); - return ( - - {this.ancestorIds.map((ancestorId, index) => ( - this.handleMouseEnter(event, ancestorId)} - onMouseLeave={(event) => this.handleMouseLeave(event, ancestorId)} - /> - ))} - {icon && ( - this.handleMouseEnter(event, spanID)} - onMouseLeave={(event) => this.handleMouseLeave(event, spanID)} - data-testid="icon-wrapper" - > - {icon} - - )} - - ); - } -} + // If span has no children, don't show the last indent guide + const displayedAncestorIds = hasChildren ? ancestorIds : ancestorIds.slice(0, -1); + + return ( + + {displayedAncestorIds.map((ancestorId, index) => ( + handleMouseEnter(event, ancestorId)} + onMouseLeave={(event) => handleMouseLeave(event, ancestorId)} + /> + ))} + {icon && ( + handleMouseEnter(event, spanID)} + onMouseLeave={(event) => handleMouseLeave(event, spanID)} + data-testid="icon-wrapper" + > + {icon} + + )} + + ); +}); + +UnthemedSpanTreeOffset.displayName = 'UnthemedSpanTreeOffset'; export default withTheme2(UnthemedSpanTreeOffset); From 8149f586b37da46bb3b89cd69b3d0ec9640e2109 Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:00:28 -0600 Subject: [PATCH 033/209] Annotations: Multi-lane annotations rendering (lane per frame) (#111559) * feat: support multi-lane annotations panel option --------- Co-authored-by: Leon Sorokin --- .../multi-lane-annotations.v42.json | 1024 +++++++++++++++++ .../annotations/multi-lane-annotations.json | 1020 ++++++++++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 1 + .../grafana-schema/src/common/common.gen.ts | 14 + .../grafana-schema/src/common/mudball.cue | 10 + .../x/CandlestickPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/HeatmapPanelCfg_types.gen.ts | 1 + .../x/StateTimelinePanelCfg_types.gen.ts | 2 +- .../x/StatusHistoryPanelCfg_types.gen.ts | 2 +- .../x/TimeSeriesPanelCfg_types.gen.ts | 2 +- .../uPlot/config/UPlotConfigBuilder.ts | 1 + .../app/core/components/GraphNG/GraphNG.tsx | 23 +- .../core/components/TimeSeries/TimeSeries.tsx | 12 +- .../core/components/TimeSeries/utils.test.ts | 25 +- .../app/core/components/TimeSeries/utils.ts | 31 + .../TimelineChart/TimelineChart.tsx | 15 +- .../core/components/TimelineChart/utils.ts | 3 +- .../panel/candlestick/CandlestickPanel.tsx | 3 + .../plugins/panel/candlestick/panelcfg.cue | 1 + .../plugins/panel/candlestick/panelcfg.gen.ts | 2 +- .../plugins/panel/heatmap/HeatmapPanel.tsx | 8 +- public/app/plugins/panel/heatmap/panelcfg.cue | 1 + .../app/plugins/panel/heatmap/panelcfg.gen.ts | 1 + public/app/plugins/panel/heatmap/utils.ts | 5 +- .../state-timeline/StateTimelinePanel.tsx | 4 + .../plugins/panel/state-timeline/panelcfg.cue | 1 + .../panel/state-timeline/panelcfg.gen.ts | 2 +- .../status-history/StatusHistoryPanel.tsx | 3 + .../plugins/panel/status-history/panelcfg.cue | 1 + .../panel/status-history/panelcfg.gen.ts | 2 +- .../panel/timeseries/TimeSeriesPanel.tsx | 3 + .../app/plugins/panel/timeseries/panelcfg.cue | 6 +- .../plugins/panel/timeseries/panelcfg.gen.ts | 2 +- .../timeseries/plugins/AnnotationsPlugin2.tsx | 120 +- .../panel/timeseries/plugins/utils.test.ts | 165 +++ .../plugins/panel/timeseries/plugins/utils.ts | 18 + 36 files changed, 2462 insertions(+), 74 deletions(-) create mode 100644 apps/dashboard/pkg/migration/testdata/dev-dashboards-output/annotations/multi-lane-annotations.v42.json create mode 100644 devenv/dev-dashboards/annotations/multi-lane-annotations.json create mode 100644 public/app/plugins/panel/timeseries/plugins/utils.test.ts create mode 100644 public/app/plugins/panel/timeseries/plugins/utils.ts diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/annotations/multi-lane-annotations.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/annotations/multi-lane-annotations.v42.json new file mode 100644 index 00000000000..f77cb68c01f --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/annotations/multi-lane-annotations.v42.json @@ -0,0 +1,1024 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "maxDataPoints": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "noise": 10, + "refId": "A", + "scenarioId": "random_walk", + "spread": 10 + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1720697881000,\n 1728580067000,\n 1730129182000,\n 1730734644000,\n 1732542118000,\n 1736426576000,\n 1736874473000,\n 1738676647000,\n 1740067713000,\n 1740684246000,\n 1743733609000,\n 1744034815000,\n 1745335229000,\n 1745936130000,\n 1746107356000,\n 1747055303000,\n 1747946736000,\n 1748446531000,\n 1750098297000,\n 1750963080000,\n 1750963096000,\n 1752067555000,\n 1752080606000,\n 1753125884000,\n 1754395568000,\n 1754407010000,\n 1756228450000,\n 1757947221000,\n 1759763050000,\n 1759924632000,\n 1761572602000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"timeEnd\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1729081505000,\n 1730129307000,\n 1730838127000,\n 1732543887000,\n 1736528756000,\n 1736874600000,\n 1740064375000,\n 1740170732000,\n 1742397235000,\n 1743733611000,\n 1744038997000,\n 1745341972000,\n 1745936141000,\n 1746118905000,\n 1747055307000,\n 1747853613000,\n 1748446520000,\n 1750187872000,\n 1750963071000,\n 1751038503000,\n 1752067563000,\n 1752166243000,\n 1753396603000,\n 1754395592000,\n 1754407004000,\n 1756228445000,\n 1758134868000,\n 1759763044000,\n 1761572592000,\n 1761572593000,\n null\n ],\n \"type\": \"number\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"0.1.0\",\n \"1.0.2\",\n \"1.0.3\",\n \"1.0.4\",\n \"1.0.5\",\n \"1.0.6\",\n \"1.0.7\",\n \"1.0.8\",\n \"1.0.9\",\n \"1.0.10\",\n \"1.0.11\",\n \"1.0.12\",\n \"1.0.13\",\n \"1.0.14\",\n \"1.0.15\",\n \"1.0.16\",\n \"1.0.17\",\n \"1.0.18\",\n \"1.0.19\",\n \"1.0.20\",\n \"1.0.21\",\n \"1.0.22\",\n \"1.0.23\",\n \"1.0.24\",\n \"1.0.25\",\n \"1.0.26\",\n \"1.0.27\",\n \"1.0.28\",\n \"1.0.29\",\n \"1.0.30\",\n \"1.0.31\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n false\n ],\n \"type\": \"boolean\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"red\",\n \"mappings\": {\n \"text\": {\n \"source\": \"field\",\n \"value\": \"closed\"\n },\n \"time\": {\n \"source\": \"field\",\n \"value\": \"created_at\"\n },\n \"timeEnd\": {\n \"source\": \"field\",\n \"value\": \"closed_at\"\n },\n \"title\": {\n \"source\": \"field\",\n \"value\": \"title\"\n }\n },\n \"name\": \"Milestones\",\n \"target\": {\n \"options\": {\n \"query\": \"\"\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Milestones\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n false\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 31,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n },\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Issues\",\n \"Issues\",\n \"Issues\",\n \"Issues\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#FADE2A\",\n \"#FADE2A\",\n \"#FADE2A\",\n \"#FADE2A\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760112753000,\n 1759930212000,\n 1759924899000,\n 1759850404000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"timeEnd\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760972421000,\n null,\n null,\n 1759926839000\n ],\n \"type\": \"number\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"[BUG]: Error when data source configured for multi tenant queries\",\n \"[FEAT]: Allow users without explore permissions to use logs drilldown\",\n \"Sync Logs panel and label/fields empty states\",\n \"fix: labels clear variable state not showing\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"[BUG]: Error when data source configured for multi tenant queries\",\n \"[FEAT]: Allow users without explore permissions to use logs drilldown\",\n \"Sync Logs panel and label/fields empty states\",\n \"fix: labels clear variable state not showing\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"tags\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n [\n \"bug\",\n \"needs-triage\"\n ],\n [\n \"enhancement\",\n \"needs-triage\"\n ],\n [\n \"needs-triage\"\n ],\n [\n \"bug\"\n ]\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"yellow\",\n \"mappings\": {\n \"tags\": {\n \"source\": \"field\",\n \"value\": \"labels\"\n },\n \"time\": {\n \"source\": \"field\",\n \"value\": \"created_at\"\n },\n \"timeEnd\": {\n \"source\": \"field\",\n \"value\": \"closed_at\"\n }\n },\n \"name\": \"Issues\",\n \"target\": {\n \"options\": {\n \"query\": \"\"\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Issues\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n false,\n false,\n true\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 4,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n },\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Pulls\",\n \"Pulls\",\n \"Pulls\",\n \"Pulls\",\n \"Pulls\",\n \"Pulls\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1759941908000,\n 1759854730000,\n 1759852733000,\n 1759841416000,\n 1759786844000,\n 1759762979000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"timeEnd\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760110687000,\n 1759857566000,\n 1759926838000,\n 1759843529000,\n 1759848237000,\n 1759769543000\n ],\n \"type\": \"number\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"feat(EmptyLogs): add button to fix with assistant\",\n \"docs: Update troubleshooting page\",\n \"fix: unexpected clear variable behavior\",\n \"docs: update stale readme, fix docker install script\",\n \"docs: Update install and troubleshooting\",\n \"fix: fix RegExp.source removing flags, use toString instead\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"feat(EmptyLogs): add button to fix with assistant\",\n \"docs: Update troubleshooting page\",\n \"fix: unexpected clear variable behavior\",\n \"docs: update stale readme, fix docker install script\",\n \"docs: Update install and troubleshooting\",\n \"fix: fix RegExp.source removing flags, use toString instead\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"blue\",\n \"mappings\": {\n \"time\": {\n \"source\": \"field\",\n \"value\": \"created_at\"\n },\n \"timeEnd\": {\n \"source\": \"field\",\n \"value\": \"merged_at\"\n }\n },\n \"name\": \"Pulls\",\n \"target\": {\n \"options\": {\n \"query\": \"\",\n \"timeField\": 1\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Pull_Requests\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n true,\n true,\n true,\n true,\n true\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 6,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n },\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760349982000,\n 1760110687000,\n 1759950041000,\n 1759948586000,\n 1759933371000,\n 1759926838000,\n 1759857566000,\n 1759850172000,\n 1759848237000,\n 1759843529000,\n 1759769543000,\n 1759762725000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Matias Chomicki\",\n \"Matias Chomicki\",\n \"github-actions[bot]\",\n \"Liza Detrick\",\n \"grafana-plugins-platform-bot[bot]\",\n \"Galen Kistler\",\n \"J Stickler\",\n \"Piotr Jamróz\",\n \"J Stickler\",\n \"Galen Kistler\",\n \"Galen Kistler\",\n \"Galen Kistler\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"feat(LogsListScene): add defaultDisplayedFields support (#1554)\\n\\n* feat(LogsListScene): add defaultDisplayedFields support\\n\\n* chore: update mock\\n\\n* fix(Table): omit unsupported field\\n\\n* fix(LogListScene): reset default displayed fields when switching viz\\n\\n* chore: lint\",\n \"feat(EmptyLogs): add button to fix with assistant (#1571)\\n\\n* feat(LogListScene): add error type to identify empty results\\n\\n* feat(LogsPanelError): use assistant to investigate no results\\n\\n* fix(Shoo): shoo\\n\\n* feat(EmbeddedLogs): customize empty logs prompt\\n\\n* feat(LogsPanelError): implement custom CTA\\n\\n* chore: spelling\\n\\n* chore: add unit test\\n\\n* chore: remove it.only\\n\\n* fix(variableHelpers): exclude primary label from variables to clear\\n\\n* feat(embedding): refactor options\\n\\n* feat(assistant): create service\\n\\n* feat(assistant): integrate with empty layout and no matching labels\\n\\n* chore: fix types\\n\\n* chore: update tests\\n\\n* chore: remove quotes\\n\\n* chore: prevent access to an undefined object\\n\\n* chore: move label to constants\\n\\n* Revert \\\"chore: move label to constants\\\"\\n\\nThis reverts commit fbdf96415e5b2af7a5cae4eec6e6d5a30bc05d1c.\\n\\n* chore: use name instead of label\\n\\n* Revert \\\"fix(Shoo): shoo\\\"\\n\\nThis reverts commit 75993f7a845b514cb5da9667e94a2f0a3c913f24.\\n\\n* chore: more unit tests\\n\\n* test(variableHelpers): test getVariablesThatCanBeCleared\",\n \"chore: bump @grafana/create-plugin configuration to 5.26.9 (#1559)\",\n \"feat(table): preferences (#1534)\",\n \"chore(version): bump version to v1.0.29\",\n \"fix: unexpected clear variable behavior (#1567)\\n\\n* fix: show clear ui when query contains clearable label filters\",\n \"docs: Update troubleshooting page (#1568)\",\n \"fix: validate primary label correctly (#1561)\\n\\n* fix: properly clear variables for legacy urls\",\n \"docs: Update install and troubleshooting (#1564)\\n\\nCo-authored-by: Galen Kistler \u003c109082771+gtk-grafana@users.noreply.github.com\u003e\",\n \"docs: update stale readme, fix docker install script (#1565)\\n\\n* docs: update stale readme\\n\\n* chore: fix docker script\",\n \"fix: fix RegExp.source removing flags, use toString instead (#1563)\\n\\n* fix: fix RegExp.source removing flags, use toString instead\",\n \"fix: stale urls (#1562)\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"id\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"cf08043276597abe16f4ae9dbe97bd988d31b7ed\",\n \"01e343dacde3b9fcd862ec2cb2b04fbea4eb6d63\",\n \"9e67734938b980a80496d4cdc60efe7dbc4ba74e\",\n \"924ebd235b6ea99d297d8e04e08ac8ea90a32968\",\n \"23b9b5dc0504a4c397b326562cb796cfdc0531df\",\n \"089fdfa26c7daad5543be1aae2377850348663c3\",\n \"437a568a7a7279c436a674c7b1b0d11a4971d47b\",\n \"ee12a20b32db26b82fe32ed9d5868bd2dde4a4d0\",\n \"657880b399682101616b3413e04c2b664d018ef3\",\n \"fc776d767ebf2a64fb9fad7ab0f479cdaf536cdb\",\n \"a9207d235af9b2f5a4915f072801128a41c4063b\",\n \"e2eae8b8f45dc685659689f85962b0f0a58f13b1\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"green\",\n \"mappings\": {\n \"text\": {\n \"source\": \"field\",\n \"value\": \"message\"\n },\n \"time\": {\n \"source\": \"field\",\n \"value\": \"committed_at\"\n },\n \"title\": {\n \"source\": \"field\",\n \"value\": \"author\"\n }\n },\n \"name\": \"Commits\",\n \"target\": {\n \"options\": {\n \"gitRef\": \"main\"\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Commits\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 12,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n }\n]", + "refId": "B", + "scenarioId": "raw_frame" + } + ], + "title": "Time series", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "annotations": { + "multiLane": true + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Time series (multi-lane)", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "candleStyle": "candles", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "green" + }, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mode": "candles+volume", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Candlestick", + "type": "candlestick" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "annotations": { + "multiLane": true + }, + "candleStyle": "candles", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "green" + }, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mode": "candles+volume", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Candlestick (multi-lane)", + "type": "candlestick" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "State timeline", + "type": "state-timeline" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "alignValue": "left", + "annotations": { + "multiLane": true + }, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "State timeline (multi-lane)", + "type": "state-timeline" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Heatmap", + "type": "heatmap" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 8, + "options": { + "annotations": { + "multiLane": true + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Heatmap (multi-lane)", + "type": "heatmap" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 9, + "options": { + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Status history", + "type": "status-history" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 10, + "options": { + "annotations": { + "multiLane": true + }, + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Status history (multi-lane)", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 40 + }, + "id": 11, + "maxDataPoints": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"type\": \"timeseries\"\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"config\": {\n \"interval\": 604800000\n }\n },\n {\n \"name\": \"A-series\",\n \"type\": \"number\",\n \"labels\": {},\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1755870318505,\n 1756475118505,\n 1757079918505,\n 1757684718505,\n 1758289518505,\n 1758894318505,\n 1759499118505,\n 1760103918505,\n 1760708718505,\n 1761313518505\n ],\n [\n 49.57457814271496,\n 64.78808691382616,\n 94.88860442042386,\n 96.59132232810856,\n 58.57144477681538,\n 79.33618638515327,\n 89.64117713117561,\n 134.51905322565585,\n 122.83710544843791,\n 79.84039369237018\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"B\",\n \"name\": \"xymark\",\n \"meta\": {\n \"dataTopic\": \"annotations\"\n },\n \"fields\": [\n {\n \"name\": \"xMin\",\n \"type\": \"time\",\n \"config\": {}\n },\n {\n \"name\": \"xMax\",\n \"type\": \"time\",\n \"config\": {}\n },\n {\n \"name\": \"yMin\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"yMax\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"color\",\n \"type\": \"string\",\n \"config\": {}\n },\n {\n \"name\": \"fillOpacity\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"lineWidth\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"lineStyle\",\n \"type\": \"string\",\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1757684718505\n ],\n [\n 1758894318505\n ],\n [\n 70\n ],\n [\n 120\n ],\n [\n \"#f00\"\n ],\n [\n 0.1\n ],\n [\n 1\n ],\n [\n \"dash\"\n ]\n ]\n }\n }\n]", + "refId": "B", + "scenarioId": "raw_frame" + } + ], + "title": "xymark", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2025-08-22T13:45:18.505Z", + "to": "2025-10-28T04:38:37.130Z" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Multi-lane annotations", + "uid": "ad7p5pj", + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/annotations/multi-lane-annotations.json b/devenv/dev-dashboards/annotations/multi-lane-annotations.json new file mode 100644 index 00000000000..edd9c6ef757 --- /dev/null +++ b/devenv/dev-dashboards/annotations/multi-lane-annotations.json @@ -0,0 +1,1020 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "maxDataPoints": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "noise": 10, + "refId": "A", + "scenarioId": "random_walk", + "spread": 10 + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\",\n \"Milestones\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\",\n \"#F2495C\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1720697881000,\n 1728580067000,\n 1730129182000,\n 1730734644000,\n 1732542118000,\n 1736426576000,\n 1736874473000,\n 1738676647000,\n 1740067713000,\n 1740684246000,\n 1743733609000,\n 1744034815000,\n 1745335229000,\n 1745936130000,\n 1746107356000,\n 1747055303000,\n 1747946736000,\n 1748446531000,\n 1750098297000,\n 1750963080000,\n 1750963096000,\n 1752067555000,\n 1752080606000,\n 1753125884000,\n 1754395568000,\n 1754407010000,\n 1756228450000,\n 1757947221000,\n 1759763050000,\n 1759924632000,\n 1761572602000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"timeEnd\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1729081505000,\n 1730129307000,\n 1730838127000,\n 1732543887000,\n 1736528756000,\n 1736874600000,\n 1740064375000,\n 1740170732000,\n 1742397235000,\n 1743733611000,\n 1744038997000,\n 1745341972000,\n 1745936141000,\n 1746118905000,\n 1747055307000,\n 1747853613000,\n 1748446520000,\n 1750187872000,\n 1750963071000,\n 1751038503000,\n 1752067563000,\n 1752166243000,\n 1753396603000,\n 1754395592000,\n 1754407004000,\n 1756228445000,\n 1758134868000,\n 1759763044000,\n 1761572592000,\n 1761572593000,\n null\n ],\n \"type\": \"number\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"0.1.0\",\n \"1.0.2\",\n \"1.0.3\",\n \"1.0.4\",\n \"1.0.5\",\n \"1.0.6\",\n \"1.0.7\",\n \"1.0.8\",\n \"1.0.9\",\n \"1.0.10\",\n \"1.0.11\",\n \"1.0.12\",\n \"1.0.13\",\n \"1.0.14\",\n \"1.0.15\",\n \"1.0.16\",\n \"1.0.17\",\n \"1.0.18\",\n \"1.0.19\",\n \"1.0.20\",\n \"1.0.21\",\n \"1.0.22\",\n \"1.0.23\",\n \"1.0.24\",\n \"1.0.25\",\n \"1.0.26\",\n \"1.0.27\",\n \"1.0.28\",\n \"1.0.29\",\n \"1.0.30\",\n \"1.0.31\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n false\n ],\n \"type\": \"boolean\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"red\",\n \"mappings\": {\n \"text\": {\n \"source\": \"field\",\n \"value\": \"closed\"\n },\n \"time\": {\n \"source\": \"field\",\n \"value\": \"created_at\"\n },\n \"timeEnd\": {\n \"source\": \"field\",\n \"value\": \"closed_at\"\n },\n \"title\": {\n \"source\": \"field\",\n \"value\": \"title\"\n }\n },\n \"name\": \"Milestones\",\n \"target\": {\n \"options\": {\n \"query\": \"\"\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Milestones\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n true,\n false\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 31,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n },\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Issues\",\n \"Issues\",\n \"Issues\",\n \"Issues\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#FADE2A\",\n \"#FADE2A\",\n \"#FADE2A\",\n \"#FADE2A\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760112753000,\n 1759930212000,\n 1759924899000,\n 1759850404000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"timeEnd\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760972421000,\n null,\n null,\n 1759926839000\n ],\n \"type\": \"number\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"[BUG]: Error when data source configured for multi tenant queries\",\n \"[FEAT]: Allow users without explore permissions to use logs drilldown\",\n \"Sync Logs panel and label/fields empty states\",\n \"fix: labels clear variable state not showing\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"[BUG]: Error when data source configured for multi tenant queries\",\n \"[FEAT]: Allow users without explore permissions to use logs drilldown\",\n \"Sync Logs panel and label/fields empty states\",\n \"fix: labels clear variable state not showing\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"tags\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n [\n \"bug\",\n \"needs-triage\"\n ],\n [\n \"enhancement\",\n \"needs-triage\"\n ],\n [\n \"needs-triage\"\n ],\n [\n \"bug\"\n ]\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"yellow\",\n \"mappings\": {\n \"tags\": {\n \"source\": \"field\",\n \"value\": \"labels\"\n },\n \"time\": {\n \"source\": \"field\",\n \"value\": \"created_at\"\n },\n \"timeEnd\": {\n \"source\": \"field\",\n \"value\": \"closed_at\"\n }\n },\n \"name\": \"Issues\",\n \"target\": {\n \"options\": {\n \"query\": \"\"\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Issues\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n false,\n false,\n true\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 4,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n },\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Pulls\",\n \"Pulls\",\n \"Pulls\",\n \"Pulls\",\n \"Pulls\",\n \"Pulls\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\",\n \"#5794F2\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1759941908000,\n 1759854730000,\n 1759852733000,\n 1759841416000,\n 1759786844000,\n 1759762979000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"timeEnd\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760110687000,\n 1759857566000,\n 1759926838000,\n 1759843529000,\n 1759848237000,\n 1759769543000\n ],\n \"type\": \"number\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"feat(EmptyLogs): add button to fix with assistant\",\n \"docs: Update troubleshooting page\",\n \"fix: unexpected clear variable behavior\",\n \"docs: update stale readme, fix docker install script\",\n \"docs: Update install and troubleshooting\",\n \"fix: fix RegExp.source removing flags, use toString instead\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"feat(EmptyLogs): add button to fix with assistant\",\n \"docs: Update troubleshooting page\",\n \"fix: unexpected clear variable behavior\",\n \"docs: update stale readme, fix docker install script\",\n \"docs: Update install and troubleshooting\",\n \"fix: fix RegExp.source removing flags, use toString instead\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"blue\",\n \"mappings\": {\n \"time\": {\n \"source\": \"field\",\n \"value\": \"created_at\"\n },\n \"timeEnd\": {\n \"source\": \"field\",\n \"value\": \"merged_at\"\n }\n },\n \"name\": \"Pulls\",\n \"target\": {\n \"options\": {\n \"query\": \"\",\n \"timeField\": 1\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Pull_Requests\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n true,\n true,\n true,\n true,\n true,\n true\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 6,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n },\n {\n \"fields\": [\n {\n \"name\": \"type\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\",\n \"Commits\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"color\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\",\n \"#73BF69\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"time\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n 1760349982000,\n 1760110687000,\n 1759950041000,\n 1759948586000,\n 1759933371000,\n 1759926838000,\n 1759857566000,\n 1759850172000,\n 1759848237000,\n 1759843529000,\n 1759769543000,\n 1759762725000\n ],\n \"type\": \"time\"\n },\n {\n \"name\": \"title\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"Matias Chomicki\",\n \"Matias Chomicki\",\n \"github-actions[bot]\",\n \"Liza Detrick\",\n \"grafana-plugins-platform-bot[bot]\",\n \"Galen Kistler\",\n \"J Stickler\",\n \"Piotr Jamróz\",\n \"J Stickler\",\n \"Galen Kistler\",\n \"Galen Kistler\",\n \"Galen Kistler\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"text\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"feat(LogsListScene): add defaultDisplayedFields support (#1554)\\n\\n* feat(LogsListScene): add defaultDisplayedFields support\\n\\n* chore: update mock\\n\\n* fix(Table): omit unsupported field\\n\\n* fix(LogListScene): reset default displayed fields when switching viz\\n\\n* chore: lint\",\n \"feat(EmptyLogs): add button to fix with assistant (#1571)\\n\\n* feat(LogListScene): add error type to identify empty results\\n\\n* feat(LogsPanelError): use assistant to investigate no results\\n\\n* fix(Shoo): shoo\\n\\n* feat(EmbeddedLogs): customize empty logs prompt\\n\\n* feat(LogsPanelError): implement custom CTA\\n\\n* chore: spelling\\n\\n* chore: add unit test\\n\\n* chore: remove it.only\\n\\n* fix(variableHelpers): exclude primary label from variables to clear\\n\\n* feat(embedding): refactor options\\n\\n* feat(assistant): create service\\n\\n* feat(assistant): integrate with empty layout and no matching labels\\n\\n* chore: fix types\\n\\n* chore: update tests\\n\\n* chore: remove quotes\\n\\n* chore: prevent access to an undefined object\\n\\n* chore: move label to constants\\n\\n* Revert \\\"chore: move label to constants\\\"\\n\\nThis reverts commit fbdf96415e5b2af7a5cae4eec6e6d5a30bc05d1c.\\n\\n* chore: use name instead of label\\n\\n* Revert \\\"fix(Shoo): shoo\\\"\\n\\nThis reverts commit 75993f7a845b514cb5da9667e94a2f0a3c913f24.\\n\\n* chore: more unit tests\\n\\n* test(variableHelpers): test getVariablesThatCanBeCleared\",\n \"chore: bump @grafana/create-plugin configuration to 5.26.9 (#1559)\",\n \"feat(table): preferences (#1534)\",\n \"chore(version): bump version to v1.0.29\",\n \"fix: unexpected clear variable behavior (#1567)\\n\\n* fix: show clear ui when query contains clearable label filters\",\n \"docs: Update troubleshooting page (#1568)\",\n \"fix: validate primary label correctly (#1561)\\n\\n* fix: properly clear variables for legacy urls\",\n \"docs: Update install and troubleshooting (#1564)\\n\\nCo-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com>\",\n \"docs: update stale readme, fix docker install script (#1565)\\n\\n* docs: update stale readme\\n\\n* chore: fix docker script\",\n \"fix: fix RegExp.source removing flags, use toString instead (#1563)\\n\\n* fix: fix RegExp.source removing flags, use toString instead\",\n \"fix: stale urls (#1562)\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"id\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n \"cf08043276597abe16f4ae9dbe97bd988d31b7ed\",\n \"01e343dacde3b9fcd862ec2cb2b04fbea4eb6d63\",\n \"9e67734938b980a80496d4cdc60efe7dbc4ba74e\",\n \"924ebd235b6ea99d297d8e04e08ac8ea90a32968\",\n \"23b9b5dc0504a4c397b326562cb796cfdc0531df\",\n \"089fdfa26c7daad5543be1aae2377850348663c3\",\n \"437a568a7a7279c436a674c7b1b0d11a4971d47b\",\n \"ee12a20b32db26b82fe32ed9d5868bd2dde4a4d0\",\n \"657880b399682101616b3413e04c2b664d018ef3\",\n \"fc776d767ebf2a64fb9fad7ab0f479cdaf536cdb\",\n \"a9207d235af9b2f5a4915f072801128a41c4063b\",\n \"e2eae8b8f45dc685659689f85962b0f0a58f13b1\"\n ],\n \"type\": \"string\"\n },\n {\n \"name\": \"source\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n {\n \"datasource\": {\n \"type\": \"grafana-github-datasource\",\n \"uid\": \"feyypehpl45xcf\"\n },\n \"enable\": true,\n \"hide\": false,\n \"iconColor\": \"green\",\n \"mappings\": {\n \"text\": {\n \"source\": \"field\",\n \"value\": \"message\"\n },\n \"time\": {\n \"source\": \"field\",\n \"value\": \"committed_at\"\n },\n \"title\": {\n \"source\": \"field\",\n \"value\": \"author\"\n }\n },\n \"name\": \"Commits\",\n \"target\": {\n \"options\": {\n \"gitRef\": \"main\"\n },\n \"owner\": \"grafana\",\n \"queryType\": \"Commits\",\n \"refId\": \"Anno\",\n \"repository\": \"logs-drilldown\"\n }\n },\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null,\n null\n ],\n \"type\": \"other\"\n },\n {\n \"name\": \"isRegion\",\n \"config\": {\n \"custom\": {}\n },\n \"values\": [\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false,\n false\n ],\n \"type\": \"boolean\"\n }\n ],\n \"length\": 12,\n \"meta\": {\n \"dataTopic\": \"annotations\"\n }\n }\n]", + "refId": "B", + "scenarioId": "raw_frame" + } + ], + "title": "Time series", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "annotations": { + "multiLane": true + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Time series (multi-lane)", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "candleStyle": "candles", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "green" + }, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mode": "candles+volume", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Candlestick", + "type": "candlestick" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "annotations": { + "multiLane": true + }, + "candleStyle": "candles", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "green" + }, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mode": "candles+volume", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Candlestick (multi-lane)", + "type": "candlestick" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "State timeline", + "type": "state-timeline" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "alignValue": "left", + "annotations": { + "multiLane": true + }, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "State timeline (multi-lane)", + "type": "state-timeline" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Heatmap", + "type": "heatmap" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 8, + "options": { + "annotations": { + "multiLane": true + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Heatmap (multi-lane)", + "type": "heatmap" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 9, + "options": { + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Status history", + "type": "status-history" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisPlacement": "auto", + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 10, + "options": { + "annotations": { + "multiLane": true + }, + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A" + } + ], + "title": "Status history (multi-lane)", + "type": "status-history" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 40 + }, + "id": 11, + "maxDataPoints": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"type\": \"timeseries\"\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"config\": {\n \"interval\": 604800000\n }\n },\n {\n \"name\": \"A-series\",\n \"type\": \"number\",\n \"labels\": {},\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1755870318505,\n 1756475118505,\n 1757079918505,\n 1757684718505,\n 1758289518505,\n 1758894318505,\n 1759499118505,\n 1760103918505,\n 1760708718505,\n 1761313518505\n ],\n [\n 49.57457814271496,\n 64.78808691382616,\n 94.88860442042386,\n 96.59132232810856,\n 58.57144477681538,\n 79.33618638515327,\n 89.64117713117561,\n 134.51905322565585,\n 122.83710544843791,\n 79.84039369237018\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"B\",\n \"name\": \"xymark\",\n \"meta\": {\n \"dataTopic\": \"annotations\"\n },\n \"fields\": [\n {\n \"name\": \"xMin\",\n \"type\": \"time\",\n \"config\": {}\n },\n {\n \"name\": \"xMax\",\n \"type\": \"time\",\n \"config\": {}\n },\n {\n \"name\": \"yMin\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"yMax\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"color\",\n \"type\": \"string\",\n \"config\": {}\n },\n {\n \"name\": \"fillOpacity\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"lineWidth\",\n \"type\": \"number\",\n \"config\": {}\n },\n {\n \"name\": \"lineStyle\",\n \"type\": \"string\",\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1757684718505\n ],\n [\n 1758894318505\n ],\n [\n 70\n ],\n [\n 120\n ],\n [\n \"#f00\"\n ],\n [\n 0.1\n ],\n [\n 1\n ],\n [\n \"dash\"\n ]\n ]\n }\n }\n]", + "refId": "B", + "scenarioId": "raw_frame" + } + ], + "title": "xymark", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": ["gdev", "panel-tests", "graph-ng"], + "templating": { + "list": [] + }, + "time": { + "from": "2025-08-22T13:45:18.505Z", + "to": "2025-10-28T04:38:37.130Z" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Multi-lane annotations", + "uid": "ad7p5pj", + "version": 17 +} diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 6078526e079..cac117cd4e7 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -74,6 +74,7 @@ "mostly-blank-dashboard": (import '../dev-dashboards/scenarios/mostly-blank-dashboard.json'), "mssql_fakedata": (import '../dev-dashboards/datasource-mssql/mssql_fakedata.json'), "mssql_unittest": (import '../dev-dashboards/datasource-mssql/mssql_unittest.json'), + "multi-lane-annotations": (import '../dev-dashboards/annotations/multi-lane-annotations.json'), "mysql_fakedata": (import '../dev-dashboards/datasource-mysql/mysql_fakedata.json'), "mysql_unittest": (import '../dev-dashboards/datasource-mysql/mysql_unittest.json'), "new_features_in_v74": (import '../dev-dashboards/datasource-testdata/new_features_in_v74.json'), diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 90434500881..8188c467ed8 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -502,6 +502,20 @@ export enum VizOrientation { Vertical = 'vertical', } +/** + * Breaks out each annotation frame into multiple lanes on the x-axis + */ +export interface VizAnnotations { + multiLane?: boolean; +} + +/** + * TODO docs + */ +export interface OptionsWithAnnotations { + annotations?: VizAnnotations; +} + /** * TODO docs */ diff --git a/packages/grafana-schema/src/common/mudball.cue b/packages/grafana-schema/src/common/mudball.cue index 24c371d8715..6aeca5ea925 100644 --- a/packages/grafana-schema/src/common/mudball.cue +++ b/packages/grafana-schema/src/common/mudball.cue @@ -158,6 +158,16 @@ ReduceDataOptions: { // TODO docs VizOrientation: "auto" | "vertical" | "horizontal" @cuetsy(kind="enum") +// Breaks out each annotation frame into multiple lanes on the x-axis +VizAnnotations: { + multiLane?: bool +} @cuetsy(kind="interface") + +// TODO docs +OptionsWithAnnotations: { + annotations?: VizAnnotations +} @cuetsy(kind="interface") + // TODO docs OptionsWithTooltip: { tooltip: VizTooltipOptions diff --git a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts index ab38df01ee6..d1847e72998 100644 --- a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts @@ -63,7 +63,7 @@ export const defaultCandlestickColors: Partial = { up: 'green', }; -export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { +export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithAnnotations { /** * Sets the style of the candlesticks */ diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts index d905b1bd2ea..bb71df74d6d 100644 --- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts @@ -188,6 +188,7 @@ export interface RowsHeatmapOptions { } export interface Options { + annotations?: ui.VizAnnotations; /** * Controls if the heatmap should be calculated from data */ diff --git a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts index b8220d24d3a..6c3713394ca 100644 --- a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts @@ -12,7 +12,7 @@ import * as ui from '@grafana/schema'; export const pluginVersion = "12.3.0-pre"; -export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones { +export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations { /** * Controls value alignment on the timelines */ diff --git a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts index 030a310ff8b..38f816c0b77 100644 --- a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts @@ -12,7 +12,7 @@ import * as ui from '@grafana/schema'; export const pluginVersion = "12.3.0-pre"; -export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones { +export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations { /** * Controls the column width */ diff --git a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts index 538860716da..3ebffee70c2 100644 --- a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts @@ -12,7 +12,7 @@ import * as common from '@grafana/schema'; export const pluginVersion = "12.3.0-pre"; -export interface Options extends common.OptionsWithTimezones { +export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations { legend: common.VizLegendOptions; orientation?: common.VizOrientation; timeCompare?: common.TimeCompareOptions; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 68aa92e6f66..2431d9ecff5 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -286,6 +286,7 @@ type UPlotConfigPrepOpts = {}> = { tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; hoverProximity?: number; orientation?: VizOrientation; + xAxisConfig?: Pick; } & T; /** @alpha */ diff --git a/public/app/core/components/GraphNG/GraphNG.tsx b/public/app/core/components/GraphNG/GraphNG.tsx index 3babaa7ac63..7094c137501 100644 --- a/public/app/core/components/GraphNG/GraphNG.tsx +++ b/public/app/core/components/GraphNG/GraphNG.tsx @@ -1,5 +1,5 @@ -import { Component } from 'react'; import * as React from 'react'; +import { Component } from 'react'; import uPlot, { AlignedData } from 'uplot'; import { @@ -16,7 +16,7 @@ import { } from '@grafana/data'; import { DashboardCursorSync, VizLegendOptions } from '@grafana/schema'; import { Themeable2, VizLayout } from '@grafana/ui'; -import { UPlotChart, AxisProps, Renderers, UPlotConfigBuilder, ScaleProps, pluginLog } from '@grafana/ui/internal'; +import { AxisProps, pluginLog, Renderers, ScaleProps, UPlotChart, UPlotConfigBuilder } from '@grafana/ui/internal'; import { GraphNGLegendEvent, XYFieldMatchers } from './types'; import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; @@ -40,7 +40,12 @@ export interface GraphNGProps extends Themeable2 { tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; onLegendClick?: (event: GraphNGLegendEvent) => void; children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; - prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder; + prepConfig: ( + alignedFrame: DataFrame, + allFrames: DataFrame[], + getTimeRange: () => TimeRange, + annotationLanes?: number + ) => UPlotConfigBuilder; propsToDiff?: Array; preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null; renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; @@ -63,6 +68,9 @@ export interface GraphNGProps extends Themeable2 { * similar to structureRev. then we can drop propsToDiff entirely. */ options?: Record; + + // Annotation lanes count + annotationLanes?: number; } function sameProps>( @@ -191,7 +199,7 @@ export class GraphNG extends Component { let config = this.state?.config; if (withConfig) { - config = props.prepConfig(alignedFrameFinal, this.props.frames, this.getTimeRange); + config = props.prepConfig(alignedFrameFinal, this.props.frames, this.getTimeRange, this.props.annotationLanes); pluginLog('GraphNG', false, 'config prepared', config); } @@ -229,7 +237,12 @@ export class GraphNG extends Component { propsChanged; if (shouldReconfig) { - newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); + newState.config = this.props.prepConfig( + newState.alignedFrame, + this.props.frames, + this.getTimeRange, + this.props.annotationLanes + ); pluginLog('GraphNG', false, 'config recreated', newState.config); } diff --git a/public/app/core/components/TimeSeries/TimeSeries.tsx b/public/app/core/components/TimeSeries/TimeSeries.tsx index 5af30da15c5..8fd11f1bc37 100644 --- a/public/app/core/components/TimeSeries/TimeSeries.tsx +++ b/public/app/core/components/TimeSeries/TimeSeries.tsx @@ -6,14 +6,19 @@ import { hasVisibleLegendSeries, PlotLegend, UPlotConfigBuilder } from '@grafana import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; -import { preparePlotConfigBuilder } from './utils'; +import { getXAxisConfig, preparePlotConfigBuilder } from './utils'; -const propsToDiff: Array = ['legend', 'options', 'theme']; +const propsToDiff: Array = ['legend', 'options', 'annotationLanes', 'theme']; type TimeSeriesProps = Omit; export class UnthemedTimeSeries extends Component { - prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + prepConfig = ( + alignedFrame: DataFrame, + allFrames: DataFrame[], + getTimeRange: () => TimeRange, + annotationLanes?: number + ) => { const { theme, timeZone, options, renderers, tweakAxis, tweakScale } = this.props; return preparePlotConfigBuilder({ @@ -27,6 +32,7 @@ export class UnthemedTimeSeries extends Component { tweakAxis, hoverProximity: options?.tooltip?.hoverProximity, orientation: options?.orientation, + xAxisConfig: getXAxisConfig(annotationLanes), }); }; diff --git a/public/app/core/components/TimeSeries/utils.test.ts b/public/app/core/components/TimeSeries/utils.test.ts index c57d96c2213..6168fb8ddc5 100644 --- a/public/app/core/components/TimeSeries/utils.test.ts +++ b/public/app/core/components/TimeSeries/utils.test.ts @@ -1,7 +1,7 @@ import { createDataFrame, dateTime, DateTimeInput, EventBus, FieldType } from '@grafana/data'; import { getTheme } from '@grafana/ui'; -import { preparePlotConfigBuilder } from './utils'; +import { getXAxisConfig, preparePlotConfigBuilder, UPLOT_DEFAULT_AXIS_GAP } from './utils'; describe('when fill below to option is used', () => { let eventBus: EventBus; @@ -375,3 +375,26 @@ describe('time axis units', () => { expect(config.axes![0]!.values(config, [1667406900000, 1761316576114], 0, 100, 1000)).toEqual(['11-02', '10-24']); }); }); + +describe('calculateAnnotationLaneSizes', () => { + it('should not regress', () => { + expect(getXAxisConfig()).toEqual(undefined); + expect(getXAxisConfig(0)).toEqual(undefined); + }); + it('should return config to resize x-axis size, gap, and ticks size', () => { + expect(getXAxisConfig(2)).toEqual({ + gap: UPLOT_DEFAULT_AXIS_GAP, + size: 36, + ticks: { + size: 19, + }, + }); + expect(getXAxisConfig(3)).toEqual({ + gap: UPLOT_DEFAULT_AXIS_GAP, + size: 43, + ticks: { + size: 26, + }, + }); + }); +}); diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index f029388e602..82c18cdd359 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -68,8 +68,15 @@ import { buildScaleKey, getStackingGroups, preparePlotData2, + AxisProps, } from '@grafana/ui/internal'; +import { ANNOTATION_LANE_SIZE } from '../../../plugins/panel/timeseries/plugins/utils'; + +// See UPlotAxisBuilder.ts::calculateAxisSize for default axis size calculation +export const UPLOT_DEFAULT_AXIS_SIZE = 17; +export const UPLOT_DEFAULT_AXIS_GAP = 5; + const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals)); const defaultConfig: GraphFieldConfig = { @@ -90,6 +97,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ tweakAxis = (opts) => opts, hoverProximity, orientation = VizOrientation.Horizontal, + xAxisConfig, }) => { // we want the Auto and Horizontal orientation to default to Horizontal const isHorizontal = orientation !== VizOrientation.Vertical; @@ -159,6 +167,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ formatValue: xField.config.unit?.startsWith('time:') ? (v, decimals) => xField.display!(v, decimals).text : undefined, + ...xAxisConfig, }); } @@ -709,3 +718,25 @@ function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map | undefined { + if (lanes > 1) { + const annotationLanesSize = lanes * ANNOTATION_LANE_SIZE; + // Add an extra lane's worth of height below the annotation lanes in order to show the gridlines through the annotation lanes + const axisSize = annotationLanesSize + UPLOT_DEFAULT_AXIS_GAP; + // Consistent gap between gridlines and x-axis labels + const gap = UPLOT_DEFAULT_AXIS_GAP; + // Axis size is: default size + gap size + annotationLaneSize + const size = UPLOT_DEFAULT_AXIS_SIZE + gap + annotationLanesSize; + + return { + size, + gap, + ticks: { + size: axisSize, + }, + }; + } + + return undefined; +} diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index ce6e4a1f4c1..4b677c72282 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -1,10 +1,11 @@ import { useCallback } from 'react'; import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data'; -import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema'; +import { TimelineValueAlignment, TooltipDisplayMode, VisibilityMode, VizTooltipOptions } from '@grafana/schema'; import { UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui'; import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG'; +import { getXAxisConfig } from '../TimeSeries/utils'; import { preparePlotConfigBuilder, TimelineMode } from './utils'; @@ -23,7 +24,16 @@ export interface TimelineProps extends Omit { const { frames, timeZone, rowHeight, tooltip, legend, legendItems } = props; @@ -60,6 +70,7 @@ export const TimelineChart = (props: TimelineProps) => { getValueColor: getValueColor, hoverMulti: tooltip?.mode === TooltipDisplayMode.Multi, + xAxisConfig: getXAxisConfig(props.annotationLanes), }); }, [frames, props, timeZone, rowHeight, getValueColor, tooltip] diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 78f7cd4ef69..a2e9ad366ff 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -93,6 +93,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( mergeValues, getValueColor, hoverMulti, + xAxisConfig, }) => { const builder = new UPlotConfigBuilder(timeZones[0]); @@ -167,7 +168,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( }); const xField = frame.fields[0]; - const xAxisHidden = xField.config.custom.axisPlacement === AxisPlacement.Hidden; builder.addAxis({ @@ -181,6 +181,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( formatValue: xField.config.unit?.startsWith('time:') ? (v, decimals) => xField.display!(v, decimals).text : undefined, + ...xAxisConfig, }); const yCustomConfig = frame.fields[1].config.custom; diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index f66b3d554b6..e566a8445c6 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -24,6 +24,7 @@ import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin'; +import { getXAnnotationFrames } from '../timeseries/plugins/utils'; import { prepareCandlestickFields } from './fields'; import { Options, defaultCandlestickColors, VizDisplayMode } from './types'; @@ -266,6 +267,7 @@ export const CandlestickPanel = ({ replaceVariables={replaceVariables} dataLinkPostProcessor={dataLinkPostProcessor} cursorSync={cursorSync} + annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined} > {(uplotConfig, alignedFrame) => { return ( @@ -322,6 +324,7 @@ export const CandlestickPanel = ({ )} = { up: 'green', }; -export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { +export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithAnnotations { /** * Sets the style of the candlesticks */ diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 7a2c154351f..57fee8ebcb8 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -19,8 +19,10 @@ import { FacetedData, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal' import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; +import { getXAxisConfig } from '../../../core/components/TimeSeries/utils'; import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; +import { getXAnnotationFrames } from '../timeseries/plugins/utils'; import { HeatmapTooltip } from './HeatmapTooltip'; import { HeatmapData, prepareHeatmapData } from './fields'; @@ -132,6 +134,8 @@ const HeatmapPanelViz = ({ const dataRef = useRef(info); dataRef.current = info; + const annotationsLength = options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined; + const builder = useMemo(() => { const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution; @@ -147,10 +151,11 @@ const HeatmapPanelViz = ({ yAxisConfig: options.yAxis, ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1, selectionMode: options.selectionMode, + xAxisConfig: getXAxisConfig(annotationsLength), }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, timeZone, data.structureRev, cursorSync]); + }, [options, timeZone, data.structureRev, cursorSync, annotationsLength]); const renderLegend = () => { if (!options.legend.show) { @@ -242,6 +247,7 @@ const HeatmapPanelViz = ({ )} [0]['xAxisConfig']; } export function prepConfig(opts: PrepConfigOpts) { @@ -67,6 +68,7 @@ export function prepConfig(opts: PrepConfigOpts) { yAxisConfig, ySizeDivisor, selectionMode = HeatmapSelectionMode.X, + xAxisConfig, } = opts; const xScaleKey = 'x'; @@ -182,6 +184,7 @@ export function prepConfig(opts: PrepConfigOpts) { isTime && xField.config.unit?.startsWith('time:') ? (v, decimals) => xField.display!(v, decimals).text : undefined, + ...xAxisConfig, }); const yField = dataRef.current?.heatmap?.fields[1]!; diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index 59883b946c4..a68e81fb6d6 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -20,6 +20,7 @@ import { import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; +import { getXAnnotationFrames } from '../timeseries/plugins/utils'; import { getTimezones } from '../timeseries/utils'; import { StateTimelineTooltip } from './StateTimelineTooltip'; @@ -87,11 +88,13 @@ export const StateTimelinePanel = ({ width={width} height={height - paginationHeight} legendItems={legendItems} + annotations={options.annotations} {...options} mode={TimelineMode.Changes} replaceVariables={replaceVariables} dataLinkPostProcessor={dataLinkPostProcessor} cursorSync={cursorSync} + annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined} > {(builder, alignedFrame) => { return ( @@ -149,6 +152,7 @@ export const StateTimelinePanel = ({ {alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && ( {(builder, alignedFrame) => { return ( @@ -165,6 +167,7 @@ export const StatusHistoryPanel = ({ {alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && ( =0 & <=1 | *0.9 diff --git a/public/app/plugins/panel/status-history/panelcfg.gen.ts b/public/app/plugins/panel/status-history/panelcfg.gen.ts index 366f69f05fd..dbc46613dd7 100644 --- a/public/app/plugins/panel/status-history/panelcfg.gen.ts +++ b/public/app/plugins/panel/status-history/panelcfg.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones { +export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations { /** * Controls the column width */ diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index e254fdc3e91..bb0a93310a8 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -23,6 +23,7 @@ import { AnnotationsPlugin2 } from './plugins/AnnotationsPlugin2'; import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from './plugins/OutsideRangePlugin'; import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin'; +import { getXAnnotationFrames } from './plugins/utils'; import { getPrepareTimeseriesSuggestion } from './suggestions'; import { getTimezones, prepareGraphableFields } from './utils'; @@ -131,6 +132,7 @@ export const TimeSeriesPanel = ({ replaceVariables={replaceVariables} dataLinkPostProcessor={dataLinkPostProcessor} cursorSync={cursorSync} + annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined} > {(uplotConfig, alignedFrame) => { return ( @@ -192,6 +194,7 @@ export const TimeSeriesPanel = ({ <> void; canvasRegionRendering?: boolean; replaceVariables: InterpolateFunction; + multiLane?: boolean; } // TODO: batch by color, use Path2D objects @@ -72,6 +74,7 @@ export const AnnotationsPlugin2 = ({ setNewRange, replaceVariables, canvasRegionRendering = true, + multiLane = false, }: AnnotationsPluginProps) => { const [plot, setPlot] = useState(); @@ -85,10 +88,9 @@ export const AnnotationsPlugin2 = ({ const { canExecuteActions } = usePanelContext(); const userCanExecuteActions = canExecuteActions?.() ?? false; - const annos = useMemo(() => { - let annos = annotations.filter( - (frame) => frame.name !== 'exemplar' && frame.length > 0 && frame.fields.some((f) => f.name === 'time') - ); + const { xAnnos, xyAnnos } = useMemo(() => { + let xAnnos = getXAnnotationFrames(annotations); + let xyAnnos = getXYAnnotationFrames(annotations); if (newRange) { let isRegion = newRange.to > newRange.from; @@ -109,18 +111,25 @@ export const AnnotationsPlugin2 = ({ }, }; - annos.push(wipAnnoFrame); + xAnnos.push(wipAnnoFrame); } - return annos; + return { + xAnnos, + xyAnnos, + }; }, [annotations, newRange]); const exitWipEdit = useCallback(() => { setNewRange(null); }, [setNewRange]); - const annoRef = useRef(annos); - annoRef.current = annos; + const xAnnoRef = useRef(xAnnos); + xAnnoRef.current = xAnnos; + + const xyAnnoRef = useRef(xyAnnos); + xyAnnoRef.current = xyAnnos; + const newRangeRef = useRef(newRange); newRangeRef.current = newRange; @@ -134,7 +143,8 @@ export const AnnotationsPlugin2 = ({ }); config.addHook('draw', (u) => { - let annos = annoRef.current; + let xAnnos = xAnnoRef.current; + let xyAnnos = xyAnnoRef.current; const ctx = u.ctx; @@ -144,40 +154,10 @@ export const AnnotationsPlugin2 = ({ ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); ctx.clip(); - annos.forEach((frame) => { + // Multi-lane annotations do not support vertical lines or shaded regions + xAnnos.forEach((frame) => { let vals = getVals(frame); - - if (frame.name === 'xymark') { - // xMin, xMax, yMin, yMax, color, lineWidth, lineStyle, fillOpacity, text - - let xKey = config.scales[0].props.scaleKey; - let yKey = config.scales[1].props.scaleKey; - - for (let i = 0; i < frame.length; i++) { - let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); - - let x0 = u.valToPos(vals.xMin[i], xKey, true); - let x1 = u.valToPos(vals.xMax[i], xKey, true); - let y0 = u.valToPos(vals.yMax[i], yKey, true); - let y1 = u.valToPos(vals.yMin[i], yKey, true); - - ctx.fillStyle = colorManipulator.alpha(color, vals.fillOpacity[i]); - ctx.fillRect(x0, y0, x1 - x0, y1 - y0); - - ctx.lineWidth = Math.round(vals.lineWidth[i] * uPlot.pxRatio); - - if (vals.lineStyle[i] === 'dash') { - // maybe extract this to vals.lineDash[i] in future? - ctx.setLineDash([5, 5]); - } else { - // solid - ctx.setLineDash([]); - } - - ctx.strokeStyle = color; - ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); - } - } else { + if (!multiLane) { let y0 = u.bbox.top; let y1 = y0 + u.bbox.height; @@ -185,12 +165,14 @@ export const AnnotationsPlugin2 = ({ ctx.setLineDash([5, 5]); for (let i = 0; i < vals.time.length; i++) { - let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); + let color = getColorByName(vals.color?.[i] ?? DEFAULT_ANNOTATION_COLOR_HEX8); let x0 = u.valToPos(vals.time[i], 'x', true); renderLine(ctx, y0, y1, x0, color); - if (vals.isRegion?.[i]) { + // If dataframe does not have end times, let's omit rendering the region for now to prevent runtime error in valToPos + // @todo do we want to fix isRegion to render a point (or use "to" as timeEnd) when we're missing timeEnd? + if (vals.isRegion?.[i] && vals.timeEnd?.[i]) { let x1 = u.valToPos(vals.timeEnd[i], 'x', true); renderLine(ctx, y0, y1, x1, color); @@ -203,11 +185,44 @@ export const AnnotationsPlugin2 = ({ } }); + // xMin, xMax, yMin, yMax, color, lineWidth, lineStyle, fillOpacity, text + xyAnnos.forEach((frame) => { + let vals = getVals(frame); + + let xKey = config.scales[0].props.scaleKey; + let yKey = config.scales[1].props.scaleKey; + + for (let i = 0; i < frame.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); + + let x0 = u.valToPos(vals.xMin[i], xKey, true); + let x1 = u.valToPos(vals.xMax[i], xKey, true); + let y0 = u.valToPos(vals.yMax[i], yKey, true); + let y1 = u.valToPos(vals.yMin[i], yKey, true); + + ctx.fillStyle = colorManipulator.alpha(color, vals.fillOpacity[i]); + ctx.fillRect(x0, y0, x1 - x0, y1 - y0); + + ctx.lineWidth = Math.round(vals.lineWidth[i] * uPlot.pxRatio); + + if (vals.lineStyle[i] === 'dash') { + // maybe extract this to vals.lineDash[i] in future? + ctx.setLineDash([5, 5]); + } else { + // solid + ctx.setLineDash([]); + } + + ctx.strokeStyle = color; + ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); + } + }); + ctx.restore(); }); - }, [config, canvasRegionRendering, getColorByName]); + }, [config, canvasRegionRendering, getColorByName, multiLane]); - // ensure annos are re-drawn whenever they change + // ensure xAnnos are re-drawn whenever they change useEffect(() => { if (plot) { plot.redraw(); @@ -219,14 +234,17 @@ export const AnnotationsPlugin2 = ({ forceUpdate(); }, 0); } - }, [annos, plot]); + }, [xAnnos, plot]); if (plot) { - let markers = annos.flatMap((frame, frameIdx) => { + let markers = xAnnos.flatMap((frame, frameIdx) => { let vals = getVals(frame); let markers: React.ReactNode[] = []; + // Top offset for multi-lane annotations + const top = multiLane ? frameIdx * ANNOTATION_LANE_SIZE : undefined; + for (let i = 0; i < vals.time.length; i++) { let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR); let left = Math.round(plot.valToPos(vals.time[i], 'x')) || 0; // handles -0 @@ -243,14 +261,14 @@ export const AnnotationsPlugin2 = ({ let clampedLeft = Math.max(0, left); let clampedRight = Math.min(plot.rect.width, right); - style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft }; + style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft, top }; className = styles.annoRegion; } } else { isVisible = left >= 0 && left <= plot.rect.width; if (isVisible) { - style = { left, borderBottomColor: color }; + style = { left, borderBottomColor: color, top }; className = styles.annoMarker; } } diff --git a/public/app/plugins/panel/timeseries/plugins/utils.test.ts b/public/app/plugins/panel/timeseries/plugins/utils.test.ts new file mode 100644 index 00000000000..81421be056b --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/utils.test.ts @@ -0,0 +1,165 @@ +import { arrayToDataFrame, createDataFrame, DataFrame, DataTopic, FieldType } from '@grafana/data'; + +import { getXAnnotationFrames, getXYAnnotationFrames } from './utils'; + +const exemplarFrame = createDataFrame({ + refId: 'A', + name: 'exemplar', + meta: { + custom: { + resultType: 'exemplar', + }, + }, + fields: [ + { name: 'Time', type: FieldType.time, values: [6, 5, 4, 3, 2, 1] }, + { + name: 'Value', + type: FieldType.number, + values: [30, 10, 40, 90, 14, 21], + labels: { le: '6' }, + }, + { + name: 'traceID', + type: FieldType.string, + values: ['unknown'], + labels: { le: '6' }, + }, + ], +}); +const annotationRegionFrame: DataFrame = { + fields: [ + { + name: 'type', + config: { + custom: {}, + }, + values: ['Milestones'], + type: FieldType.string, + state: { + displayName: null, + seriesIndex: 0, + }, + }, + { + name: 'color', + config: { + custom: {}, + }, + values: ['#F2495C'], + type: FieldType.string, + state: { + displayName: null, + seriesIndex: 1, + }, + }, + { + name: 'time', + config: { + custom: {}, + }, + values: [1720697881000], + type: FieldType.time, + state: { + displayName: null, + seriesIndex: 2, + }, + }, + { + name: 'timeEnd', + config: { + custom: {}, + }, + values: [1729081505000], + type: FieldType.number, + state: { + displayName: null, + seriesIndex: 2, + range: { + min: 1729081505000, + max: 1759857566000, + delta: 30776061000, + }, + }, + }, + { + name: 'title', + config: { + custom: {}, + }, + values: ['0.1.0'], + type: FieldType.string, + state: { + displayName: null, + seriesIndex: 3, + }, + }, + { + name: 'text', + config: { + custom: {}, + }, + values: [true], + type: FieldType.boolean, + state: { + displayName: null, + seriesIndex: 4, + }, + }, + { + name: 'isRegion', + config: { + custom: {}, + }, + values: [true], + type: FieldType.boolean, + state: { + displayName: null, + seriesIndex: 6, + }, + }, + ], + length: 1, + meta: { + dataTopic: DataTopic.Annotations, + }, +}; +const annotationFrame: DataFrame = { + ...annotationRegionFrame, + fields: [...annotationRegionFrame.fields.filter((f) => f.name !== 'timeEnd')], +}; +const frames: DataFrame[] = [exemplarFrame, annotationRegionFrame, annotationFrame]; +const xymark = arrayToDataFrame([ + { + time: 0, + xMin: 0, + xMax: 0, + timeEnd: 0, + yMin: 0, + yMax: 100, + isRegion: true, + fillOpacity: 0.15, + lineWidth: 1, + lineStyle: 'solid', + color: '#FF9930', + text: 'Comparison selection', + }, +]); +xymark.name = 'xymark'; + +describe('getXAnnotationFrames', () => { + it('should filter exemplar frames', () => { + expect(getXAnnotationFrames(frames)).toEqual([annotationRegionFrame, annotationFrame]); + }); + + it('should exclude xymark frames', () => { + const framesWithxymark = [...frames, xymark]; + expect(getXAnnotationFrames(framesWithxymark)).toEqual([annotationRegionFrame, annotationFrame]); + }); +}); + +describe('getXYAnnotationFrames', () => { + it('should include xymark frames', () => { + const framesWithxymark = [...frames, xymark]; + expect(getXYAnnotationFrames(framesWithxymark)).toEqual([xymark]); + }); +}); diff --git a/public/app/plugins/panel/timeseries/plugins/utils.ts b/public/app/plugins/panel/timeseries/plugins/utils.ts new file mode 100644 index 00000000000..2e43a4a9348 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/utils.ts @@ -0,0 +1,18 @@ +import { DataFrame, FieldType } from '@grafana/data'; + +// Annotation points/regions are 5px with 1px of padding +export const ANNOTATION_LANE_SIZE = 7; + +export function getXAnnotationFrames(dataFrames: DataFrame[] = []) { + return dataFrames.filter( + (frame) => + frame.name !== 'exemplar' && + frame.name !== 'xymark' && + frame.length > 0 && + frame.fields.some((f) => f.type === FieldType.time) + ); +} + +export function getXYAnnotationFrames(dataFrames: DataFrame[] = []) { + return dataFrames.filter((frame) => frame.name === 'xymark'); +} From 9a8d17a2091e27bf63312c711f3aec18fb64df28 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Wed, 5 Nov 2025 16:05:20 +0100 Subject: [PATCH 034/209] Toolbar: Move ExtensionSidebar next to toolbar (#113404) * Toolbar: Move ExtensionSidebar next to toolbar * Toolbar: Simplify AppChrome * simplify height calculation * Fix scrollbar positon * remove irrelevant comment Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --------- Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --- .../app/core/components/AppChrome/AppChrome.tsx | 9 ++++----- .../AppChrome/TopBar/SingleTopBarActions.tsx | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 568fb3fcf3e..bc132207698 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -45,8 +45,7 @@ export function AppChrome({ children }: Props) { ); const headerLevels = useChromeHeaderLevels(); - const headerHeight = headerLevels * getChromeHeaderLevelHeight(); - const styles = useStyles2(getStyles, headerHeight); + const styles = useStyles2(getStyles, headerLevels, getChromeHeaderLevelHeight()); const contentSizeStyles = useStyles2(getContentSizeStyles, extensionSidebarWidth); const dragStyles = useStyles2(getDragStyles); @@ -186,13 +185,13 @@ function useResponsiveDockedMegaMenu(chrome: AppChromeService) { }, [isLargeScreen, chrome, dockedMenuLocalStorageState]); } -const getStyles = (theme: GrafanaTheme2, headerHeight: number) => { +const getStyles = (theme: GrafanaTheme2, headerLevels: number, headerHeight: number) => { return { content: css({ label: 'page-content', display: 'flex', flexDirection: 'column', - paddingTop: headerHeight, + paddingTop: headerLevels * headerHeight, flexGrow: 1, height: 'auto', }), @@ -282,7 +281,7 @@ const getStyles = (theme: GrafanaTheme2, headerHeight: number) => { position: 'fixed !important' as 'fixed', top: headerHeight, bottom: 0, - zIndex: 2, + zIndex: theme.zIndex.navbarFixed + 1, right: 0, }), }; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx index 9e7e0ce7c7c..81f77230e26 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBarActions.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Components } from '@grafana/e2e-selectors'; @@ -6,6 +6,7 @@ import { ScopesContextValue } from '@grafana/runtime'; import { Stack, useStyles2 } from '@grafana/ui'; import { ScopesSelector } from 'app/features/scopes/selector/ScopesSelector'; +import { useExtensionSidebarContext } from '../ExtensionSidebar/ExtensionSidebarProvider'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { getChromeHeaderLevelHeight } from './useChromeHeaderHeight'; @@ -17,10 +18,14 @@ export interface Props { } export function SingleTopBarActions({ actions, breadcrumbActions, scopes }: Props) { - const styles = useStyles2(getStyles); + const { isOpen: isExtensionSidebarOpen, extensionSidebarWidth } = useExtensionSidebarContext(); + const styles = useStyles2(getStyles, extensionSidebarWidth); return ( -
+
{scopes?.state.enabled ? : undefined} @@ -33,7 +38,7 @@ export function SingleTopBarActions({ actions, breadcrumbActions, scopes }: Prop ); } -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, extensionSidebarWidth = 0) => { return { actionsBar: css({ alignItems: 'center', @@ -43,5 +48,8 @@ const getStyles = (theme: GrafanaTheme2) => { height: getChromeHeaderLevelHeight(), padding: theme.spacing(0, 1, 0, 2), }), + constrained: css({ + maxWidth: `calc(100% - ${extensionSidebarWidth}px)`, + }), }; }; From 1830e2ce9d0baff9423a6e3816c421bd836e58b0 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:13:47 +0100 Subject: [PATCH 035/209] CommanPalette: Add Assistant integration for empty state (#112601) * CommanPalette: Add Assistant integration for empty state * Update assistant package and use new onClick pop * i18n * Update public/locales/en-US/grafana.json Co-authored-by: Sven Grossmann * Update public/app/features/commandPalette/CommandPalette.tsx Co-authored-by: Sven Grossmann * Update test --------- Co-authored-by: Sven Grossmann --- package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- .../commandPalette/CommandPalette.test.tsx | 60 +++++++++++++++++++ .../commandPalette/CommandPalette.tsx | 27 ++++++--- .../grafana-pyroscope-datasource/package.json | 2 +- public/locales/en-US/grafana.json | 1 + yarn.lock | 14 ++--- 7 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 public/app/features/commandPalette/CommandPalette.test.tsx diff --git a/package.json b/package.json index 5d7dd234e25..9c03062a448 100644 --- a/package.json +++ b/package.json @@ -278,7 +278,7 @@ "@glideapps/glide-data-grid": "^6.0.0", "@grafana/alerting": "workspace:*", "@grafana/api-clients": "workspace:*", - "@grafana/assistant": "0.1.0", + "@grafana/assistant": "0.1.1", "@grafana/aws-sdk": "0.7.1", "@grafana/azure-sdk": "0.0.8", "@grafana/data": "workspace:*", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 98cb22e9fb2..14a3c5ab891 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -83,7 +83,7 @@ "typescript": "5.9.2" }, "peerDependencies": { - "@grafana/assistant": "^0.1.0", + "@grafana/assistant": "^0.1.1", "react": "^18.0.0", "react-dom": "^18.0.0" } diff --git a/public/app/features/commandPalette/CommandPalette.test.tsx b/public/app/features/commandPalette/CommandPalette.test.tsx new file mode 100644 index 00000000000..02cdd308229 --- /dev/null +++ b/public/app/features/commandPalette/CommandPalette.test.tsx @@ -0,0 +1,60 @@ +import { KBarProvider } from 'kbar'; +import { render, screen } from 'test/test-utils'; + +import { useAssistant } from '@grafana/assistant'; +import { setPluginLinksHook } from '@grafana/runtime'; +import { setGetObservablePluginLinks } from '@grafana/runtime/internal'; + +import { getObservablePluginLinks } from '../plugins/extensions/getPluginExtensions'; + +import { CommandPalette } from './CommandPalette'; + +setPluginLinksHook(() => ({ + links: [], + isLoading: false, +})); +setGetObservablePluginLinks(getObservablePluginLinks); + +jest.mock('@grafana/assistant', () => ({ + ...jest.requireActual('@grafana/assistant'), + useAssistant: jest.fn(), + OpenAssistantButton: jest.fn().mockImplementation(({ title }) => ), +})); + +jest.mock('kbar', () => ({ + ...jest.requireActual('kbar'), + KBarPortal: jest.fn().mockImplementation(({ children }) =>
{children}
), + KBarAnimator: jest.fn().mockImplementation(({ children }) =>
{children}
), +})); + +const setup = () => { + return render( + + + + ); +}; + +describe('CommandPalette', () => { + it('should render empty state with AI Assistant button when no results and assistant is available', async () => { + // Mock assistant being available + (useAssistant as jest.Mock).mockReturnValue({ isAvailable: true }); + setup(); + + // Check if empty state message is rendered + expect(await screen.findByText('No results found')).toBeInTheDocument(); + // Check if AI Assistant button is rendered with correct props + expect(screen.getByRole('button', { name: 'Try searching with Grafana Assistant' })).toBeInTheDocument(); + }); + + it('should render empty state without AI Assistant button when assistant is not available', async () => { + // Mock assistant being unavailable + (useAssistant as jest.Mock).mockReturnValue({ isAvailable: false }); + setup(); + + // Check if empty state message is rendered + expect(await screen.findByText('No results found')).toBeInTheDocument(); + // Check that AI Assistant button is not rendered + expect(screen.queryByRole('button', { name: 'Try searching with Grafana Assistant' })).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index 1bd1634546c..d51b238a569 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -5,6 +5,7 @@ import { useOverlay } from '@react-aria/overlays'; import { KBarAnimator, KBarPortal, KBarPositioner, VisualState, useKBar, ActionImpl } from 'kbar'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { OpenAssistantButton, useAssistant } from '@grafana/assistant'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; @@ -88,7 +89,11 @@ function CommandPaletteContents() {
{scopesRow ?
{scopesRow}
: null}
- +
@@ -131,10 +136,13 @@ function AncestorBreadcrumbs() { interface RenderResultsProps { isFetchingSearchResults: boolean; searchResults: CommandPaletteAction[]; + searchQuery: string; } -const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResultsProps) => { +const RenderResults = ({ isFetchingSearchResults, searchResults, searchQuery }: RenderResultsProps) => { const { results: kbarResults, rootActionId } = useMatches(); + const { query } = useKBar(); + const { isAvailable: isAssistantAvailable } = useAssistant(); const lateralSpace = getCommandPalettePosition(); const styles = useStyles2(getSearchStyles, lateralSpace); @@ -176,11 +184,16 @@ const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResults }, [showEmptyState]); return showEmptyState ? ( - + + {isAssistantAvailable && ( + + )} + ) : ( =12.1.0" "@grafana/runtime": ">=12.1.0" @@ -3027,7 +3027,7 @@ __metadata: "@grafana/ui": ">=12.1.0" react: ">=18.0.0" rxjs: ">=7.0.0" - checksum: 10/90cfee9860bc128190ec1357e554685892b3ddd080d914457da311f1d45294dfcc15a427be36c381463eefe39268b5abbc39e6b541ad402e78264cc6d038c23f + checksum: 10/33e5b3f59b3b7a747a736f36183c77014214fa6e8048faac7a680dbba64833b1827dd7ef2e8c1ddf566a31829347449579ef6255811586b39914ca0510bb34a5 languageName: node linkType: hard @@ -3248,7 +3248,7 @@ __metadata: tslib: "npm:2.8.1" typescript: "npm:5.9.2" peerDependencies: - "@grafana/assistant": ^0.1.0 + "@grafana/assistant": ^0.1.1 react: ^18.0.0 react-dom: ^18.0.0 languageName: unknown @@ -18844,7 +18844,7 @@ __metadata: "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana/alerting": "workspace:*" "@grafana/api-clients": "workspace:*" - "@grafana/assistant": "npm:0.1.0" + "@grafana/assistant": "npm:0.1.1" "@grafana/aws-sdk": "npm:0.7.1" "@grafana/azure-sdk": "npm:0.0.8" "@grafana/data": "workspace:*" From daa28773d6a085b172557396582cde1fe8ca3acc Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:52:23 +0100 Subject: [PATCH 036/209] SCIM: Update UIDs for provisioned users (#113423) * Update UIDs for provisioned users * change the prefix from scim_ to scim- * Update tests --- pkg/services/authn/authnimpl/sync/user_sync_test.go | 6 +++--- pkg/services/sqlstore/migrations/user_mig.go | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index dd19836b0a5..834f512356d 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -149,7 +149,7 @@ func TestUserSync_SyncUserHook(t *testing.T) { scimUserNotAdminInitial := &user.User{ ID: 100, - UID: "scim_uid_100", + UID: "scim-uid-100", Login: "scim.user.notadmin", Email: "scim.notadmin@example.com", Name: "SCIM NotAdmin", @@ -160,7 +160,7 @@ func TestUserSync_SyncUserHook(t *testing.T) { scimUserIsAdminInitial := &user.User{ ID: 101, - UID: "scim_uid_101", + UID: "scim-uid-101", Login: "scim.user.isadmin", Email: "scim.isadmin@example.com", Name: "SCIM IsAdmin", @@ -171,7 +171,7 @@ func TestUserSync_SyncUserHook(t *testing.T) { nonScimUserInitial := &user.User{ ID: 102, - UID: "nonscim_uid_102", + UID: "nonscim-uid-102", Login: "nonscim.user", Email: "nonscim@example.com", Name: "NonSCIM User", diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index af9f0d235cd..dfcd40306aa 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -181,6 +181,12 @@ func addUserMigrations(mg *Migrator) { mg.AddMigration("Add index on user.is_service_account and user.last_seen_at", NewAddIndexMigration(userV2, &Index{ Cols: []string{"is_service_account", "last_seen_at"}, Type: IndexType, })) + + // Prefix SCIM UID for provisioned users to avoid numeric/existing-id collisions + mg.AddMigration("Prefix SCIM uid for provisioned users", NewRawSQLMigration(""). + SQLite("UPDATE user SET uid = 'scim-' || uid WHERE is_provisioned = 1 AND uid NOT LIKE 'scim-%';"). + Postgres("UPDATE `user` SET uid = 'scim-' || uid WHERE is_provisioned = TRUE AND uid NOT LIKE 'scim-%';"). + Mysql("UPDATE user SET uid = CONCAT('scim-', uid) WHERE is_provisioned = 1 AND uid NOT LIKE 'scim-%';")) } const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0; From 06373ae47b97b013ca428eac0449e097a233e1cb Mon Sep 17 00:00:00 2001 From: Misi Date: Wed, 5 Nov 2025 18:02:34 +0100 Subject: [PATCH 037/209] IAM: Add ExternalGroupMapping kind for TeamSync (#113052) * wip * wip * Add authorizer -> VERIFY it's working correctly * Update openapi definitions * Authorizer wip * regen apis * Increase timeout of pg int tests to 20m * Revert "Increase timeout of pg int tests to 20m" This reverts commit 8c20568217f267b31f709092d708acbd45e01a4f. * Fix NewTestStore when Truncate is enabled --- apps/iam/kinds/externalgroupmapping.cue | 20 + apps/iam/kinds/manifest.cue | 1 + .../v0alpha1/externalgroupmappingspec.cue | 6 + .../externalgroupmapping_client_gen.go | 80 ++ .../externalgroupmapping_codec_gen.go | 28 + .../externalgroupmapping_metadata_gen.go | 31 + .../externalgroupmapping_object_gen.go | 293 +++++ .../externalgroupmapping_schema_gen.go | 34 + .../v0alpha1/externalgroupmapping_spec_gen.go | 27 + apps/iam/pkg/apis/iam/v0alpha1/register.go | 26 + .../pkg/apis/iam/v0alpha1/zz_openapi_gen.go | 143 +++ apps/iam/pkg/apis/iam_manifest.go | 28 +- .../rtkq/iam/v0alpha1/endpoints.gen.ts | 348 +++++- pkg/registry/apis/iam/authorizer.go | 1 + pkg/registry/apis/iam/models.go | 15 +- pkg/registry/apis/iam/register.go | 61 +- pkg/registry/apis/wireset.go | 1 + pkg/server/wire_gen.go | 4 +- pkg/services/authz/rbac/mapper.go | 21 + pkg/services/authz/rbac/resolver.go | 4 +- pkg/services/sqlstore/sqlstore_testinfra.go | 2 +- .../iam.grafana.app-v0alpha1.json | 1053 +++++++++++++++++ 22 files changed, 2179 insertions(+), 48 deletions(-) create mode 100644 apps/iam/kinds/externalgroupmapping.cue create mode 100644 apps/iam/kinds/v0alpha1/externalgroupmappingspec.cue create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_client_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_codec_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_metadata_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_spec_gen.go diff --git a/apps/iam/kinds/externalgroupmapping.cue b/apps/iam/kinds/externalgroupmapping.cue new file mode 100644 index 00000000000..b4a8bf2d7f5 --- /dev/null +++ b/apps/iam/kinds/externalgroupmapping.cue @@ -0,0 +1,20 @@ +package kinds + +import ( + "github.com/grafana/grafana/apps/iam/kinds/v0alpha1" +) + +externalGroupMappingKind: { + kind: "ExternalGroupMapping" + pluralName: "ExternalGroupMappings" + codegen: { + ts: {enabled: false} + go: {enabled: true} + } +} + +externalGroupMappingv0alpha1: externalGroupMappingKind & { + schema: { + spec: v0alpha1.ExternalGroupMappingSpec + } +} diff --git a/apps/iam/kinds/manifest.cue b/apps/iam/kinds/manifest.cue index c9390acbf3e..bc9c3d47ad7 100644 --- a/apps/iam/kinds/manifest.cue +++ b/apps/iam/kinds/manifest.cue @@ -20,5 +20,6 @@ v0alpha1: { teamv0alpha1, teambindingv0alpha1, serviceaccountv0alpha1, + externalGroupMappingv0alpha1 ] } diff --git a/apps/iam/kinds/v0alpha1/externalgroupmappingspec.cue b/apps/iam/kinds/v0alpha1/externalgroupmappingspec.cue new file mode 100644 index 00000000000..175da727409 --- /dev/null +++ b/apps/iam/kinds/v0alpha1/externalgroupmappingspec.cue @@ -0,0 +1,6 @@ +package v0alpha1 + +ExternalGroupMappingSpec: { + teamRef: TeamRef + externalGroupId: string +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_client_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_client_gen.go new file mode 100644 index 00000000000..8419fce6046 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_client_gen.go @@ -0,0 +1,80 @@ +package v0alpha1 + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/resource" +) + +type ExternalGroupMappingClient struct { + client *resource.TypedClient[*ExternalGroupMapping, *ExternalGroupMappingList] +} + +func NewExternalGroupMappingClient(client resource.Client) *ExternalGroupMappingClient { + return &ExternalGroupMappingClient{ + client: resource.NewTypedClient[*ExternalGroupMapping, *ExternalGroupMappingList](client, ExternalGroupMappingKind()), + } +} + +func NewExternalGroupMappingClientFromGenerator(generator resource.ClientGenerator) (*ExternalGroupMappingClient, error) { + c, err := generator.ClientFor(ExternalGroupMappingKind()) + if err != nil { + return nil, err + } + return NewExternalGroupMappingClient(c), nil +} + +func (c *ExternalGroupMappingClient) Get(ctx context.Context, identifier resource.Identifier) (*ExternalGroupMapping, error) { + return c.client.Get(ctx, identifier) +} + +func (c *ExternalGroupMappingClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*ExternalGroupMappingList, error) { + return c.client.List(ctx, namespace, opts) +} + +func (c *ExternalGroupMappingClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*ExternalGroupMappingList, error) { + resp, err := c.client.List(ctx, namespace, resource.ListOptions{ + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + for resp.GetContinue() != "" { + page, err := c.client.List(ctx, namespace, resource.ListOptions{ + Continue: resp.GetContinue(), + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + resp.SetContinue(page.GetContinue()) + resp.SetResourceVersion(page.GetResourceVersion()) + resp.SetItems(append(resp.GetItems(), page.GetItems()...)) + } + return resp, nil +} + +func (c *ExternalGroupMappingClient) Create(ctx context.Context, obj *ExternalGroupMapping, opts resource.CreateOptions) (*ExternalGroupMapping, error) { + // Make sure apiVersion and kind are set + obj.APIVersion = GroupVersion.Identifier() + obj.Kind = ExternalGroupMappingKind().Kind() + return c.client.Create(ctx, obj, opts) +} + +func (c *ExternalGroupMappingClient) Update(ctx context.Context, obj *ExternalGroupMapping, opts resource.UpdateOptions) (*ExternalGroupMapping, error) { + return c.client.Update(ctx, obj, opts) +} + +func (c *ExternalGroupMappingClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*ExternalGroupMapping, error) { + return c.client.Patch(ctx, identifier, req, opts) +} + +func (c *ExternalGroupMappingClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + return c.client.Delete(ctx, identifier, opts) +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_codec_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_codec_gen.go new file mode 100644 index 00000000000..9e519d40b6f --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_codec_gen.go @@ -0,0 +1,28 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "encoding/json" + "io" + + "github.com/grafana/grafana-app-sdk/resource" +) + +// ExternalGroupMappingJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type ExternalGroupMappingJSONCodec struct{} + +// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` +func (*ExternalGroupMappingJSONCodec) Read(reader io.Reader, into resource.Object) error { + return json.NewDecoder(reader).Decode(into) +} + +// Write writes JSON-encoded bytes into `writer` marshaled from `from` +func (*ExternalGroupMappingJSONCodec) Write(writer io.Writer, from resource.Object) error { + return json.NewEncoder(writer).Encode(from) +} + +// Interface compliance checks +var _ resource.Codec = &ExternalGroupMappingJSONCodec{} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_metadata_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_metadata_gen.go new file mode 100644 index 00000000000..5b33b2d2279 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_metadata_gen.go @@ -0,0 +1,31 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + time "time" +) + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +type ExternalGroupMappingMetadata struct { + UpdateTimestamp time.Time `json:"updateTimestamp"` + CreatedBy string `json:"createdBy"` + Uid string `json:"uid"` + CreationTimestamp time.Time `json:"creationTimestamp"` + DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` + Finalizers []string `json:"finalizers"` + ResourceVersion string `json:"resourceVersion"` + Generation int64 `json:"generation"` + UpdatedBy string `json:"updatedBy"` + Labels map[string]string `json:"labels"` +} + +// NewExternalGroupMappingMetadata creates a new ExternalGroupMappingMetadata object. +func NewExternalGroupMappingMetadata() *ExternalGroupMappingMetadata { + return &ExternalGroupMappingMetadata{ + Finalizers: []string{}, + Labels: map[string]string{}, + } +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go new file mode 100644 index 00000000000..db20616c355 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go @@ -0,0 +1,293 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "fmt" + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "time" +) + +// +k8s:openapi-gen=true +type ExternalGroupMapping struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata" yaml:"metadata"` + + // Spec is the spec of the ExternalGroupMapping + Spec ExternalGroupMappingSpec `json:"spec" yaml:"spec"` +} + +func (o *ExternalGroupMapping) GetSpec() any { + return o.Spec +} + +func (o *ExternalGroupMapping) SetSpec(spec any) error { + cast, ok := spec.(ExternalGroupMappingSpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *ExternalGroupMapping) GetSubresources() map[string]any { + return map[string]any{} +} + +func (o *ExternalGroupMapping) GetSubresource(name string) (any, bool) { + switch name { + default: + return nil, false + } +} + +func (o *ExternalGroupMapping) SetSubresource(name string, value any) error { + switch name { + default: + return fmt.Errorf("subresource '%s' does not exist", name) + } +} + +func (o *ExternalGroupMapping) GetStaticMetadata() resource.StaticMetadata { + gvk := o.GroupVersionKind() + return resource.StaticMetadata{ + Name: o.ObjectMeta.Name, + Namespace: o.ObjectMeta.Namespace, + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} + +func (o *ExternalGroupMapping) SetStaticMetadata(metadata resource.StaticMetadata) { + o.Name = metadata.Name + o.Namespace = metadata.Namespace + o.SetGroupVersionKind(schema.GroupVersionKind{ + Group: metadata.Group, + Version: metadata.Version, + Kind: metadata.Kind, + }) +} + +func (o *ExternalGroupMapping) GetCommonMetadata() resource.CommonMetadata { + dt := o.DeletionTimestamp + var deletionTimestamp *time.Time + if dt != nil { + deletionTimestamp = &dt.Time + } + // Legacy ExtraFields support + extraFields := make(map[string]any) + if o.Annotations != nil { + extraFields["annotations"] = o.Annotations + } + if o.ManagedFields != nil { + extraFields["managedFields"] = o.ManagedFields + } + if o.OwnerReferences != nil { + extraFields["ownerReferences"] = o.OwnerReferences + } + return resource.CommonMetadata{ + UID: string(o.UID), + ResourceVersion: o.ResourceVersion, + Generation: o.Generation, + Labels: o.Labels, + CreationTimestamp: o.CreationTimestamp.Time, + DeletionTimestamp: deletionTimestamp, + Finalizers: o.Finalizers, + UpdateTimestamp: o.GetUpdateTimestamp(), + CreatedBy: o.GetCreatedBy(), + UpdatedBy: o.GetUpdatedBy(), + ExtraFields: extraFields, + } +} + +func (o *ExternalGroupMapping) SetCommonMetadata(metadata resource.CommonMetadata) { + o.UID = types.UID(metadata.UID) + o.ResourceVersion = metadata.ResourceVersion + o.Generation = metadata.Generation + o.Labels = metadata.Labels + o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) + if metadata.DeletionTimestamp != nil { + dt := metav1.NewTime(*metadata.DeletionTimestamp) + o.DeletionTimestamp = &dt + } else { + o.DeletionTimestamp = nil + } + o.Finalizers = metadata.Finalizers + if o.Annotations == nil { + o.Annotations = make(map[string]string) + } + if !metadata.UpdateTimestamp.IsZero() { + o.SetUpdateTimestamp(metadata.UpdateTimestamp) + } + if metadata.CreatedBy != "" { + o.SetCreatedBy(metadata.CreatedBy) + } + if metadata.UpdatedBy != "" { + o.SetUpdatedBy(metadata.UpdatedBy) + } + // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields + if metadata.ExtraFields != nil { + if annotations, ok := metadata.ExtraFields["annotations"]; ok { + if cast, ok := annotations.(map[string]string); ok { + o.Annotations = cast + } + } + if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { + if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { + o.ManagedFields = cast + } + } + if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { + if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { + o.OwnerReferences = cast + } + } + } +} + +func (o *ExternalGroupMapping) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *ExternalGroupMapping) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *ExternalGroupMapping) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) + return parsed +} + +func (o *ExternalGroupMapping) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *ExternalGroupMapping) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *ExternalGroupMapping) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +func (o *ExternalGroupMapping) Copy() resource.Object { + return resource.CopyObject(o) +} + +func (o *ExternalGroupMapping) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *ExternalGroupMapping) DeepCopy() *ExternalGroupMapping { + cpy := &ExternalGroupMapping{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *ExternalGroupMapping) DeepCopyInto(dst *ExternalGroupMapping) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) + o.Spec.DeepCopyInto(&dst.Spec) +} + +// Interface compliance compile-time check +var _ resource.Object = &ExternalGroupMapping{} + +// +k8s:openapi-gen=true +type ExternalGroupMappingList struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ListMeta `json:"metadata" yaml:"metadata"` + Items []ExternalGroupMapping `json:"items" yaml:"items"` +} + +func (o *ExternalGroupMappingList) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *ExternalGroupMappingList) Copy() resource.ListObject { + cpy := &ExternalGroupMappingList{ + TypeMeta: o.TypeMeta, + Items: make([]ExternalGroupMapping, len(o.Items)), + } + o.ListMeta.DeepCopyInto(&cpy.ListMeta) + for i := 0; i < len(o.Items); i++ { + if item, ok := o.Items[i].Copy().(*ExternalGroupMapping); ok { + cpy.Items[i] = *item + } + } + return cpy +} + +func (o *ExternalGroupMappingList) GetItems() []resource.Object { + items := make([]resource.Object, len(o.Items)) + for i := 0; i < len(o.Items); i++ { + items[i] = &o.Items[i] + } + return items +} + +func (o *ExternalGroupMappingList) SetItems(items []resource.Object) { + o.Items = make([]ExternalGroupMapping, len(items)) + for i := 0; i < len(items); i++ { + o.Items[i] = *items[i].(*ExternalGroupMapping) + } +} + +func (o *ExternalGroupMappingList) DeepCopy() *ExternalGroupMappingList { + cpy := &ExternalGroupMappingList{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *ExternalGroupMappingList) DeepCopyInto(dst *ExternalGroupMappingList) { + resource.CopyObjectInto(dst, o) +} + +// Interface compliance compile-time check +var _ resource.ListObject = &ExternalGroupMappingList{} + +// Copy methods for all subresource types + +// DeepCopy creates a full deep copy of Spec +func (s *ExternalGroupMappingSpec) DeepCopy() *ExternalGroupMappingSpec { + cpy := &ExternalGroupMappingSpec{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies Spec into another Spec object +func (s *ExternalGroupMappingSpec) DeepCopyInto(dst *ExternalGroupMappingSpec) { + resource.CopyObjectInto(dst, s) +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go new file mode 100644 index 00000000000..91090a9b460 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go @@ -0,0 +1,34 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" +) + +// schema is unexported to prevent accidental overwrites +var ( + schemaExternalGroupMapping = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &ExternalGroupMapping{}, &ExternalGroupMappingList{}, resource.WithKind("ExternalGroupMapping"), + resource.WithPlural("externalgroupmappings"), resource.WithScope(resource.NamespacedScope)) + kindExternalGroupMapping = resource.Kind{ + Schema: schemaExternalGroupMapping, + Codecs: map[resource.KindEncoding]resource.Codec{ + resource.KindEncodingJSON: &ExternalGroupMappingJSONCodec{}, + }, + } +) + +// Kind returns a resource.Kind for this Schema with a JSON codec +func ExternalGroupMappingKind() resource.Kind { + return kindExternalGroupMapping +} + +// Schema returns a resource.SimpleSchema representation of ExternalGroupMapping +func ExternalGroupMappingSchema() *resource.SimpleSchema { + return schemaExternalGroupMapping +} + +// Interface compliance checks +var _ resource.Schema = kindExternalGroupMapping diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_spec_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_spec_gen.go new file mode 100644 index 00000000000..8bfbfe5b04a --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_spec_gen.go @@ -0,0 +1,27 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type ExternalGroupMappingTeamRef struct { + // Name is the unique identifier for a team. + Name string `json:"name"` +} + +// NewExternalGroupMappingTeamRef creates a new ExternalGroupMappingTeamRef object. +func NewExternalGroupMappingTeamRef() *ExternalGroupMappingTeamRef { + return &ExternalGroupMappingTeamRef{} +} + +// +k8s:openapi-gen=true +type ExternalGroupMappingSpec struct { + TeamRef ExternalGroupMappingTeamRef `json:"teamRef"` + ExternalGroupId string `json:"externalGroupId"` +} + +// NewExternalGroupMappingSpec creates a new ExternalGroupMappingSpec object. +func NewExternalGroupMappingSpec() *ExternalGroupMappingSpec { + return &ExternalGroupMappingSpec{ + TeamRef: *NewExternalGroupMappingTeamRef(), + } +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/register.go b/apps/iam/pkg/apis/iam/v0alpha1/register.go index 16ee89847ef..54d60c7bd6f 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/register.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/register.go @@ -206,6 +206,30 @@ var TeamBindingResourceInfo = utils.NewResourceInfo( }, ) +var ExternalGroupMappingResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "externalgroupmappings", "externalgroupmapping", "ExternalGroupMapping", + func() runtime.Object { return &ExternalGroupMapping{} }, + func() runtime.Object { return &ExternalGroupMappingList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + }, + Reader: func(obj any) ([]interface{}, error) { + mapping, ok := obj.(*ExternalGroupMapping) + if ok { + if mapping != nil { + return []interface{}{ + mapping.Name, + mapping.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + } + return nil, fmt.Errorf("expected external group mapping") + }, + }, +) + var RoleBindingInfo = utils.NewResourceInfo(GROUP, VERSION, "rolebindings", "rolebinding", "RoleBinding", func() runtime.Object { return &RoleBinding{} }, @@ -295,6 +319,8 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error { &TeamList{}, &TeamBinding{}, &TeamBindingList{}, + &ExternalGroupMapping{}, + &ExternalGroupMappingList{}, // For now these are registered in pkg/apis/iam/v0alpha1/register.go // &UserTeamList{}, // &ServiceAccountTokenList{}, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index ac03b342c18..f0c517afd38 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -18,6 +18,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.CoreRoleStatus": schema_pkg_apis_iam_v0alpha1_CoreRoleStatus(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.CoreRolespecPermission": schema_pkg_apis_iam_v0alpha1_CoreRolespecPermission(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.CoreRolestatusOperatorState": schema_pkg_apis_iam_v0alpha1_CoreRolestatusOperatorState(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMapping": schema_pkg_apis_iam_v0alpha1_ExternalGroupMapping(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingList": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingList(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingSpec": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingSpec(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingTeamRef(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": schema_pkg_apis_iam_v0alpha1_GlobalRole(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBinding": schema_pkg_apis_iam_v0alpha1_GlobalRoleBinding(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBindingList": schema_pkg_apis_iam_v0alpha1_GlobalRoleBindingList(ref), @@ -348,6 +352,145 @@ func schema_pkg_apis_iam_v0alpha1_CoreRolestatusOperatorState(ref common.Referen } } +func schema_pkg_apis_iam_v0alpha1_ExternalGroupMapping(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Spec is the spec of the ExternalGroupMapping", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingSpec"), + }, + }, + }, + Required: []string{"metadata", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingList(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/iam/pkg/apis/iam/v0alpha1.ExternalGroupMapping"), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMapping", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "teamRef": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef"), + }, + }, + "externalGroupId": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"teamRef", "externalGroupId"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef"}, + } +} + +func schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingTeamRef(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name is the unique identifier for a team.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name"}, + }, + }, + } +} + func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/iam/pkg/apis/iam_manifest.go b/apps/iam/pkg/apis/iam_manifest.go index 644ad2ea2d1..388c0dca609 100644 --- a/apps/iam/pkg/apis/iam_manifest.go +++ b/apps/iam/pkg/apis/iam_manifest.go @@ -96,6 +96,13 @@ var appManifestData = app.ManifestData{ Scope: "Namespaced", Conversion: false, }, + + { + Kind: "ExternalGroupMapping", + Plural: "ExternalGroupMappings", + Scope: "Namespaced", + Conversion: false, + }, }, Routes: app.ManifestVersionRoutes{ Namespaced: map[string]spec3.PathProps{}, @@ -115,16 +122,17 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "GlobalRole/v0alpha1": v0alpha1.GlobalRoleKind(), - "GlobalRoleBinding/v0alpha1": v0alpha1.GlobalRoleBindingKind(), - "CoreRole/v0alpha1": v0alpha1.CoreRoleKind(), - "Role/v0alpha1": v0alpha1.RoleKind(), - "RoleBinding/v0alpha1": v0alpha1.RoleBindingKind(), - "ResourcePermission/v0alpha1": v0alpha1.ResourcePermissionKind(), - "User/v0alpha1": v0alpha1.UserKind(), - "Team/v0alpha1": v0alpha1.TeamKind(), - "TeamBinding/v0alpha1": v0alpha1.TeamBindingKind(), - "ServiceAccount/v0alpha1": v0alpha1.ServiceAccountKind(), + "GlobalRole/v0alpha1": v0alpha1.GlobalRoleKind(), + "GlobalRoleBinding/v0alpha1": v0alpha1.GlobalRoleBindingKind(), + "CoreRole/v0alpha1": v0alpha1.CoreRoleKind(), + "Role/v0alpha1": v0alpha1.RoleKind(), + "RoleBinding/v0alpha1": v0alpha1.RoleBindingKind(), + "ResourcePermission/v0alpha1": v0alpha1.ResourcePermissionKind(), + "User/v0alpha1": v0alpha1.UserKind(), + "Team/v0alpha1": v0alpha1.TeamKind(), + "TeamBinding/v0alpha1": v0alpha1.TeamBindingKind(), + "ServiceAccount/v0alpha1": v0alpha1.ServiceAccountKind(), + "ExternalGroupMapping/v0alpha1": v0alpha1.ExternalGroupMappingKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts index 3645b4b3373..e9ce26a3cbe 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts @@ -2,6 +2,7 @@ import { api } from './baseAPI'; export const addTagTypes = [ 'API Discovery', 'Display', + 'ExternalGroupMapping', 'ServiceAccount', 'SSOSetting', 'TeamBinding', @@ -27,6 +28,130 @@ const injectedRtkApi = api }), providesTags: ['Display'], }), + listExternalGroupMapping: build.query({ + query: (queryArg) => ({ + url: `/externalgroupmappings`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['ExternalGroupMapping'], + }), + createExternalGroupMapping: build.mutation< + CreateExternalGroupMappingApiResponse, + CreateExternalGroupMappingApiArg + >({ + query: (queryArg) => ({ + url: `/externalgroupmappings`, + method: 'POST', + body: queryArg.externalGroupMapping, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ExternalGroupMapping'], + }), + deletecollectionExternalGroupMapping: build.mutation< + DeletecollectionExternalGroupMappingApiResponse, + DeletecollectionExternalGroupMappingApiArg + >({ + query: (queryArg) => ({ + url: `/externalgroupmappings`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['ExternalGroupMapping'], + }), + getExternalGroupMapping: build.query({ + query: (queryArg) => ({ + url: `/externalgroupmappings/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['ExternalGroupMapping'], + }), + replaceExternalGroupMapping: build.mutation< + ReplaceExternalGroupMappingApiResponse, + ReplaceExternalGroupMappingApiArg + >({ + query: (queryArg) => ({ + url: `/externalgroupmappings/${queryArg.name}`, + method: 'PUT', + body: queryArg.externalGroupMapping, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['ExternalGroupMapping'], + }), + deleteExternalGroupMapping: build.mutation< + DeleteExternalGroupMappingApiResponse, + DeleteExternalGroupMappingApiArg + >({ + query: (queryArg) => ({ + url: `/externalgroupmappings/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['ExternalGroupMapping'], + }), + updateExternalGroupMapping: build.mutation< + UpdateExternalGroupMappingApiResponse, + UpdateExternalGroupMappingApiArg + >({ + query: (queryArg) => ({ + url: `/externalgroupmappings/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['ExternalGroupMapping'], + }), listServiceAccount: build.query({ query: (queryArg) => ({ url: `/serviceaccounts`, @@ -564,6 +689,175 @@ export type GetDisplayMappingApiArg = { /** Display keys */ key: string[]; }; +export type ListExternalGroupMappingApiResponse = /** status 200 OK */ ExternalGroupMappingList; +export type ListExternalGroupMappingApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateExternalGroupMappingApiResponse = /** status 200 OK */ + | ExternalGroupMapping + | /** status 201 Created */ ExternalGroupMapping + | /** status 202 Accepted */ ExternalGroupMapping; +export type CreateExternalGroupMappingApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + externalGroupMapping: ExternalGroupMapping; +}; +export type DeletecollectionExternalGroupMappingApiResponse = /** status 200 OK */ Status; +export type DeletecollectionExternalGroupMappingApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetExternalGroupMappingApiResponse = /** status 200 OK */ ExternalGroupMapping; +export type GetExternalGroupMappingApiArg = { + /** name of the ExternalGroupMapping */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceExternalGroupMappingApiResponse = /** status 200 OK */ + | ExternalGroupMapping + | /** status 201 Created */ ExternalGroupMapping; +export type ReplaceExternalGroupMappingApiArg = { + /** name of the ExternalGroupMapping */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + externalGroupMapping: ExternalGroupMapping; +}; +export type DeleteExternalGroupMappingApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteExternalGroupMappingApiArg = { + /** name of the ExternalGroupMapping */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateExternalGroupMappingApiResponse = /** status 200 OK */ + | ExternalGroupMapping + | /** status 201 Created */ ExternalGroupMapping; +export type UpdateExternalGroupMappingApiArg = { + /** name of the ExternalGroupMapping */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; export type ListServiceAccountApiResponse = /** status 200 OK */ ServiceAccountList; export type ListServiceAccountApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -1494,25 +1788,27 @@ export type ObjectMeta = { Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; -export type ServiceAccountSpec = { - disabled: boolean; - plugin: string; - role: string; - title: string; +export type ExternalGroupMappingTeamRef = { + /** Name is the unique identifier for a team. */ + name: string; }; -export type ServiceAccount = { +export type ExternalGroupMappingSpec = { + externalGroupId: string; + teamRef: ExternalGroupMappingTeamRef; +}; +export type ExternalGroupMapping = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ kind?: string; metadata: ObjectMeta; - /** Spec is the spec of the ServiceAccount */ - spec: ServiceAccountSpec; + /** Spec is the spec of the ExternalGroupMapping */ + spec: ExternalGroupMappingSpec; }; -export type ServiceAccountList = { +export type ExternalGroupMappingList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; - items: ServiceAccount[]; + items: ExternalGroupMapping[]; /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ kind?: string; metadata: ListMeta; @@ -1562,6 +1858,29 @@ export type Status = { status?: string; }; export type Patch = object; +export type ServiceAccountSpec = { + disabled: boolean; + plugin: string; + role: string; + title: string; +}; +export type ServiceAccount = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ObjectMeta; + /** Spec is the spec of the ServiceAccount */ + spec: ServiceAccountSpec; +}; +export type ServiceAccountList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: ServiceAccount[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ListMeta; +}; export type ServiceAccountToken = { created: Time; expires?: Time; @@ -1736,6 +2055,15 @@ export const { useLazyGetApiResourcesQuery, useGetDisplayMappingQuery, useLazyGetDisplayMappingQuery, + useListExternalGroupMappingQuery, + useLazyListExternalGroupMappingQuery, + useCreateExternalGroupMappingMutation, + useDeletecollectionExternalGroupMappingMutation, + useGetExternalGroupMappingQuery, + useLazyGetExternalGroupMappingQuery, + useReplaceExternalGroupMappingMutation, + useDeleteExternalGroupMappingMutation, + useUpdateExternalGroupMappingMutation, useListServiceAccountQuery, useLazyListServiceAccountQuery, useCreateServiceAccountMutation, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index db7c2727fb7..89f3f5ff2fa 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -35,6 +35,7 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer} diff --git a/pkg/registry/apis/iam/models.go b/pkg/registry/apis/iam/models.go index 632a3f15a9a..c960b7a8655 100644 --- a/pkg/registry/apis/iam/models.go +++ b/pkg/registry/apis/iam/models.go @@ -35,14 +35,19 @@ type RoleStorageBackend interface{ resource.StorageBackend } // Used by wire to identify the storage backend for role bindings. type RoleBindingStorageBackend interface{ resource.StorageBackend } +// ExternalGroupMappingStorageBackend uses the resource.StorageBackend interface to provide storage for external group mappings. +// Used by wire to identify the storage backend for external group mappings. +type ExternalGroupMappingStorageBackend interface{ resource.StorageBackend } + // This is used just so wire has something unique to return type IdentityAccessManagementAPIBuilder struct { // Stores - store legacy.LegacyIdentityStore - coreRolesStorage CoreRoleStorageBackend - rolesStorage RoleStorageBackend - resourcePermissionsStorage resource.StorageBackend - roleBindingsStorage RoleBindingStorageBackend + store legacy.LegacyIdentityStore + coreRolesStorage CoreRoleStorageBackend + rolesStorage RoleStorageBackend + resourcePermissionsStorage resource.StorageBackend + roleBindingsStorage RoleBindingStorageBackend + externalGroupMappingStorage ExternalGroupMappingStorageBackend // Access Control authorizer authorizer.Authorizer diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 1356ea49261..752fd949b35 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -63,6 +63,7 @@ func RegisterAPIService( coreRolesStorage CoreRoleStorageBackend, rolesStorage RoleStorageBackend, roleBindingsStorage RoleBindingStorageBackend, + externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend, dual dualwrite.Service, unified resource.ResourceClient, userService legacyuser.Service, @@ -74,25 +75,26 @@ func RegisterAPIService( registerMetrics(reg) builder := &IdentityAccessManagementAPIBuilder{ - store: store, - coreRolesStorage: coreRolesStorage, - rolesStorage: rolesStorage, - resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider), - roleBindingsStorage: roleBindingsStorage, - sso: ssoService, - authorizer: authorizer, - legacyAccessClient: legacyAccessClient, - accessClient: accessClient, - zClient: zClient, - zTickets: make(chan bool, MaxConcurrentZanzanaWrites), - display: user.NewLegacyDisplayREST(store), - reg: reg, - logger: log.New("iam.apis"), - features: features, - enableDualWriter: true, - dual: dual, - unified: unified, - userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features), + store: store, + coreRolesStorage: coreRolesStorage, + rolesStorage: rolesStorage, + resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider), + roleBindingsStorage: roleBindingsStorage, + externalGroupMappingStorage: externalGroupMappingStorageBackend, + sso: ssoService, + authorizer: authorizer, + legacyAccessClient: legacyAccessClient, + accessClient: accessClient, + zClient: zClient, + zTickets: make(chan bool, MaxConcurrentZanzanaWrites), + display: user.NewLegacyDisplayREST(store), + reg: reg, + logger: log.New("iam.apis"), + features: features, + enableDualWriter: true, + dual: dual, + unified: unified, + userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features), } apiregistration.RegisterAPI(builder) @@ -289,6 +291,27 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso) } + externalGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo + externalGroupMappingLegacyStore, err := NewLocalStore(externalGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage) + if err != nil { + return err + } + storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingLegacyStore + + if b.enableDualWriter { + externalGroupMappingStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, externalGroupMappingResource, opts.OptsGetter) + if err != nil { + return err + } + + externalGroupMappingDW, err := opts.DualWriteBuilder(externalGroupMappingResource.GroupResource(), externalGroupMappingLegacyStore, externalGroupMappingStore) + if err != nil { + return err + } + + storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingDW + } + //nolint:staticcheck // not yet migrated to OpenFeature if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { // v0alpha1 diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 20bc5c3bf1f..58e22810c7b 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -28,6 +28,7 @@ var WireSetExts = wire.NewSet( wire.Bind(new(iam.CoreRoleStorageBackend), new(*noopstorage.StorageBackendImpl)), wire.Bind(new(iam.RoleStorageBackend), new(*noopstorage.StorageBackendImpl)), wire.Bind(new(iam.RoleBindingStorageBackend), new(*noopstorage.StorageBackendImpl)), + wire.Bind(new(iam.ExternalGroupMappingStorageBackend), new(*noopstorage.StorageBackendImpl)), ) var provisioningExtras = wire.NewSet( diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 417b4aeeb4d..6fd5a56b2cd 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -848,7 +848,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, dualwriteService, resourceClient, userService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, dualwriteService, resourceClient, userService) if err != nil { return nil, err } @@ -1482,7 +1482,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, dualwriteService, resourceClient, userService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, dualwriteService, resourceClient, userService) if err != nil { return nil, err } diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index f2db9256d92..fd7e5d2140c 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -182,6 +182,25 @@ func newFolderTranslation() translation { return folderTranslation } +func newExternalGroupMappingTranslation() translation { + return translation{ + resource: "teams.permissions", + attribute: "uid", + verbMapping: map[string]string{ + utils.VerbGet: "teams.permissions:read", + utils.VerbList: "teams.permissions:read", + utils.VerbWatch: "teams.permissions:read", + utils.VerbCreate: "teams.permissions:write", + utils.VerbUpdate: "teams.permissions:write", + utils.VerbPatch: "teams.permissions:write", + utils.VerbDelete: "teams.permissions:write", + utils.VerbGetPermissions: "teams.permissions:write", + utils.VerbSetPermissions: "teams.permissions:write", + }, + folderSupport: false, + } +} + func NewMapperRegistry() MapperRegistry { skipScopeOnAllVerbs := map[string]bool{ utils.VerbCreate: true, @@ -210,6 +229,8 @@ func NewMapperRegistry() MapperRegistry { "serviceaccounts": newResourceTranslation("serviceaccounts", "uid", false, map[string]bool{utils.VerbCreate: true}), // Teams is a special case. We translate user permissions from id to uid based. "teams": newResourceTranslation("teams", "uid", false, map[string]bool{utils.VerbCreate: true}), + // ExternalGroupMappings is a special case. We translate team permissions from id to uid based. + "externalgroupmappings": newExternalGroupMappingTranslation(), "coreroles": translation{ resource: "roles", attribute: "uid", diff --git a/pkg/services/authz/rbac/resolver.go b/pkg/services/authz/rbac/resolver.go index 56aacf539d6..0077d17c8ad 100644 --- a/pkg/services/authz/rbac/resolver.go +++ b/pkg/services/authz/rbac/resolver.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/registry/apis/iam/common" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/accesscontrol" ) @@ -54,7 +55,7 @@ func (s *Service) newServiceAccountNameResolver(ctx context.Context, ns types.Na func (s *Service) fetchTeams(ctx context.Context, ns types.NamespaceInfo) (map[int64]string, error) { key := teamIDsCacheKey(ns.Value) res, err, _ := s.sf.Do(key, func() (any, error) { - teams, err := s.identityStore.ListTeams(ctx, ns, legacy.ListTeamQuery{}) + teams, err := s.identityStore.ListTeams(ctx, ns, legacy.ListTeamQuery{Pagination: common.Pagination{Limit: 100}}) if err != nil { return nil, fmt.Errorf("could not fetch teams: %w", err) } @@ -170,6 +171,7 @@ func (s *Service) nameResolver(ctx context.Context, ns types.NamespaceInfo, scop if scopePrefix == "teams:id:" { return s.newTeamNameResolver(ctx, ns) } + if scopePrefix == "permissions:type:" { return permissionsDelegateResolverFunc, nil } diff --git a/pkg/services/sqlstore/sqlstore_testinfra.go b/pkg/services/sqlstore/sqlstore_testinfra.go index c75dfc28645..9d05f72b3fb 100644 --- a/pkg/services/sqlstore/sqlstore_testinfra.go +++ b/pkg/services/sqlstore/sqlstore_testinfra.go @@ -195,7 +195,7 @@ func NewTestStore(tb TestingTB, opts ...TestOption) *SQLStore { tb.Fatalf("failed to truncate DB tables after migrations: %v", err) panic("unreachable") } - testSQLStore.engine.ResetSequenceGenerator() + store.engine.ResetSequenceGenerator() } return store diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index c247c727237..d0ac7bbfff3 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -82,6 +82,836 @@ } } }, + "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/externalgroupmappings": { + "get": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "list or watch objects of kind ExternalGroupMapping", + "operationId": "listExternalGroupMapping", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingList" + } + }, + "application/json;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingList" + } + }, + "application/vnd.kubernetes.protobuf;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingList" + } + } + } + } + }, + "x-kubernetes-action": "list", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "post": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "create an ExternalGroupMapping", + "operationId": "createExternalGroupMapping", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "delete": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "delete collection of ExternalGroupMapping", + "operationId": "deletecollectionExternalGroupMapping", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/externalgroupmappings/{name}": { + "get": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "read the specified ExternalGroupMapping", + "operationId": "getExternalGroupMapping", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "put": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "replace the specified ExternalGroupMapping", + "operationId": "replaceExternalGroupMapping", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "delete": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "delete an ExternalGroupMapping", + "operationId": "deleteExternalGroupMapping", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "patch": { + "tags": [ + "ExternalGroupMapping" + ], + "description": "partially update the specified ExternalGroupMapping", + "operationId": "updateExternalGroupMapping", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ExternalGroupMapping" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the ExternalGroupMapping", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": { "get": { "tags": [ @@ -4102,6 +4932,124 @@ }, "components": { "schemas": { + "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping": { + "type": "object", + "required": [ + "metadata", + "spec" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + } + ] + }, + "spec": { + "description": "Spec is the spec of the ExternalGroupMapping", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingSpec" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "iam.grafana.app", + "kind": "ExternalGroupMapping", + "version": "v0alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingList": { + "type": "object", + "required": [ + "metadata", + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMapping" + } + ] + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "iam.grafana.app", + "kind": "ExternalGroupMappingList", + "version": "v0alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingSpec": { + "type": "object", + "required": [ + "teamRef", + "externalGroupId" + ], + "properties": { + "externalGroupId": { + "type": "string", + "default": "" + }, + "teamRef": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingTeamRef" + } + ] + } + } + }, + "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ExternalGroupMappingTeamRef": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "Name is the unique identifier for a team.", + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount": { "type": "object", "required": [ @@ -5210,6 +6158,85 @@ } } }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMapping": { + "type": "object", + "required": [ + "metadata", + "spec" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {} + }, + "spec": { + "description": "Spec is the spec of the ExternalGroupMapping", + "default": {} + } + } + }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingList": { + "type": "object", + "required": [ + "metadata", + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {} + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {} + } + } + }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingSpec": { + "type": "object", + "required": [ + "teamRef", + "externalGroupId" + ], + "properties": { + "externalGroupId": { + "type": "string", + "default": "" + }, + "teamRef": { + "default": {} + } + } + }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "Name is the unique identifier for a team.", + "type": "string", + "default": "" + } + } + }, "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": { "type": "object", "required": [ @@ -7053,6 +8080,32 @@ "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", "type": "string", "format": "date-time" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.WatchEvent": { + "description": "Event represents a single event to a watched resource.", + "type": "object", + "required": [ + "type", + "object" + ], + "properties": { + "object": { + "description": "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.runtime.RawExtension" + } + ] + }, + "type": { + "type": "string", + "default": "" + } + } + }, + "io.k8s.apimachinery.pkg.runtime.RawExtension": { + "description": "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)", + "type": "object" } } } From 612a0d1c7f788b217d2c80379c75a75f0b4b4035 Mon Sep 17 00:00:00 2001 From: colin-stuart Date: Wed, 5 Nov 2025 15:49:05 -0600 Subject: [PATCH 038/209] Revert "SCIM: Update UIDs for provisioned users (#113423)" (#113474) This reverts commit daa28773d6a085b172557396582cde1fe8ca3acc. --- pkg/services/authn/authnimpl/sync/user_sync_test.go | 6 +++--- pkg/services/sqlstore/migrations/user_mig.go | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index 834f512356d..dd19836b0a5 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -149,7 +149,7 @@ func TestUserSync_SyncUserHook(t *testing.T) { scimUserNotAdminInitial := &user.User{ ID: 100, - UID: "scim-uid-100", + UID: "scim_uid_100", Login: "scim.user.notadmin", Email: "scim.notadmin@example.com", Name: "SCIM NotAdmin", @@ -160,7 +160,7 @@ func TestUserSync_SyncUserHook(t *testing.T) { scimUserIsAdminInitial := &user.User{ ID: 101, - UID: "scim-uid-101", + UID: "scim_uid_101", Login: "scim.user.isadmin", Email: "scim.isadmin@example.com", Name: "SCIM IsAdmin", @@ -171,7 +171,7 @@ func TestUserSync_SyncUserHook(t *testing.T) { nonScimUserInitial := &user.User{ ID: 102, - UID: "nonscim-uid-102", + UID: "nonscim_uid_102", Login: "nonscim.user", Email: "nonscim@example.com", Name: "NonSCIM User", diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index dfcd40306aa..af9f0d235cd 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -181,12 +181,6 @@ func addUserMigrations(mg *Migrator) { mg.AddMigration("Add index on user.is_service_account and user.last_seen_at", NewAddIndexMigration(userV2, &Index{ Cols: []string{"is_service_account", "last_seen_at"}, Type: IndexType, })) - - // Prefix SCIM UID for provisioned users to avoid numeric/existing-id collisions - mg.AddMigration("Prefix SCIM uid for provisioned users", NewRawSQLMigration(""). - SQLite("UPDATE user SET uid = 'scim-' || uid WHERE is_provisioned = 1 AND uid NOT LIKE 'scim-%';"). - Postgres("UPDATE `user` SET uid = 'scim-' || uid WHERE is_provisioned = TRUE AND uid NOT LIKE 'scim-%';"). - Mysql("UPDATE user SET uid = CONCAT('scim-', uid) WHERE is_provisioned = 1 AND uid NOT LIKE 'scim-%';")) } const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0; From 7236ba5fce1e7dea2b118875ff3869fa19cf3fc4 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:54:05 +0100 Subject: [PATCH 039/209] Release: Bump version to 12.4.0-pre (#113480) bump version 12.4.0-pre Co-authored-by: grafana-delivery-bot[bot] --- .../grafana-extensionstest-app/package.json | 2 +- .../grafana-test-datasource/package.json | 2 +- lerna.json | 2 +- package.json | 2 +- packages/grafana-alerting/package.json | 4 +- packages/grafana-api-clients/package.json | 2 +- packages/grafana-data/package.json | 6 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-eslint-rules/package.json | 2 +- packages/grafana-flamegraph/package.json | 6 +- packages/grafana-i18n/package.json | 2 +- .../grafana-o11y-ds-frontend/package.json | 12 +- packages/grafana-plugin-configs/package.json | 2 +- packages/grafana-prometheus/package.json | 14 +- packages/grafana-runtime/package.json | 10 +- packages/grafana-schema/package.json | 2 +- .../x/AnnotationsListPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/BarChartPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/BarGaugePanelCfg_types.gen.ts | 2 +- .../x/CandlestickPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/CanvasPanelCfg_types.gen.ts | 2 +- .../x/CloudWatchDataQuery_types.gen.ts | 2 +- .../x/DashboardListPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/DatagridPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/DebugPanelCfg_types.gen.ts | 2 +- .../x/ElasticsearchDataQuery_types.gen.ts | 2 +- .../panelcfg/x/GaugePanelCfg_types.gen.ts | 2 +- .../panelcfg/x/GeomapPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/HeatmapPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/HistogramPanelCfg_types.gen.ts | 2 +- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/NewGaugePanelCfg_types.gen.ts | 2 +- .../news/panelcfg/x/NewsPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/NodeGraphPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/PieChartPanelCfg_types.gen.ts | 2 +- .../stat/panelcfg/x/StatPanelCfg_types.gen.ts | 2 +- .../x/StateTimelinePanelCfg_types.gen.ts | 2 +- .../x/StatusHistoryPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/TablePanelCfg_types.gen.ts | 2 +- .../text/panelcfg/x/TextPanelCfg_types.gen.ts | 2 +- .../x/TimeSeriesPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/TrendPanelCfg_types.gen.ts | 2 +- .../panelcfg/x/XYChartPanelCfg_types.gen.ts | 2 +- packages/grafana-sql/package.json | 12 +- packages/grafana-test-utils/package.json | 2 +- packages/grafana-ui/package.json | 10 +- .../datasource/azuremonitor/package.json | 16 +- .../datasource/cloud-monitoring/package.json | 14 +- .../package.json | 14 +- .../grafana-pyroscope-datasource/package.json | 12 +- .../grafana-testdata-datasource/package.json | 14 +- .../plugins/datasource/graphite/package.json | 14 +- .../plugins/datasource/jaeger/package.json | 2 +- .../app/plugins/datasource/loki/package.json | 14 +- .../app/plugins/datasource/mssql/package.json | 16 +- .../app/plugins/datasource/mysql/package.json | 14 +- .../app/plugins/datasource/parca/package.json | 12 +- .../app/plugins/datasource/tempo/package.json | 4 +- .../plugins/datasource/zipkin/package.json | 2 +- yarn.lock | 196 +++++++++--------- 60 files changed, 247 insertions(+), 247 deletions(-) diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/package.json b/e2e-playwright/test-plugins/grafana-extensionstest-app/package.json index 12829268e71..0b9f45a25a3 100644 --- a/e2e-playwright/test-plugins/grafana-extensionstest-app/package.json +++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/package.json @@ -1,6 +1,6 @@ { "name": "@test-plugins/extensions-test-app", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "private": true, "scripts": { "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", diff --git a/e2e-playwright/test-plugins/grafana-test-datasource/package.json b/e2e-playwright/test-plugins/grafana-test-datasource/package.json index e62edcbaad6..8d97d03dc30 100644 --- a/e2e-playwright/test-plugins/grafana-test-datasource/package.json +++ b/e2e-playwright/test-plugins/grafana-test-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@test-plugins/grafana-e2etest-datasource", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "private": true, "scripts": { "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", diff --git a/lerna.json b/lerna.json index 70b729cb019..d6dc835d802 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "npmClient": "yarn", - "version": "12.3.0-pre" + "version": "12.4.0-pre" } diff --git a/package.json b/package.json index 9c03062a448..6b4cc94733f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "license": "AGPL-3.0-only", "private": true, "name": "grafana", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "repository": "github:grafana/grafana", "scripts": { "check-frontend-dev": "./scripts/check-frontend-dev.sh", diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 226771138ff..da13aa6fc0f 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/alerting", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Alerting Library – Build vertical integrations on top of the industry-leading alerting solution", "keywords": [ "typescript", @@ -94,7 +94,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@faker-js/faker": "^9.8.0", - "@grafana/i18n": "12.3.0-pre", + "@grafana/i18n": "12.4.0-pre", "@reduxjs/toolkit": "^2.9.0", "fishery": "^2.3.1", "lodash": "^4.17.21", diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 9741c97303b..3f5482340a6 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -3,7 +3,7 @@ "license": "Apache-2.0", "private": true, "name": "@grafana/api-clients", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana API client utilities", "keywords": [ "grafana", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index c990c304caf..2fce041444e 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/data", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Data Library", "keywords": [ "typescript" @@ -56,8 +56,8 @@ }, "dependencies": { "@braintree/sanitize-url": "7.0.1", - "@grafana/i18n": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", + "@grafana/i18n": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", "@leeoniya/ufuzzy": "1.0.19", "@types/d3-interpolate": "^3.0.0", "@types/string-hash": "1.1.3", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 9a50bc2ee5c..7a8109b8a57 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e-selectors", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana End-to-End Test Selectors Library", "keywords": [ "cli", diff --git a/packages/grafana-eslint-rules/package.json b/packages/grafana-eslint-rules/package.json index f182cd0281b..9fcf5867033 100644 --- a/packages/grafana-eslint-rules/package.json +++ b/packages/grafana-eslint-rules/package.json @@ -1,7 +1,7 @@ { "name": "@grafana/eslint-plugin", "description": "ESLint rules for use within the Grafana repo. Not suitable (or supported) for external use.", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "main": "./index.cjs", "author": "Grafana Labs", "license": "Apache-2.0", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 14a3c5ab891..d450f911e70 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/flamegraph", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana flamegraph visualization component", "keywords": [ "grafana", @@ -44,8 +44,8 @@ ], "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "@leeoniya/ufuzzy": "1.0.19", "d3": "^7.8.5", "lodash": "4.17.21", diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index ba8d2311acf..824f594f8e6 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/i18n", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Internationalization Library", "keywords": [ "grafana", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index b79a584715f..569b7e60c88 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -3,7 +3,7 @@ "license": "AGPL-3.0-only", "name": "@grafana/o11y-ds-frontend", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Library to manage traces in Grafana.", "sideEffects": false, "repository": { @@ -18,12 +18,12 @@ }, "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/e2e-selectors": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "react-select": "5.10.2", "react-use": "17.6.0", "rxjs": "7.8.2", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 84a74940dbf..68400c19b92 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -2,7 +2,7 @@ "name": "@grafana/plugin-configs", "description": "Shared dependencies and files for core plugins", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "react": "18.3.1", "terser-webpack-plugin": "5.3.14", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 4c8f16ac688..a4d36839bf6 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "AGPL-3.0-only", "name": "@grafana/prometheus", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Prometheus Library", "keywords": [ "typescript", @@ -41,13 +41,13 @@ "dependencies": { "@emotion/css": "11.13.5", "@floating-ui/react": "0.27.16", - "@grafana/data": "12.3.0-pre", - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/i18n": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/i18n": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "@hello-pangea/dnd": "18.0.1", "@leeoniya/ufuzzy": "1.0.19", "@lezer/common": "1.2.3", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index f9ba7ce203d..a46d08d4623 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/runtime", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Runtime Library", "keywords": [ "grafana", @@ -53,11 +53,11 @@ "postpack": "mv package.json.bak package.json && rimraf ./unstable" }, "dependencies": { - "@grafana/data": "12.3.0-pre", - "@grafana/e2e-selectors": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", "@grafana/faro-web-sdk": "^1.13.2", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "@openfeature/core": "^1.9.0", "@openfeature/ofrep-web-provider": "^0.3.3", "@openfeature/web-sdk": "^1.6.1", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index feff0eb8ba3..067475cd209 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/schema", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Schema Library", "keywords": [ "typescript" diff --git a/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts index c9d2e73432e..715397acd61 100644 --- a/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { limit: number; diff --git a/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts index 275d5d7c921..e01120da861 100644 --- a/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithTextFormatting { /** diff --git a/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts index dcf792aadbd..542944b0e3a 100644 --- a/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends common.OptionsWithLegend, common.SingleStatBaseOptions { displayMode: common.BarGaugeDisplayMode; diff --git a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts index d1847e72998..18b9cb91f71 100644 --- a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export enum VizDisplayMode { Candles = 'candles', diff --git a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts index 13939c556d6..fea3266ca6d 100644 --- a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export enum HorizontalConstraint { Center = 'center', diff --git a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts index 61cb1e34254..7f197b13d18 100644 --- a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface MetricStat { /** diff --git a/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts index a1ec65bf148..5b65d042e72 100644 --- a/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { /** diff --git a/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts index 65ef7e9883d..cfc4efd5f22 100644 --- a/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { selectedSeries: number; diff --git a/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts index 443add70b78..d71d823d38f 100644 --- a/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export type UpdateConfig = { render: boolean, diff --git a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts index d63d368d534..ad46e229611 100644 --- a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested); diff --git a/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts index ac1fd6808c2..f68950c8cb3 100644 --- a/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends common.SingleStatBaseOptions { minVizHeight: number; diff --git a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts index 4819a4cfc1b..74bdafa169d 100644 --- a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { basemap: ui.MapLayerOptions; diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts index bb71df74d6d..1ff4370c9a2 100644 --- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; /** * Controls the color mode of the heatmap diff --git a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts index 2d5705e31e5..aa104192e1a 100644 --- a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { /** 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 9ea5e96bad3..f8e672a8979 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 @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { controlsStorageKey?: string; diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index 045b217482f..5f604cebfae 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface GaugePanelEffects { barGlow?: boolean; diff --git a/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts index 5f46545f820..e26eabbdee2 100644 --- a/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { /** diff --git a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts index 17fe57fcc44..0c980fc8495 100644 --- a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface ArcOption { /** diff --git a/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts index 067c6d7d146..8a3f0bbe55c 100644 --- a/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; /** * Select the pie chart display style. diff --git a/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts index 0fb48020d23..f12b46fd8f0 100644 --- a/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends common.SingleStatBaseOptions { colorMode: common.BigValueColorMode; diff --git a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts index 6c3713394ca..1c327e1b8c0 100644 --- a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations { /** diff --git a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts index 38f816c0b77..48825cb2b58 100644 --- a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations { /** diff --git a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts index 3964c70f630..c45151fed2b 100644 --- a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as ui from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options { /** diff --git a/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts index 39053ebb9af..c4695995725 100644 --- a/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts @@ -8,7 +8,7 @@ // // Run 'make gen-cue' from repository root to regenerate. -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export enum TextMode { Code = 'code', diff --git a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts index 3ebffee70c2..d0213051004 100644 --- a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations { legend: common.VizLegendOptions; diff --git a/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts index 7ec6ddd5686..fc0bcd92a4d 100644 --- a/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; /** * Identical to timeseries... except it does not have timezone settings diff --git a/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts index 450721304fe..5e851caa415 100644 --- a/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts @@ -10,7 +10,7 @@ import * as common from '@grafana/schema'; -export const pluginVersion = "12.3.0-pre"; +export const pluginVersion = "12.4.0-pre"; export enum PointShape { Circle = 'circle', diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index db36dfec348..321cfccfe7b 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -3,7 +3,7 @@ "license": "AGPL-3.0-only", "private": true, "name": "@grafana/sql", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git", @@ -16,12 +16,12 @@ }, "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/i18n": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/i18n": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "@react-awesome-query-builder/ui": "6.6.15", "immutable": "5.1.4", "lodash": "4.17.21", diff --git a/packages/grafana-test-utils/package.json b/packages/grafana-test-utils/package.json index ecd8980173a..a8b69f8d665 100644 --- a/packages/grafana-test-utils/package.json +++ b/packages/grafana-test-utils/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/test-utils", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "private": true, "description": "Grafana test utils & Mock API", "keywords": [ diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 6c96682758b..011a26fb86d 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/ui", - "version": "12.3.0-pre", + "version": "12.4.0-pre", "description": "Grafana Components Library", "keywords": [ "grafana", @@ -67,11 +67,11 @@ "@emotion/react": "11.14.0", "@emotion/serialize": "1.3.3", "@floating-ui/react": "0.27.16", - "@grafana/data": "12.3.0-pre", - "@grafana/e2e-selectors": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", "@grafana/faro-web-sdk": "^1.13.2", - "@grafana/i18n": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", + "@grafana/i18n": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", "@hello-pangea/dnd": "18.0.1", "@monaco-editor/react": "4.7.0", "@popperjs/core": "2.11.8", diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 778de26d31d..3304af4f93d 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -2,15 +2,15 @@ "name": "@grafana-plugins/grafana-azure-monitor-datasource", "description": "Grafana data source for Azure Monitor", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/i18n": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/i18n": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "@kusto/monaco-kusto": "^10.0.0", "fast-deep-equal": "^3.1.3", "i18next": "^25.0.0", @@ -26,8 +26,8 @@ "tslib": "2.8.1" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 19ce293b786..15025df2ae5 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -2,15 +2,15 @@ "name": "@grafana-plugins/stackdriver", "description": "Grafana data source for Google Cloud Monitoring", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", "@grafana/google-sdk": "0.3.5", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "debounce-promise": "3.1.2", "fast-deep-equal": "^3.1.3", "i18next": "^25.0.0", @@ -26,8 +26,8 @@ "tslib": "2.8.1" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index b1106f0202c..6923dd71ec3 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -2,22 +2,22 @@ "name": "@grafana-plugins/grafana-postgresql-datasource", "description": "PostgreSQL data source plugin", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/sql": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/sql": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "lodash": "4.17.21", "react": "18.3.1", "rxjs": "7.8.2", "tslib": "2.8.1" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index a3247d4d022..1bb4c358c2f 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -2,13 +2,13 @@ "name": "@grafana-plugins/grafana-pyroscope-datasource", "description": "Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "fast-deep-equal": "^3.1.3", "lodash": "4.17.21", "monaco-editor": "0.34.1", @@ -20,7 +20,7 @@ "tslib": "2.8.1" }, "devDependencies": { - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index d738f47d24d..f4abbf4c9ee 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -2,13 +2,13 @@ "name": "@grafana-plugins/grafana-testdata-datasource", "description": "Generates test data in different forms", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "d3-random": "^3.0.1", "lodash": "4.17.21", "micro-memoize": "^4.1.2", @@ -21,8 +21,8 @@ "uuid": "11.1.0" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/graphite/package.json b/public/app/plugins/datasource/graphite/package.json index 5804b4b1b48..14a34a5fb75 100644 --- a/public/app/plugins/datasource/graphite/package.json +++ b/public/app/plugins/datasource/graphite/package.json @@ -2,14 +2,14 @@ "name": "@grafana-plugins/graphite", "description": "Graphite data source plugin for Grafana", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "@reduxjs/toolkit": "2.9.0", "lodash": "4.17.21", "moment": "2.30.1", @@ -23,8 +23,8 @@ "uuid": "11.1.0" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index 3fe1880cc50..c6e099783f6 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -2,7 +2,7 @@ "name": "@grafana-plugins/jaeger", "description": "Jaeger plugin for Grafana", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", diff --git a/public/app/plugins/datasource/loki/package.json b/public/app/plugins/datasource/loki/package.json index 36f40009094..51c9b63ec61 100644 --- a/public/app/plugins/datasource/loki/package.json +++ b/public/app/plugins/datasource/loki/package.json @@ -2,16 +2,16 @@ "name": "@grafana-plugins/loki", "description": "Loki data source plugin for Grafana", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", "@grafana/lezer-logql": "0.2.9", "@grafana/llm": "0.22.1", "@grafana/monaco-logql": "^0.0.8", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "d3-random": "^3.0.1", "lodash": "4.17.21", "micro-memoize": "^4.1.2", @@ -24,8 +24,8 @@ "uuid": "11.1.0" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index 9f3da85359b..a0a8ed342d8 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -2,23 +2,23 @@ "name": "@grafana-plugins/mssql", "description": "MSSQL data source plugin", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/i18n": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/i18n": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/sql": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/sql": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "lodash": "4.17.21", "react": "18.3.1", "rxjs": "7.8.2", "tslib": "2.8.1" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index b541a1506be..4750acc052c 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -2,22 +2,22 @@ "name": "@grafana-plugins/mysql", "description": "MySQL data source plugin", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", "@grafana/plugin-ui": "^0.10.10", - "@grafana/runtime": "12.3.0-pre", - "@grafana/sql": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/sql": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "lodash": "4.17.21", "react": "18.3.1", "rxjs": "7.8.2", "tslib": "2.8.1" }, "devDependencies": { - "@grafana/e2e-selectors": "12.3.0-pre", - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/e2e-selectors": "12.4.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index b4b6041c348..0def9ff69fd 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -2,13 +2,13 @@ "name": "@grafana-plugins/parca", "description": "Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", - "@grafana/data": "12.3.0-pre", - "@grafana/runtime": "12.3.0-pre", - "@grafana/schema": "12.3.0-pre", - "@grafana/ui": "12.3.0-pre", + "@grafana/data": "12.4.0-pre", + "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", + "@grafana/ui": "12.4.0-pre", "lodash": "4.17.21", "monaco-editor": "0.34.1", "react": "18.3.1", @@ -18,7 +18,7 @@ "tslib": "2.8.1" }, "devDependencies": { - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index 1a089ff5590..f207453fa4c 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -2,7 +2,7 @@ "name": "@grafana-plugins/tempo", "description": "Grafana plugin for the Tempo data source.", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", @@ -38,7 +38,7 @@ "uuid": "11.1.0" }, "devDependencies": { - "@grafana/plugin-configs": "12.3.0-pre", + "@grafana/plugin-configs": "12.4.0-pre", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index f7a8a8ad869..157d58bac27 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -2,7 +2,7 @@ "name": "@grafana-plugins/zipkin", "description": "Zipkin plugin for Grafana", "private": true, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", diff --git a/yarn.lock b/yarn.lock index c357b263ead..423b8de21cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,14 +2433,14 @@ __metadata: resolution: "@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/i18n": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@kusto/monaco-kusto": "npm:^10.0.0" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.6.4" @@ -2480,13 +2480,13 @@ __metadata: resolution: "@grafana-plugins/grafana-postgresql-datasource@workspace:public/app/plugins/datasource/grafana-postgresql-datasource" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/sql": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/sql": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/react": "npm:16.3.0" "@testing-library/user-event": "npm:14.6.1" @@ -2512,11 +2512,11 @@ __metadata: resolution: "@grafana-plugins/grafana-pyroscope-datasource@workspace:public/app/plugins/datasource/grafana-pyroscope-datasource" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.6.4" "@testing-library/react": "npm:16.3.0" @@ -2553,12 +2553,12 @@ __metadata: resolution: "@grafana-plugins/grafana-testdata-datasource@workspace:public/app/plugins/datasource/grafana-testdata-datasource" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.6.4" "@testing-library/react": "npm:16.3.0" @@ -2594,13 +2594,13 @@ __metadata: resolution: "@grafana-plugins/graphite@workspace:public/app/plugins/datasource/graphite" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@reduxjs/toolkit": "npm:2.9.0" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.6.4" @@ -2680,15 +2680,15 @@ __metadata: resolution: "@grafana-plugins/loki@workspace:public/app/plugins/datasource/loki" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" "@grafana/lezer-logql": "npm:0.2.9" "@grafana/llm": "npm:0.22.1" "@grafana/monaco-logql": "npm:^0.0.8" - "@grafana/plugin-configs": "npm:12.3.0-pre" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.6.4" "@testing-library/react": "npm:16.3.0" @@ -2724,14 +2724,14 @@ __metadata: resolution: "@grafana-plugins/mssql@workspace:public/app/plugins/datasource/mssql" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/i18n": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/sql": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/sql": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/react": "npm:16.3.0" "@testing-library/user-event": "npm:14.6.1" @@ -2757,13 +2757,13 @@ __metadata: resolution: "@grafana-plugins/mysql@workspace:public/app/plugins/datasource/mysql" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/sql": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/sql": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/react": "npm:16.3.0" "@testing-library/user-event": "npm:14.6.1" @@ -2789,11 +2789,11 @@ __metadata: resolution: "@grafana-plugins/parca@workspace:public/app/plugins/datasource/parca" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/plugin-configs": "npm:12.3.0-pre" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/react": "npm:16.3.0" "@testing-library/user-event": "npm:14.6.1" @@ -2822,14 +2822,14 @@ __metadata: resolution: "@grafana-plugins/stackdriver@workspace:public/app/plugins/datasource/cloud-monitoring" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" "@grafana/google-sdk": "npm:0.3.5" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.6.4" "@testing-library/react": "npm:16.3.0" @@ -2874,7 +2874,7 @@ __metadata: "@grafana/lezer-traceql": "npm:0.0.25" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" - "@grafana/plugin-configs": "npm:12.3.0-pre" + "@grafana/plugin-configs": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" "@grafana/runtime": "workspace:*" "@grafana/schema": "workspace:*" @@ -2966,7 +2966,7 @@ __metadata: dependencies: "@emotion/css": "npm:11.13.5" "@faker-js/faker": "npm:^9.8.0" - "@grafana/i18n": "npm:12.3.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" "@grafana/test-utils": "workspace:*" "@reduxjs/toolkit": "npm:^2.9.0" "@rtk-query/codegen-openapi": "npm:^2.0.0" @@ -3060,14 +3060,14 @@ __metadata: languageName: node linkType: hard -"@grafana/data@npm:12.3.0-pre, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data": +"@grafana/data@npm:12.4.0-pre, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data": version: 0.0.0-use.local resolution: "@grafana/data@workspace:packages/grafana-data" dependencies: "@braintree/sanitize-url": "npm:7.0.1" - "@grafana/i18n": "npm:12.3.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" "@grafana/scenes": "npm:6.38.0" - "@grafana/schema": "npm:12.3.0-pre" + "@grafana/schema": "npm:12.4.0-pre" "@leeoniya/ufuzzy": "npm:1.0.19" "@rollup/plugin-node-resolve": "npm:16.0.1" "@testing-library/react": "npm:16.3.0" @@ -3115,7 +3115,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/e2e-selectors@npm:12.3.0-pre, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors": +"@grafana/e2e-selectors@npm:12.4.0-pre, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors": version: 0.0.0-use.local resolution: "@grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors" dependencies: @@ -3215,8 +3215,8 @@ __metadata: "@babel/preset-env": "npm:7.28.0" "@babel/preset-react": "npm:7.27.1" "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@leeoniya/ufuzzy": "npm:1.0.19" "@rollup/plugin-node-resolve": "npm:16.0.1" "@testing-library/dom": "npm:10.4.1" @@ -3266,7 +3266,7 @@ __metadata: languageName: node linkType: hard -"@grafana/i18n@npm:12.3.0-pre, @grafana/i18n@workspace:*, @grafana/i18n@workspace:packages/grafana-i18n": +"@grafana/i18n@npm:12.4.0-pre, @grafana/i18n@workspace:*, @grafana/i18n@workspace:packages/grafana-i18n": version: 0.0.0-use.local resolution: "@grafana/i18n@workspace:packages/grafana-i18n" dependencies: @@ -3336,12 +3336,12 @@ __metadata: resolution: "@grafana/o11y-ds-frontend@workspace:packages/grafana-o11y-ds-frontend" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:^6.1.2" "@testing-library/react": "npm:16.3.0" @@ -3365,7 +3365,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/plugin-configs@npm:12.3.0-pre, @grafana/plugin-configs@workspace:*, @grafana/plugin-configs@workspace:packages/grafana-plugin-configs": +"@grafana/plugin-configs@npm:12.4.0-pre, @grafana/plugin-configs@workspace:*, @grafana/plugin-configs@workspace:packages/grafana-plugin-configs": version: 0.0.0-use.local resolution: "@grafana/plugin-configs@workspace:packages/grafana-plugin-configs" dependencies: @@ -3445,13 +3445,13 @@ __metadata: dependencies: "@emotion/css": "npm:11.13.5" "@floating-ui/react": "npm:0.27.16" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/i18n": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@hello-pangea/dnd": "npm:18.0.1" "@leeoniya/ufuzzy": "npm:1.0.19" "@lezer/common": "npm:1.2.3" @@ -3511,15 +3511,15 @@ __metadata: languageName: unknown linkType: soft -"@grafana/runtime@npm:12.3.0-pre, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime": +"@grafana/runtime@npm:12.4.0-pre, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime": version: 0.0.0-use.local resolution: "@grafana/runtime@workspace:packages/grafana-runtime" dependencies: - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" "@grafana/faro-web-sdk": "npm:^1.13.2" - "@grafana/schema": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/schema": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@openfeature/core": "npm:^1.9.0" "@openfeature/ofrep-web-provider": "npm:^0.3.3" "@openfeature/web-sdk": "npm:^1.6.1" @@ -3627,7 +3627,7 @@ __metadata: languageName: node linkType: hard -"@grafana/schema@npm:12.3.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema": +"@grafana/schema@npm:12.4.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema": version: 0.0.0-use.local resolution: "@grafana/schema@workspace:packages/grafana-schema" dependencies: @@ -3644,17 +3644,17 @@ __metadata: languageName: unknown linkType: soft -"@grafana/sql@npm:12.3.0-pre, @grafana/sql@workspace:*, @grafana/sql@workspace:packages/grafana-sql": +"@grafana/sql@npm:12.4.0-pre, @grafana/sql@workspace:*, @grafana/sql@workspace:packages/grafana-sql": version: 0.0.0-use.local resolution: "@grafana/sql@workspace:packages/grafana-sql" dependencies: "@emotion/css": "npm:11.13.5" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" - "@grafana/i18n": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.10.10" - "@grafana/runtime": "npm:12.3.0-pre" - "@grafana/ui": "npm:12.3.0-pre" + "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/ui": "npm:12.4.0-pre" "@react-awesome-query-builder/ui": "npm:6.6.15" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:^6.1.2" @@ -3716,7 +3716,7 @@ __metadata: languageName: node linkType: hard -"@grafana/ui@npm:12.3.0-pre, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui": +"@grafana/ui@npm:12.4.0-pre, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui": version: 0.0.0-use.local resolution: "@grafana/ui@workspace:packages/grafana-ui" dependencies: @@ -3726,11 +3726,11 @@ __metadata: "@emotion/serialize": "npm:1.3.3" "@faker-js/faker": "npm:^9.0.0" "@floating-ui/react": "npm:0.27.16" - "@grafana/data": "npm:12.3.0-pre" - "@grafana/e2e-selectors": "npm:12.3.0-pre" + "@grafana/data": "npm:12.4.0-pre" + "@grafana/e2e-selectors": "npm:12.4.0-pre" "@grafana/faro-web-sdk": "npm:^1.13.2" - "@grafana/i18n": "npm:12.3.0-pre" - "@grafana/schema": "npm:12.3.0-pre" + "@grafana/i18n": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" "@hello-pangea/dnd": "npm:18.0.1" "@monaco-editor/react": "npm:4.7.0" "@popperjs/core": "npm:2.11.8" From 2411e78cd1aab2950c9de53bde5bf8b56869b822 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 00:39:34 +0000 Subject: [PATCH 040/209] I18n: Download translations from Crowdin (#113428) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 5 +++-- public/locales/de-DE/grafana.json | 5 +++-- public/locales/es-ES/grafana.json | 5 +++-- public/locales/fr-FR/grafana.json | 5 +++-- public/locales/hu-HU/grafana.json | 5 +++-- public/locales/id-ID/grafana.json | 5 +++-- public/locales/it-IT/grafana.json | 5 +++-- public/locales/ja-JP/grafana.json | 5 +++-- public/locales/ko-KR/grafana.json | 5 +++-- public/locales/nl-NL/grafana.json | 5 +++-- public/locales/pl-PL/grafana.json | 5 +++-- public/locales/pt-BR/grafana.json | 5 +++-- public/locales/pt-PT/grafana.json | 5 +++-- public/locales/ru-RU/grafana.json | 5 +++-- public/locales/sv-SE/grafana.json | 5 +++-- public/locales/tr-TR/grafana.json | 5 +++-- public/locales/zh-Hans/grafana.json | 5 +++-- public/locales/zh-Hant/grafana.json | 5 +++-- 18 files changed, 54 insertions(+), 36 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 83d5e778a28..02e0639b853 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -2969,15 +2969,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4170,6 +4170,7 @@ "scopes": "Rozsahy" }, "empty-state": { + "button-title": "", "message": "Nebyly nalezeny žádné výsledky" }, "scopes": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index fed4b2c3811..e4e84df25ff 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Bereiche" }, "empty-state": { + "button-title": "", "message": "Keine Ergebnisse gefunden" }, "scopes": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 651e2e3a525..548d36b6f09 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Alcances" }, "empty-state": { + "button-title": "", "message": "No se han encontrado resultados" }, "scopes": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index d7e4784214c..868edd138d7 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Portées" }, "empty-state": { + "button-title": "", "message": "Aucun résultat trouvé" }, "scopes": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index a38e1771412..f8b3e6728d7 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Hatókörök" }, "empty-state": { + "button-title": "", "message": "Nincs eredmény" }, "scopes": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index ffd82d302ad..7b2e629b3ed 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -2933,15 +2933,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4110,6 +4110,7 @@ "scopes": "Lingkup" }, "empty-state": { + "button-title": "", "message": "Hasil tidak ditemukan" }, "scopes": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 77c9899421e..97dd480c90e 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Ambiti di applicazione" }, "empty-state": { + "button-title": "", "message": "Nessun risultato trovato" }, "scopes": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 140532dc989..0647b0b4393 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -2933,15 +2933,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4110,6 +4110,7 @@ "scopes": "スコープ" }, "empty-state": { + "button-title": "", "message": "結果はありません" }, "scopes": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index b2183038449..afbdc50e1d2 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -2933,15 +2933,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4110,6 +4110,7 @@ "scopes": "범위" }, "empty-state": { + "button-title": "", "message": "찾은 결과 없음" }, "scopes": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index a70fba538bb..ae2b06a0f64 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Bereik" }, "empty-state": { + "button-title": "", "message": "Geen resultaten gevonden" }, "scopes": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index a50736a276b..b9c397005fe 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -2969,15 +2969,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4170,6 +4170,7 @@ "scopes": "Zakresy" }, "empty-state": { + "button-title": "", "message": "Brak wyników" }, "scopes": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index a1c999c2fad..40bb380702d 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Escopos" }, "empty-state": { + "button-title": "", "message": "Nenhum resultado encontrado" }, "scopes": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 81da1ed9cbd..12f6ce60a54 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Âmbitos" }, "empty-state": { + "button-title": "", "message": "Não foram encontrados resultados" }, "scopes": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 3197ba51990..b3d46874259 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -2969,15 +2969,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4170,6 +4170,7 @@ "scopes": "Области" }, "empty-state": { + "button-title": "", "message": "Результаты не найдены" }, "scopes": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 2e6efaaa522..b7497fcef5c 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Omfattningar" }, "empty-state": { + "button-title": "", "message": "Inga resultat hittades" }, "scopes": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index c586b768e87..b328286b358 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -2945,15 +2945,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4130,6 +4130,7 @@ "scopes": "Kapsamlar" }, "empty-state": { + "button-title": "", "message": "Sonuç bulunamadı" }, "scopes": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 1ef404ca688..3310d6b7fa2 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -2933,15 +2933,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4110,6 +4110,7 @@ "scopes": "范围" }, "empty-state": { + "button-title": "", "message": "未找到结果" }, "scopes": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 73aa747d454..15059e2e5dd 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -2933,15 +2933,15 @@ "triage": { "alert-instances": "", "error-loading-rule": "", - "firing-instances-count": "", "instance-details-drawer": { "instance-details": "" }, + "no-firing-or-pending-instances": "", "no-instances-found": "", "no-labels": "", + "no-matching-instances-with-filters": "", "open-in-sidebar": "", "open-rule-details": "", - "pending-instances-count": "", "rule-details": { "subtitle": "", "title": "" @@ -4110,6 +4110,7 @@ "scopes": "範圍" }, "empty-state": { + "button-title": "", "message": "未找到結果" }, "scopes": { From 35ac04bad3ee3b3afb0c855763efb4a7e9dc4f38 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Thu, 6 Nov 2025 09:18:11 +0100 Subject: [PATCH 041/209] Chore: Fix two minor bugs related to favorite datasources (#113444) --- .../datasources/components/picker/DataSourceCard.tsx | 1 + .../datasources/components/picker/DataSourceList.tsx | 6 +++--- .../datasources/components/picker/DataSourceModal.tsx | 1 + .../datasources/components/picker/DataSourcePicker.tsx | 7 +++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/public/app/features/datasources/components/picker/DataSourceCard.tsx b/public/app/features/datasources/components/picker/DataSourceCard.tsx index dbdbdc2512a..4f30c07d894 100644 --- a/public/app/features/datasources/components/picker/DataSourceCard.tsx +++ b/public/app/features/datasources/components/picker/DataSourceCard.tsx @@ -41,6 +41,7 @@ export function DataSourceCard({ {description || ds.meta.name} {onToggleFavorite && !ds.meta.builtIn && ( { e.stopPropagation(); diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index 77e332dbc5f..691f6ae0d34 100644 --- a/public/app/features/datasources/components/picker/DataSourceList.tsx +++ b/public/app/features/datasources/components/picker/DataSourceList.tsx @@ -6,7 +6,7 @@ import { Observable } from 'rxjs'; import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; -import { getTemplateSrv, reportInteraction, useFavoriteDatasources } from '@grafana/runtime'; +import { FavoriteDatasources, getTemplateSrv, reportInteraction } from '@grafana/runtime'; import { useStyles2, useTheme2 } from '@grafana/ui'; import { useDatasources, useKeyboardNavigatableList, useRecentlyUsedDataSources } from '../../hooks'; @@ -45,6 +45,7 @@ export interface DataSourceListProps { onClickEmptyStateCTA?: () => void; enableKeyboardNavigation?: boolean; dataSources?: Array>; + favoriteDataSources: FavoriteDatasources; } export function DataSourceList(props: DataSourceListProps) { @@ -58,7 +59,7 @@ export function DataSourceList(props: DataSourceListProps) { const theme = useTheme2(); const styles = getStyles(theme, selectedItemCssSelector); - const { className, current, onChange, enableKeyboardNavigation, onClickEmptyStateCTA } = props; + const { className, current, onChange, enableKeyboardNavigation, onClickEmptyStateCTA, favoriteDataSources } = props; const dataSources = useDatasources( { alerting: props.alerting, @@ -76,7 +77,6 @@ export function DataSourceList(props: DataSourceListProps) { ); const [recentlyUsedDataSources, pushRecentlyUsedDataSource] = useRecentlyUsedDataSources(); - const favoriteDataSources = useFavoriteDatasources(); const filteredDataSources = props.filter ? dataSources.filter(props.filter) : dataSources; diff --git a/public/app/features/datasources/components/picker/DataSourceModal.tsx b/public/app/features/datasources/components/picker/DataSourceModal.tsx index ea6f7e63d80..8bbf5f367a5 100644 --- a/public/app/features/datasources/components/picker/DataSourceModal.tsx +++ b/public/app/features/datasources/components/picker/DataSourceModal.tsx @@ -219,6 +219,7 @@ export function DataSourceModal({ dashboard={dashboard} mixed={mixed} dataSources={dataSources} + favoriteDataSources={favoriteDataSources} /> diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.tsx index 7d2be3aa341..fef92a8e0c0 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.tsx @@ -11,7 +11,7 @@ import { Observable } from 'rxjs'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { reportInteraction, useFavoriteDatasources } from '@grafana/runtime'; +import { FavoriteDatasources, reportInteraction, useFavoriteDatasources } from '@grafana/runtime'; import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; import { Button, floatingUtils, Icon, Input, ModalsController, Portal, ScrollContainer, useStyles2 } from '@grafana/ui'; import config from 'app/core/config'; @@ -306,6 +306,7 @@ export function DataSourcePicker(props: DataSourcePickerProps) { onDismiss={onClose} onNavigateOutsiteFooter={onNavigateOutsiteFooter} dataSources={dataSources} + favoriteDataSources={favoriteDataSources} />
@@ -343,10 +344,11 @@ export interface PickerContentProps extends DataSourcePickerProps { footerRef: (element: HTMLElement | null) => void; onNavigateOutsiteFooter: (e: React.KeyboardEvent) => void; dataSources: Array>; + favoriteDataSources: FavoriteDatasources; } const PickerContent = React.forwardRef((props, ref) => { - const { filterTerm, onChange, onClose, onClickAddCSV, current, filter, dataSources } = props; + const { filterTerm, onChange, onClose, onClickAddCSV, current, filter, dataSources, favoriteDataSources } = props; const changeCallback = useCallback( (ds: DataSourceInstanceSettings) => { @@ -368,6 +370,7 @@ const PickerContent = React.forwardRef((prop Date: Thu, 6 Nov 2025 09:21:11 +0100 Subject: [PATCH 042/209] fix: background delete on create failure after ctx cancellation (#113442) * fix: background delete on create failure after ctx cancellation * fix: address comments * chore: remove tests using mock --- pkg/storage/legacysql/dualwrite/dualwriter.go | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index cefaf923efc..adb36ae5e73 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -241,10 +241,12 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida if errObjectSt != nil { log.With("object", createdCopy).Error("failed to CREATE object in unified storage", "err", errObjectSt) // If we cannot create in unified storage, attempt to clean up legacy. - _, _, err = d.legacy.Delete(ctx, accCreated.GetName(), nil, &metav1.DeleteOptions{}) - if err != nil { - log.With("name", accCreated.GetName()).Error("failed to CLEANUP object in legacy storage", "err", err) - } + go func(ctxBg context.Context, cancel context.CancelFunc) { + defer cancel() + if _, asyncDelete, err := d.legacy.Delete(ctxBg, accCreated.GetName(), nil, &metav1.DeleteOptions{}); err != nil { + log.With("name", accCreated.GetName()).Error("failed to CLEANUP object in legacy storage", "err", err, "asyncDelete", asyncDelete) + } + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return nil, errObjectSt } return storageObj, nil @@ -263,12 +265,13 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida if d.errorIsOK { return createdFromLegacy, nil } - // If we cannot create in unified storage, attempt to clean up legacy. - _, _, errLegacy := d.legacy.Delete(ctx, accCreated.GetName(), nil, &metav1.DeleteOptions{}) - if errLegacy != nil { - log.With("name", accCreated.GetName()).Error("failed to CLEANUP object in legacy storage", "err", errLegacy) - } + go func(ctxBg context.Context, cancel context.CancelFunc) { + defer cancel() + if _, asyncDelete, err := d.legacy.Delete(ctxBg, accCreated.GetName(), nil, &metav1.DeleteOptions{}); err != nil { + log.With("name", accCreated.GetName()).Error("failed to CLEANUP object in legacy storage", "err", err, "asyncDelete", asyncDelete) + } + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return nil, err } } From b739e4e8027fd4925f73d4777e582cc0e89e7985 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 6 Nov 2025 08:31:24 +0000 Subject: [PATCH 043/209] APIs: Include enterprise spec check (#113470) --- .github/workflows/backend-code-checks.yml | 2 +- public/api-enterprise-spec.json | 26 +++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/backend-code-checks.yml b/.github/workflows/backend-code-checks.yml index 0294a121551..0367ee34641 100644 --- a/.github/workflows/backend-code-checks.yml +++ b/.github/workflows/backend-code-checks.yml @@ -79,7 +79,7 @@ jobs: make swagger-clean && make openapi3-gen # Check if the generated specs differ from what's in the repository - for f in public/api-merged.json public/openapi3.json; do git add $f; done + for f in public/api-merged.json public/openapi3.json public/api-enterprise-spec.json; do git add $f; done if [ -z "$(git diff --name-only --cached)" ]; then echo "OpenAPI specs are up to date!" else diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 46c47a02e82..34968691f50 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -2975,6 +2975,10 @@ "description": "Name of annotation.", "type": "string" }, + "placement": { + "description": "Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.", + "type": "string" + }, "target": { "$ref": "#/definitions/AnnotationTarget" }, @@ -4323,6 +4327,20 @@ } } }, + "DashboardVersionResponseMeta": { + "type": "object", + "properties": { + "continueToken": { + "type": "string" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/definitions/DashboardVersionMeta" + } + } + } + }, "DataLink": { "description": "DataLink define what", "type": "object", @@ -7979,6 +7997,9 @@ "teamUID": { "type": "string" }, + "uid": { + "type": "string" + }, "userId": { "type": "integer", "format": "int64" @@ -9420,10 +9441,7 @@ "dashboardVersionsResponse": { "description": "", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/DashboardVersionMeta" - } + "$ref": "#/definitions/DashboardVersionResponseMeta" } }, "deleteCorrelationResponse": { From ff53276870a57dc31bc57d8e80bdd416c9ed522c Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 6 Nov 2025 11:00:37 +0100 Subject: [PATCH 044/209] `grafana-iam`: Instantiate ExternalGroupMappingStorage as a NoopStorage (#113499) Co-authored-by: jguer --- pkg/registry/apis/iam/register.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 752fd949b35..b679c64f918 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" + "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" "github.com/grafana/grafana/pkg/registry/apis/iam/resourcepermission" "github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount" "github.com/grafana/grafana/pkg/registry/apis/iam/sso" @@ -111,16 +112,18 @@ func NewAPIService( store := legacy.NewLegacySQLStores(dbProvider) resourcePermissionsStorage := resourcepermission.ProvideStorageBackend(dbProvider) resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient) + noopStorage := noopstorage.ProvideStorageBackend() registerMetrics(reg) return &IdentityAccessManagementAPIBuilder{ - store: store, - display: user.NewLegacyDisplayREST(store), - resourcePermissionsStorage: resourcePermissionsStorage, - logger: log.New("iam.apis"), - features: features, - zClient: zClient, - zTickets: make(chan bool, MaxConcurrentZanzanaWrites), - reg: reg, + store: store, + display: user.NewLegacyDisplayREST(store), + resourcePermissionsStorage: resourcePermissionsStorage, + externalGroupMappingStorage: noopStorage, + logger: log.New("iam.apis"), + features: features, + zClient: zClient, + zTickets: make(chan bool, MaxConcurrentZanzanaWrites), + reg: reg, authorizer: authorizer.AuthorizerFunc( func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { // For now only authorize resourcepermissions resource From 51c31e00a45e03742a342a71b52f40dd558b83e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:03:28 +0100 Subject: [PATCH 045/209] fix: dual writer log object formatting (#113492) fix: logging --- pkg/storage/legacysql/dualwrite/dualwriter.go | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index adb36ae5e73..966340db779 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -22,6 +22,33 @@ var ( _ grafanarest.Storage = (*dualWriter)(nil) ) +func objectInfo(obj runtime.Object) map[string]interface{} { + if obj == nil { + return map[string]interface{}{"object": "nil"} + } + + acc, err := meta.Accessor(obj) + if err != nil { + return map[string]interface{}{"object": fmt.Sprintf("%T", obj), "error": err.Error()} + } + + info := map[string]interface{}{ + "name": acc.GetName(), + } + + if ns := acc.GetNamespace(); ns != "" { + info["namespace"] = ns + } + if uid := acc.GetUID(); uid != "" { + info["uid"] = string(uid) + } + if rv := acc.GetResourceVersion(); rv != "" { + info["resourceVersion"] = rv + } + + return info +} + // Let's give the background queries a bit more time to complete // as we also run them as part of load tests that might need longer // to complete. Those run in the background and won't impact the @@ -45,7 +72,7 @@ func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOp // If legacy is still our main store, lets first read from it. legacyGet, err := d.legacy.Get(ctx, name, options) if err != nil { - log.Error("failed to get object from legacy storage", "err", err) + log.Error("failed to GET object from legacy storage", "err", err) return nil, err } // Once we have successfully read from legacy, we can check if we want to fail on a unified read. @@ -62,7 +89,7 @@ func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOp // If it's not okay to fail, we have to check it in the foreground. _, unifiedErr := d.unified.Get(ctx, name, options) if unifiedErr != nil && !apierrors.IsNotFound(unifiedErr) { - log.Error("failed to get object from unified storage", "err", unifiedErr) + log.Error("failed to GET object from unified storage", "err", unifiedErr) return nil, unifiedErr } return legacyGet, nil @@ -212,7 +239,7 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida // will try to cleanup the object in legacy. createdFromLegacy, err := d.legacy.Create(ctx, in, createValidation, options) if err != nil { - log.With("object", in).Error("failed to CREATE object in legacy storage", "err", err) + log.With("objectInfo", objectInfo(in)).Error("failed to CREATE object in legacy storage", "err", err) return nil, err } @@ -239,7 +266,7 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida if d.readUnified { storageObj, errObjectSt := d.unified.Create(ctx, createdCopy, createValidation, options) if errObjectSt != nil { - log.With("object", createdCopy).Error("failed to CREATE object in unified storage", "err", errObjectSt) + log.With("objectInfo", objectInfo(createdCopy)).Error("failed to CREATE object in unified storage", "err", errObjectSt) // If we cannot create in unified storage, attempt to clean up legacy. go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() @@ -255,13 +282,13 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.Create(ctxBg, createdCopy, createValidation, options); err != nil { - log.With("object", createdCopy).Error("failed to CREATE object in unified storage", "err", err) + log.With("objectInfo", objectInfo(createdCopy)).Error("failed to CREATE object in unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) } else { // Otherwise let's create it in the foreground and return any error. if _, err := d.unified.Create(ctx, createdCopy, createValidation, options); err != nil { - log.With("object", createdCopy).Error("failed to CREATE object in unified storage", "err", err) + log.With("objectInfo", objectInfo(createdCopy)).Error("failed to CREATE object in unified storage", "err", err) if d.errorIsOK { return createdFromLegacy, nil } @@ -330,7 +357,7 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r // Update overrides the behavior of the generic DualWriter and writes first to Storage and then to LegacyStorage. func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { - log := logging.FromContext(ctx).With("method", "Update", "name", name, "objInfo", objInfo) + log := logging.FromContext(ctx).With("method", "Update", "name", name) // update in legacy first, and then unistore. Will return a failure if either fails. // @@ -378,14 +405,14 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, _, err := d.unified.Update(ctxBg, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options); err != nil { - log.Error("failed background UPDATE to unified storage", "err", err) + log.With("objectInfo", objectInfo(objFromLegacy)).Error("failed background UPDATE to unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return objFromLegacy, createdLegacy, nil } // If we want to check unified errors just run it in foreground. if _, _, err := d.unified.Update(ctx, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options); err != nil { - log.Error("failed to UPDATE in unified storage", "err", err) + log.With("objectInfo", objectInfo(objFromLegacy)).Error("failed to UPDATE in unified storage", "err", err) return nil, false, err } return objFromLegacy, createdLegacy, nil @@ -415,14 +442,14 @@ func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.DeleteCollection(ctxBg, deleteValidation, options, listOptions); err != nil { - log.With("object", deletedLegacy).Error("failed background DELETE collection to unified storage", "err", err) + log.With("objectInfo", objectInfo(deletedLegacy)).Error("failed background DELETE collection to unified storage", "err", err) } }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return deletedLegacy, nil } // Otherwise we have to check the error and run it in the foreground. if _, err := d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions); err != nil { - log.With("object", deletedLegacy).Error("failed to DELETE collection successfully from Storage", "err", err) + log.With("objectInfo", objectInfo(deletedLegacy)).Error("failed to DELETE collection successfully from Storage", "err", err) return nil, err } return deletedLegacy, nil From 1b278cc87e0a782304b330935c7c3838651bde8d Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 6 Nov 2025 11:06:53 +0100 Subject: [PATCH 046/209] Plugins: cleanup feature toggle pluginsFrontendSandbox (#113439) * clean feature pluginsFrontendSandbox Co-authored-by: Andres Martinez Gotor --- conf/defaults.ini | 1 - conf/sample.ini | 1 - .../plugin-frontend-sandbox.md | 6 +- .../frontend-sandbox-panel.spec.ts | 157 +++------ .../frontend-sandbox-app.spec.ts | 60 +--- .../frontend-sandbox-datasource.spec.ts | 305 ++++++------------ .../src/types/featureToggles.gen.ts | 4 - pkg/services/featuremgmt/registry.go | 6 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 3 +- .../sandboxPluginLoaderRegistry.test.ts | 8 - .../sandbox/sandboxPluginLoaderRegistry.ts | 5 - 13 files changed, 152 insertions(+), 409 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 61d0ca7e318..5373b53882c 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -443,7 +443,6 @@ content_security_policy_report_only_template = """script-src 'self' 'unsafe-eval csrf_always_check = false # Comma-separated list of plugins ids that will be loaded inside the frontend sandbox -# Currently behind the feature flag pluginsFrontendSandbox enable_frontend_sandbox_for_plugins = # Comma-separated list of paths for POST/PUT URL in actions. Empty will allow anything that is not on the same origin diff --git a/conf/sample.ini b/conf/sample.ini index bee580aaa98..78a77294a99 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -442,7 +442,6 @@ ;csrf_always_check = false # Comma-separated list of plugins ids that will be loaded inside the frontend sandbox -# Currently behind the feature flag pluginsFrontendSandbox ;enable_frontend_sandbox_for_plugins = # Comma-separated list of paths for POST/PUT URL in actions. Empty will allow anything that is not on the same origin diff --git a/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md b/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md index 19c5c6f72b3..1b2721d49a8 100644 --- a/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md +++ b/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md @@ -53,11 +53,7 @@ The following applies: ## Enable the Frontend Sandbox -The Frontend Sandbox feature is currently behind the `pluginsFrontendSandbox` feature flag. To enable it, you need to: - -1. Enable the feature flag in your Grafana configuration. For more information about enabling feature flags, refer to [Configure feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/). - -2. For self-hosted Grafana installations, add the plugin IDs you want to sandbox in the `security` section using the `enable_frontend_sandbox_for_plugins` configuration option. +For self-hosted Grafana installations, add the plugin IDs you want to sandbox in the `security` section using the `enable_frontend_sandbox_for_plugins` configuration option. For Grafana Cloud users, you can simply use the toggle switch in the plugin catalog page to enable or disable the sandbox for each plugin. By default, the sandbox is disabled for all plugins. diff --git a/e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts b/e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts index 4cdb7afb081..b9100201f02 100644 --- a/e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts +++ b/e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts @@ -22,133 +22,64 @@ test.describe( tag: ['@panels'], }, () => { - test.describe('Sandbox disabled', () => { - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - await page.reload(); + test('Does not add iframes to body', async ({ page, gotoDashboardPage }) => { + await gotoDashboardPage({ + uid: DASHBOARD_ID, }); - test('Add iframes to body', async ({ page, gotoDashboardPage }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - }); + // this button adds 3 iframes to the body + await page.locator('[data-testid="button-create-iframes"]').click(); - // this button adds iframes to the body - await page.locator('[data-testid="button-create-iframes"]').click(); + const iframeIds = [ + 'createElementIframe', + 'innerHTMLIframe', + 'appendIframe', + 'prependIframe', + 'afterIframe', + 'beforeIframe', + 'outerHTMLIframe', + 'parseFromStringIframe', + 'insertBeforeIframe', + 'replaceChildIframe', + ]; - const iframeIds = [ - 'createElementIframe', - 'innerHTMLIframe', - 'appendIframe', - 'prependIframe', - 'afterIframe', - 'beforeIframe', - 'outerHTMLIframe', - 'parseFromStringIframe', - 'insertBeforeIframe', - 'replaceChildIframe', - ]; - - for (const id of iframeIds) { - await expect(page.locator(`#${id}`)).toBeVisible(); - } - }); - - test('Reaches out of panel div', async ({ page, gotoDashboardPage }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - }); - - // this button reaches out of the panel div and modifies the element dataset - await page.locator('[data-testid="button-reach-out"]').click(); - await expect(page.locator('[data-sandbox-test="true"]')).toBeVisible(); - }); - - test('Reaches out of the panel editor', async ({ gotoDashboardPage, page }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); - - const input = page.locator('[data-testid="panel-editor-custom-editor-input"]'); - await expect(input).toBeEnabled(); - await expect(input).toHaveValue(''); - - await input.fill('x'); - await expect(input).toHaveValue('x'); - await expect(page.locator('[data-sandbox-test="panel-editor"]')).toBeVisible(); - }); + for (const id of iframeIds) { + await expect(page.locator(`#${id}`)).toBeHidden(); + } }); - test.describe('Sandbox enabled', () => { - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - await page.reload(); + test('Does not reaches out of panel div', async ({ page, gotoDashboardPage }) => { + await gotoDashboardPage({ + uid: DASHBOARD_ID, }); - test('Does not add iframes to body', async ({ page, gotoDashboardPage }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - }); + // this button reaches out of the panel div and modifies the element dataset + await page.locator('[data-testid="button-reach-out"]').click(); + await expect(page.locator('[data-sandbox-test="true"]')).toBeHidden(); + }); - // this button adds 3 iframes to the body - await page.locator('[data-testid="button-create-iframes"]').click(); - - const iframeIds = [ - 'createElementIframe', - 'innerHTMLIframe', - 'appendIframe', - 'prependIframe', - 'afterIframe', - 'beforeIframe', - 'outerHTMLIframe', - 'parseFromStringIframe', - 'insertBeforeIframe', - 'replaceChildIframe', - ]; - - for (const id of iframeIds) { - await expect(page.locator(`#${id}`)).toBeHidden(); - } + test('Does not Reaches out of the panel editor', async ({ gotoDashboardPage, page }) => { + await gotoDashboardPage({ + uid: DASHBOARD_ID, + queryParams: new URLSearchParams({ editPanel: '1' }), }); - test('Does not reaches out of panel div', async ({ page, gotoDashboardPage }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - }); + const input = page.locator('[data-testid="panel-editor-custom-editor-input"]'); + await expect(input).toBeEnabled(); - // this button reaches out of the panel div and modifies the element dataset - await page.locator('[data-testid="button-reach-out"]').click(); - await expect(page.locator('[data-sandbox-test="true"]')).toBeHidden(); + await input.fill('x'); + await expect(page.locator('[data-sandbox-test="panel-editor"]')).toBeHidden(); + }); + + test('Can access specific window global variables', async ({ page, gotoDashboardPage }) => { + await gotoDashboardPage({ + uid: DASHBOARD_ID, }); - test('Does not Reaches out of the panel editor', async ({ gotoDashboardPage, page }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); - - const input = page.locator('[data-testid="panel-editor-custom-editor-input"]'); - await expect(input).toBeEnabled(); - - await input.fill('x'); - await expect(page.locator('[data-sandbox-test="panel-editor"]')).toBeHidden(); - }); - - test('Can access specific window global variables', async ({ page, gotoDashboardPage }) => { - await gotoDashboardPage({ - uid: DASHBOARD_ID, - }); - - await page.locator('[data-testid="button-test-globals"]').click(); - await expect(page.locator('[data-sandbox-global="Prism"]')).toBeVisible(); - await expect(page.locator('[data-sandbox-global="jQuery"]')).toBeVisible(); - await expect(page.locator('[data-sandbox-global="location"]')).toBeVisible(); - }); + await page.locator('[data-testid="button-test-globals"]').click(); + await expect(page.locator('[data-sandbox-global="Prism"]')).toBeVisible(); + await expect(page.locator('[data-sandbox-global="jQuery"]')).toBeVisible(); + await expect(page.locator('[data-sandbox-global="location"]')).toBeVisible(); }); } ); diff --git a/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts b/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts index dfa19753b15..15fcdb8016c 100644 --- a/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts +++ b/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @@ -17,60 +17,24 @@ test.describe( }); test.describe('App Page', () => { - test.describe('Sandbox disabled', () => { - test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - }); + test('Loads the app page with the sandbox div wrapper', async ({ page }) => { + await page.goto(`/a/${APP_ID}`); - test('Loads the app page without the sandbox div wrapper', async ({ page }) => { - await page.goto(`/a/${APP_ID}`); + const sandboxDiv = page.locator('div[data-plugin-sandbox="sandbox-app-test"]'); + await expect(sandboxDiv).toBeVisible(); - const sandboxDiv = page.locator('div[data-plugin-sandbox="sandbox-app-test"]'); - await expect(sandboxDiv).toBeHidden(); - - const appPage = page.getByTestId('sandbox-app-test-page-one'); - await expect(appPage).toBeVisible(); - }); - - test('Loads the app configuration without the sandbox div wrapper', async ({ page }) => { - await page.goto(`/plugins/${APP_ID}`); - - const sandboxDiv = page.locator('div[data-plugin-sandbox="sandbox-app-test"]'); - await expect(sandboxDiv).toBeHidden(); - - const configPage = page.getByTestId('sandbox-app-test-config-page'); - await expect(configPage).toBeVisible(); - }); + const appPage = page.getByTestId('sandbox-app-test-page-one'); + await expect(appPage).toBeVisible(); }); - test.describe('Sandbox enabled', () => { - test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - }); + test('Loads the app configuration with the sandbox div wrapper', async ({ page }) => { + await page.goto(`/plugins/${APP_ID}`); - test('Loads the app page with the sandbox div wrapper', async ({ page }) => { - await page.goto(`/a/${APP_ID}`); + const sandboxDiv = page.locator('div[data-plugin-sandbox="sandbox-app-test"]'); + await expect(sandboxDiv).toBeVisible(); - const sandboxDiv = page.locator('div[data-plugin-sandbox="sandbox-app-test"]'); - await expect(sandboxDiv).toBeVisible(); - - const appPage = page.getByTestId('sandbox-app-test-page-one'); - await expect(appPage).toBeVisible(); - }); - - test('Loads the app configuration with the sandbox div wrapper', async ({ page }) => { - await page.goto(`/plugins/${APP_ID}`); - - const sandboxDiv = page.locator('div[data-plugin-sandbox="sandbox-app-test"]'); - await expect(sandboxDiv).toBeVisible(); - - const configPage = page.getByTestId('sandbox-app-test-config-page'); - await expect(configPage).toBeVisible(); - }); + const configPage = page.getByTestId('sandbox-app-test-config-page'); + await expect(configPage).toBeVisible(); }); }); } diff --git a/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts b/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts index acddc23018c..339d3c24c19 100644 --- a/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts +++ b/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @@ -11,246 +11,127 @@ test.describe( }, () => { test.describe('Config Editor', () => { - test.describe('Sandbox disabled', () => { - test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); + test('Should render a sandbox wrapper around the datasource config editor', async ({ + page, + createDataSource, + }) => { + const TIMESTAMP = Date.now(); + const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; + // Add the datasource + const response = await createDataSource({ + type: DATASOURCE_ID, + name: DATASOURCE_TYPED_NAME, }); + const DATASOURCE_CONNECTION_ID = response.uid; + await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); - test('Should not render a sandbox wrapper around the datasource config editor', async ({ - page, - createDataSource, - }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); - - const sandboxDiv = page.locator(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`); - await expect(sandboxDiv).toBeHidden(); - }); + const sandboxDiv = page.locator(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`); + await expect(sandboxDiv).toBeVisible(); }); - test.describe('Sandbox enabled', () => { - test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); + test('Should store values in jsonData and secureJsonData correctly', async ({ page, createDataSource }) => { + const TIMESTAMP = Date.now(); + const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; + // Add the datasource + const response = await createDataSource({ + type: DATASOURCE_ID, + name: DATASOURCE_TYPED_NAME, }); + const DATASOURCE_CONNECTION_ID = response.uid; + await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); - test('Should render a sandbox wrapper around the datasource config editor', async ({ - page, - createDataSource, - }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); + const valueToStore = 'test' + random(100); - const sandboxDiv = page.locator(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`); - await expect(sandboxDiv).toBeVisible(); - }); + const queryInput = page.locator('[data-testid="sandbox-config-editor-query-input"]'); + await expect(queryInput).not.toBeDisabled(); + await queryInput.fill(valueToStore); + await expect(queryInput).toHaveValue(valueToStore); - test('Should store values in jsonData and secureJsonData correctly', async ({ page, createDataSource }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); + const saveButton = page.getByTestId('data-testid Data source settings page Save and Test button'); + await saveButton.click(); - const valueToStore = 'test' + random(100); + const alert = page.getByTestId('data-testid Data source settings page Alert'); + await expect(alert).toBeVisible(); + await expect(alert).toContainText('Sandbox Success'); - const queryInput = page.locator('[data-testid="sandbox-config-editor-query-input"]'); - await expect(queryInput).not.toBeDisabled(); - await queryInput.fill(valueToStore); - await expect(queryInput).toHaveValue(valueToStore); - - const saveButton = page.getByTestId('data-testid Data source settings page Save and Test button'); - await saveButton.click(); - - const alert = page.getByTestId('data-testid Data source settings page Alert'); - await expect(alert).toBeVisible(); - await expect(alert).toContainText('Sandbox Success'); - - // validate the value was stored - await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); - await expect(queryInput).not.toBeDisabled(); - await expect(queryInput).toHaveValue(valueToStore); - }); + // validate the value was stored + await page.goto(`/connections/datasources/edit/${DATASOURCE_CONNECTION_ID}`); + await expect(queryInput).not.toBeDisabled(); + await expect(queryInput).toHaveValue(valueToStore); }); }); test.describe('Explore Page', () => { - test.describe('Sandbox disabled', () => { - test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); + test('Should wrap the query editor in a sandbox wrapper', async ({ + page, + createDataSource, + dashboardPage, + selectors, + }) => { + const TIMESTAMP = Date.now(); + const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; + // Add the datasource + const response = await createDataSource({ + type: DATASOURCE_ID, + name: DATASOURCE_TYPED_NAME, }); + const DATASOURCE_CONNECTION_ID = response.uid; + await page.goto('/explore'); - test('Should not wrap the query editor in a sandbox wrapper', async ({ - page, - createDataSource, - dashboardPage, - selectors, - }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto('/explore'); + const dataSourcePicker = dashboardPage.getByGrafanaSelector(selectors.components.DataSourcePicker.container); + await expect(dataSourcePicker).toBeVisible(); + await dataSourcePicker.click(); - const dataSourcePicker = dashboardPage.getByGrafanaSelector(selectors.components.DataSourcePicker.container); - await expect(dataSourcePicker).toBeVisible(); - await dataSourcePicker.click(); + const datasourceOption = page.locator(`text=${DATASOURCE_TYPED_NAME}`); + await expect(datasourceOption).toBeVisible(); + await datasourceOption.scrollIntoViewIfNeeded(); + await datasourceOption.click(); - const datasourceOption = page.locator(`text=${DATASOURCE_TYPED_NAME}`); - await expect(datasourceOption).toBeVisible(); - await datasourceOption.scrollIntoViewIfNeeded(); - await datasourceOption.click(); + // make sure the datasource was correctly selected and rendered + const breadcrumb = dashboardPage.getByGrafanaSelector( + selectors.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME) + ); + await expect(breadcrumb).toBeVisible(); - // make sure the datasource was correctly selected and rendered - const breadcrumb = dashboardPage.getByGrafanaSelector( - selectors.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME) - ); - await expect(breadcrumb).toBeVisible(); - - const sandboxDiv = page.locator(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`); - await expect(sandboxDiv).toBeHidden(); - }); - - test('Should accept values when typed', async ({ page, createDataSource, dashboardPage, selectors }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto('/explore'); - - const dataSourcePicker = dashboardPage.getByGrafanaSelector(selectors.components.DataSourcePicker.container); - await expect(dataSourcePicker).toBeVisible(); - await dataSourcePicker.click(); - - const datasourceOption = page.locator(`text=${DATASOURCE_TYPED_NAME}`); - await expect(datasourceOption).toBeVisible(); - await datasourceOption.scrollIntoViewIfNeeded(); - await datasourceOption.click(); - - // make sure the datasource was correctly selected and rendered - const breadcrumb = dashboardPage.getByGrafanaSelector( - selectors.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME) - ); - await expect(breadcrumb).toBeVisible(); - - const valueToType = 'test' + random(100); - - const queryInput = page.locator('[data-testid="sandbox-query-editor-query-input"]'); - await expect(queryInput).not.toBeDisabled(); - await queryInput.fill(valueToType); - await expect(queryInput).toHaveValue(valueToType); - }); + const sandboxDiv = page.locator(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`); + await expect(sandboxDiv).toBeVisible(); }); - test.describe('Sandbox enabled', () => { - test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); + test('Should accept values when typed', async ({ page, createDataSource, dashboardPage, selectors }) => { + const TIMESTAMP = Date.now(); + const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; + // Add the datasource + const response = await createDataSource({ + type: DATASOURCE_ID, + name: DATASOURCE_TYPED_NAME, }); + const DATASOURCE_CONNECTION_ID = response.uid; + await page.goto('/explore'); - test('Should wrap the query editor in a sandbox wrapper', async ({ - page, - createDataSource, - dashboardPage, - selectors, - }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto('/explore'); + const dataSourcePicker = dashboardPage.getByGrafanaSelector(selectors.components.DataSourcePicker.container); + await expect(dataSourcePicker).toBeVisible(); + await dataSourcePicker.click(); - const dataSourcePicker = dashboardPage.getByGrafanaSelector(selectors.components.DataSourcePicker.container); - await expect(dataSourcePicker).toBeVisible(); - await dataSourcePicker.click(); + const datasourceOption = page.locator(`text=${DATASOURCE_TYPED_NAME}`); + await expect(datasourceOption).toBeVisible(); + await datasourceOption.scrollIntoViewIfNeeded(); + await datasourceOption.click(); - const datasourceOption = page.locator(`text=${DATASOURCE_TYPED_NAME}`); - await expect(datasourceOption).toBeVisible(); - await datasourceOption.scrollIntoViewIfNeeded(); - await datasourceOption.click(); + // make sure the datasource was correctly selected and rendered + const breadcrumb = dashboardPage.getByGrafanaSelector( + selectors.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME) + ); + await expect(breadcrumb).toBeVisible(); - // make sure the datasource was correctly selected and rendered - const breadcrumb = dashboardPage.getByGrafanaSelector( - selectors.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME) - ); - await expect(breadcrumb).toBeVisible(); + const valueToType = 'test' + random(100); - const sandboxDiv = page.locator(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`); - await expect(sandboxDiv).toBeVisible(); - }); + const queryInput = page.locator('[data-testid="sandbox-query-editor-query-input"]'); + await expect(queryInput).not.toBeDisabled(); + await queryInput.fill(valueToType); + await expect(queryInput).toHaveValue(valueToType); - test('Should accept values when typed', async ({ page, createDataSource, dashboardPage, selectors }) => { - const TIMESTAMP = Date.now(); - const DATASOURCE_TYPED_NAME = `SandboxDatasourceInstance-${TIMESTAMP}`; - // Add the datasource - const response = await createDataSource({ - type: DATASOURCE_ID, - name: DATASOURCE_TYPED_NAME, - }); - const DATASOURCE_CONNECTION_ID = response.uid; - await page.goto('/explore'); - - const dataSourcePicker = dashboardPage.getByGrafanaSelector(selectors.components.DataSourcePicker.container); - await expect(dataSourcePicker).toBeVisible(); - await dataSourcePicker.click(); - - const datasourceOption = page.locator(`text=${DATASOURCE_TYPED_NAME}`); - await expect(datasourceOption).toBeVisible(); - await datasourceOption.scrollIntoViewIfNeeded(); - await datasourceOption.click(); - - // make sure the datasource was correctly selected and rendered - const breadcrumb = dashboardPage.getByGrafanaSelector( - selectors.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME) - ); - await expect(breadcrumb).toBeVisible(); - - const valueToType = 'test' + random(100); - - const queryInput = page.locator('[data-testid="sandbox-query-editor-query-input"]'); - await expect(queryInput).not.toBeDisabled(); - await queryInput.fill(valueToType); - await expect(queryInput).toHaveValue(valueToType); - - // typing the query editor should reflect in the url - await expect(page).toHaveURL(new RegExp(valueToType)); - }); + // typing the query editor should reflect in the url + await expect(page).toHaveURL(new RegExp(valueToType)); }); }); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 14148797750..f5f6a9116b6 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -165,10 +165,6 @@ export interface FeatureToggles { */ extraThemes?: boolean; /** - * Enables the plugins frontend sandbox - */ - pluginsFrontendSandbox?: boolean; - /** * Enables writing multiple items from a single query within Recorded Queries * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index be79b887b14..4834439df01 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -264,12 +264,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, }, - { - Name: "pluginsFrontendSandbox", - Description: "Enables the plugins frontend sandbox", - Stage: FeatureStagePrivatePreview, - Owner: grafanaPluginsPlatformSquad, - }, { Name: "recordedQueriesMulti", Description: "Enables writing multiple items from a single query within Recorded Queries", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 160802fc2db..6f75a9c077a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -33,7 +33,6 @@ refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true -pluginsFrontendSandbox,privatePreview,@grafana/plugins-platform-backend,false,false,false recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true awsDatasourcesTempCredentials,GA,@grafana/aws-datasources,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a82856cd292..2b568416f90 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -143,10 +143,6 @@ const ( // Enables extra themes FlagExtraThemes = "extraThemes" - // FlagPluginsFrontendSandbox - // Enables the plugins frontend sandbox - FlagPluginsFrontendSandbox = "pluginsFrontendSandbox" - // FlagRecordedQueriesMulti // Enables writing multiple items from a single query within Recorded Queries FlagRecordedQueriesMulti = "recordedQueriesMulti" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ed9c80b988f..766e1e3a502 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3080,7 +3080,8 @@ "metadata": { "name": "pluginsFrontendSandbox", "resourceVersion": "1753448760331", - "creationTimestamp": "2023-06-05T08:51:36Z" + "creationTimestamp": "2023-06-05T08:51:36Z", + "deletionTimestamp": "2025-11-05T10:06:22Z" }, "spec": { "description": "Enables the plugins frontend sandbox", diff --git a/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.test.ts b/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.test.ts index a36480579d7..a6a4b0f8f57 100644 --- a/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.test.ts +++ b/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.test.ts @@ -15,7 +15,6 @@ import { jest.mock('@grafana/runtime', () => ({ config: { - featureToggles: { pluginsFrontendSandbox: true }, buildInfo: { env: 'production' }, enableFrontendSandboxForPlugins: [], }, @@ -53,7 +52,6 @@ describe('Sandbox eligibility checks', () => { setSandboxEnabledCheck(isPluginFrontendSandboxEnabled); config.enableFrontendSandboxForPlugins = []; - config.featureToggles.pluginsFrontendSandbox = true; process.env.NODE_ENV = 'development'; }); @@ -67,12 +65,6 @@ describe('Sandbox eligibility checks', () => { expect(isEligible).toBe(false); }); - test('shouldLoadPluginInFrontendSandbox returns false when feature toggle is off', async () => { - config.featureToggles.pluginsFrontendSandbox = false; - const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); - expect(result).toBe(false); - }); - test('setSandboxEnabledCheck sets custom check function', async () => { getPluginDetailsMock.mockResolvedValue(fakePluginDetails); const customCheck = jest.fn().mockResolvedValue(true); diff --git a/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.ts b/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.ts index 3aa489a35a9..e85a4d08ceb 100644 --- a/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.ts +++ b/public/app/features/plugins/sandbox/sandboxPluginLoaderRegistry.ts @@ -35,11 +35,6 @@ export async function shouldLoadPluginInFrontendSandbox({ pluginId }: SandboxEli * It does not check if the plugin is actually enabled for the sandbox. */ export async function isPluginFrontendSandboxEligible({ pluginId }: SandboxEligibilityCheckParams): Promise { - // Only if the feature is not enabled no support for sandbox - if (!Boolean(config.featureToggles.pluginsFrontendSandbox)) { - return false; - } - // To fast-test and debug the sandbox in the browser (dev mode only). const sandboxDisableQueryParam = window.location.search.includes('nosandbox') && config.buildInfo.env === 'development'; From 71d511abd888b427cad4543a0d21b9616b537169 Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:27:26 +0000 Subject: [PATCH 047/209] VariableControls: Adjust variable selection in edit mode (#113408) * adjust variable selection logic * clean up * adjust attribute used --- .../dashboard-scene/scene/VariableControls.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index f62d8b06c41..eba51280522 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -56,10 +56,16 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP } // Ignore click if it's inside the value control - if (evt.target instanceof Element && !evt.target.closest(`label`)) { - // Prevent clearing selection when clicking inside value - evt.stopPropagation(); - return; + if (evt.target instanceof Element) { + // multi variable options contain label element so we need a more specific + // condition to target variable label to prevent edit pane selection on option click + const forAttribute = evt.target.closest('label[for]')?.getAttribute('for'); + + if (!(forAttribute === `var-${variable.state.key || ''}`)) { + // Prevent clearing selection when clicking inside value + evt.stopPropagation(); + return; + } } if (isSelectable && onSelect) { From 6bf5e3303e1fc433d7f6ca71a38f6054edd02cbd Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:04:40 +0000 Subject: [PATCH 048/209] chore(deps): pin dependencies (#113494) Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- devenv/frontend-service/docker-compose.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index c5f924f079b..74ff93c2dd4 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -75,7 +75,7 @@ services: GF_TRACING_OPENTELEMETRY_OTLP_PROPAGATION: jaeger,w3c postgres: - image: postgres:16.1-alpine3.19 + image: postgres:16.1-alpine3.19@sha256:17eb369d9330fe7fbdb2f705418c18823d66322584c77c2b43cc0e1851d01de7 environment: POSTGRES_USER: grafana POSTGRES_PASSWORD: grafana @@ -86,7 +86,7 @@ services: - 'alloy.logs=true' alloy: - image: grafana/alloy:v1.11.2 + image: grafana/alloy:v1.11.2@sha256:6ab34b8201f0e8b0c4346be4934c9965723af3f7f21dd9a65fd73f270f69b451 volumes: - ./configs/alloy:/alloy-config - /var/run/docker.sock:/var/run/docker.sock # To scrape Docker container logs @@ -104,7 +104,7 @@ services: - 'alloy.logs=true' prometheus: - image: prom/prometheus:v3.7.2 + image: prom/prometheus:v3.7.2@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7 volumes: - prometheus-data:/prometheus command: @@ -116,7 +116,7 @@ services: - 'alloy.logs=true' loki: - image: grafana/loki:3.5.7 + image: grafana/loki:3.5.7@sha256:0eaee7bf39cc83aaef46914fb58f287d4f4c4be6ec96b86c2ed55719a75e49c8 volumes: - loki-data:/loki command: -config.file=/etc/loki/local-config.yaml @@ -124,7 +124,7 @@ services: - 'alloy.logs=true' tempo-init: - image: busybox:1.37.0 + image: busybox:1.37.0@sha256:e3652a00a2fabd16ce889f0aa32c38eec347b997e73bd09e69c962ec7f8732ee user: root entrypoint: - 'chown' @@ -134,7 +134,7 @@ services: - tempo-data:/var/tempo tempo: - image: grafana/tempo:2.9.0 + image: grafana/tempo:2.9.0@sha256:65a5789759435f1ef696f1953258b9bbdb18eb571d5ce711ff812d2e128288a4 volumes: - tempo-data:/var/lib/tempo - ./configs/tempo.yaml:/etc/tempo/tempo.yaml From efd6b250d9d21dd6c90f5bf36bd82dad42dad769 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 6 Nov 2025 05:08:31 -0600 Subject: [PATCH 049/209] Chore: Remove Chance.js dependency from runtime code (#113457) --- .../provisioning/components/utils/timestamp.test.ts | 4 ++-- .../app/features/provisioning/components/utils/timestamp.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/public/app/features/provisioning/components/utils/timestamp.test.ts b/public/app/features/provisioning/components/utils/timestamp.test.ts index ab7543453dd..805874f631f 100644 --- a/public/app/features/provisioning/components/utils/timestamp.test.ts +++ b/public/app/features/provisioning/components/utils/timestamp.test.ts @@ -8,8 +8,8 @@ describe('generateTimestamp', () => { expect(typeof timestamp).toBe('string'); // Check that the timestamp follows the format YYYY-MM-DD-xxxxx - // where xxxxx is a random string of 5 alphabetic characters - expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}-[a-zA-Z]{5}$/); + // where xxxxx is a random string of 5 alpha-num characters + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}-[a-z\d]{5}$/i); }); it('should generate unique timestamps', () => { diff --git a/public/app/features/provisioning/components/utils/timestamp.ts b/public/app/features/provisioning/components/utils/timestamp.ts index a1020951d41..8906cf3e3a7 100644 --- a/public/app/features/provisioning/components/utils/timestamp.ts +++ b/public/app/features/provisioning/components/utils/timestamp.ts @@ -1,11 +1,9 @@ -import { Chance } from 'chance'; - import { dateTime } from '@grafana/data'; /** * Generates a timestamp string in the format YYYY-MM-DD-xxxxx where xxxxx is a random string */ export function generateTimestamp(): string { - const random = new Chance(); - return `${dateTime().format('YYYY-MM-DD')}-${random.string({ length: 5, alpha: true })}`; + const randStr = Math.random().toString(36).substring(2, 7); + return `${dateTime().format('YYYY-MM-DD')}-${randStr}`; } From fd14d4a5ed3ad8dfd5948a9f2d7b9074e15ea655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Thu, 6 Nov 2025 12:42:46 +0100 Subject: [PATCH 050/209] feat(unified-storage): add tracing to dual writer and legacy storage (#113504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mustafa Sencer Özcan <32759850+mustafasencer@users.noreply.github.com> --- .../apis/dashboard/legacy/sql_dashboards.go | 19 +++- pkg/registry/apis/dashboard/legacy/storage.go | 30 +++++ pkg/storage/legacysql/dualwrite/dualwriter.go | 107 ++++++++++++------ 3 files changed, 121 insertions(+), 35 deletions(-) diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 5676566fd1a..0fca282fa82 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "go.opentelemetry.io/otel" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -40,7 +41,8 @@ import ( ) var ( - _ DashboardAccess = (*dashboardSqlAccess)(nil) + _ DashboardAccess = (*dashboardSqlAccess)(nil) + tracer = otel.Tracer("github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy") ) type dashboardRow struct { @@ -105,6 +107,9 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, } func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *DashboardQuery) (*rowsWrapper, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.getRows") + defer span.End() + if len(query.Labels) > 0 { return nil, fmt.Errorf("labels not yet supported") // if query.Requirements.Folder != nil { @@ -416,6 +421,9 @@ func getUserID(v sql.NullString, id sql.NullInt64) string { // DeleteDashboard implements DashboardAccess. func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardV1.Dashboard, bool, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.DeleteDashboard") + defer span.End() + dash, _, err := a.GetDashboard(ctx, orgId, uid, 0) if err != nil { return nil, false, err @@ -432,6 +440,9 @@ func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, u } func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard) (*dashboards.SaveDashboardCommand, bool, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.buildSaveDashboardCommand") + defer span.End() + created := false user, ok := claims.AuthInfoFrom(ctx) if !ok || user == nil { @@ -495,6 +506,9 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI } func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard, failOnExisting bool) (*dashboardV1.Dashboard, bool, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.SaveDashboard") + defer span.End() + user, ok := claims.AuthInfoFrom(ctx) if !ok || user == nil { return nil, false, fmt.Errorf("no user found in context") @@ -565,6 +579,9 @@ type panel struct { } func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetLibraryPanels") + defer span.End() + limit := int(query.Limit) query.Limit += 1 // for continue if query.OrgID == 0 { diff --git a/pkg/registry/apis/dashboard/legacy/storage.go b/pkg/registry/apis/dashboard/legacy/storage.go index d5947ab24f5..d8c948c9664 100644 --- a/pkg/registry/apis/dashboard/legacy/storage.go +++ b/pkg/registry/apis/dashboard/legacy/storage.go @@ -78,6 +78,9 @@ func isDashboardKey(key *resourcepb.ResourceKey, requireName bool) error { } func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.WriteEvent) (rv int64, err error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.WriteEvent") + defer span.End() + info, err := claims.ParseNamespace(event.Key.Namespace) if err == nil { err = isDashboardKey(event.Key, true) @@ -170,6 +173,9 @@ func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.Writ } func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string, v int64) (*dashboard.Dashboard, int64, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetDashboard") + defer span.End() + sql, err := a.sql(ctx) if err != nil { return nil, 0, err @@ -197,6 +203,9 @@ func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid // Read implements ResourceStoreServer. func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *resource.BackendReadResponse { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ReadResource") + defer span.End() + rsp := &resource.BackendReadResponse{} info, err := claims.ParseNamespace(req.Key.Namespace) if err == nil { @@ -238,16 +247,25 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.R // ListHistory implements StorageBackend. func (a *dashboardSqlAccess) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListHistory") + defer span.End() + return a.ListIterator(ctx, req, cb) } func (a *dashboardSqlAccess) ListModifiedSince(ctx context.Context, key resource.NamespacedResource, sinceRv int64) (int64, iter.Seq2[*resource.ModifiedResource, error]) { + _, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListModifiedSince") + defer span.End() + return 0, func(yield func(*resource.ModifiedResource, error) bool) { yield(nil, errors.New("not implemented")) } } func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[resource.ResourceLastImportTime, error] { + _, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetResourceLastImportTimes") + defer span.End() + return func(yield func(resource.ResourceLastImportTime, error) bool) { yield(resource.ResourceLastImportTime{}, errors.New("not implemented")) } @@ -255,6 +273,9 @@ func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) ite // List implements StorageBackend. func (a *dashboardSqlAccess) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListIterator") + defer span.End() + if req.ResourceVersion != 0 { return 0, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend") } @@ -348,10 +369,16 @@ func (a *dashboardSqlAccess) WatchWriteEvents(ctx context.Context) (<-chan *reso // Simple wrapper for index implementation func (a *dashboardSqlAccess) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resource.BackendReadResponse, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Read") + defer span.End() + return a.ReadResource(ctx, req), nil } func (a *dashboardSqlAccess) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Search") + defer span.End() + return a.dashboardSearchClient.Search(ctx, req) } @@ -365,5 +392,8 @@ func (a *dashboardSqlAccess) CountManagedObjects(context.Context, *resourcepb.Co // GetStats implements ResourceServer. func (a *dashboardSqlAccess) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) { + ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetStats") + defer span.End() + return a.dashboardSearchClient.GetStats(ctx, req) } diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index 966340db779..8aed81d7200 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -5,6 +5,9 @@ import ( "fmt" "time" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" @@ -19,41 +22,17 @@ import ( ) var ( - _ grafanarest.Storage = (*dualWriter)(nil) + _ grafanarest.Storage = (*dualWriter)(nil) + tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/legacysql/dualwrite") ) -func objectInfo(obj runtime.Object) map[string]interface{} { - if obj == nil { - return map[string]interface{}{"object": "nil"} - } - - acc, err := meta.Accessor(obj) - if err != nil { - return map[string]interface{}{"object": fmt.Sprintf("%T", obj), "error": err.Error()} - } - - info := map[string]interface{}{ - "name": acc.GetName(), - } - - if ns := acc.GetNamespace(); ns != "" { - info["namespace"] = ns - } - if uid := acc.GetUID(); uid != "" { - info["uid"] = string(uid) - } - if rv := acc.GetResourceVersion(); rv != "" { - info["resourceVersion"] = rv - } - - return info -} - -// Let's give the background queries a bit more time to complete -// as we also run them as part of load tests that might need longer -// to complete. Those run in the background and won't impact the -// user experience in any way. -const backgroundReqTimeout = time.Minute +const ( + // Let's give the background queries a bit more time to complete + // as we also run them as part of load tests that might need longer + // to complete. Those run in the background and won't impact the + // user experience in any way. + backgroundReqTimeout = time.Minute +) // dualWriter will write first to legacy, then to unified keeping the same internal ID type dualWriter struct { @@ -64,6 +43,12 @@ type dualWriter struct { } func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Get", + trace.WithAttributes( + attribute.Bool("errorIsOK", d.errorIsOK), + attribute.Bool("readUnified", d.readUnified))) + defer span.End() + log := logging.FromContext(ctx).With("method", "Get", "name", name) // If we read from unified, we can just do that and return. if d.readUnified { @@ -96,6 +81,12 @@ func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOp } func (d *dualWriter) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.List", + trace.WithAttributes( + attribute.Bool("errorIsOK", d.errorIsOK), + attribute.Bool("readUnified", d.readUnified))) + defer span.End() + // Always work on *copies* so we never mutate the caller's ListOptions. var ( legacyOptions = options.DeepCopy() @@ -204,6 +195,12 @@ func (d *dualWriter) List(ctx context.Context, options *metainternalversion.List // Create overrides the behavior of the generic DualWriter and writes to LegacyStorage and Storage. func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Create", + trace.WithAttributes( + attribute.Bool("errorIsOK", d.errorIsOK), + attribute.Bool("readUnified", d.readUnified))) + defer span.End() + log := logging.FromContext(ctx).With("method", "Create") accIn, err := meta.Accessor(in) @@ -316,6 +313,11 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r // By setting RemovePermissions to false in the context, we will skip the deletion of permissions // in the legacy store. This is needed as otherwise the permissions would be missing when executing // the delete operation in the unified storage store. + ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Delete", + trace.WithAttributes( + attribute.Bool("errorIsOK", d.errorIsOK), + attribute.Bool("readUnified", d.readUnified))) + defer span.End() log := logging.FromContext(ctx).With("method", "Delete", "name", name) ctx = utils.SetFolderRemovePermissions(ctx, false) @@ -357,8 +359,12 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r // Update overrides the behavior of the generic DualWriter and writes first to Storage and then to LegacyStorage. func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.Update", + trace.WithAttributes( + attribute.Bool("errorIsOK", d.errorIsOK), + attribute.Bool("readUnified", d.readUnified))) + defer span.End() log := logging.FromContext(ctx).With("method", "Update", "name", name) - // update in legacy first, and then unistore. Will return a failure if either fails. // // we want to update in legacy first, otherwise if the update from unistore was successful, @@ -420,6 +426,12 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat // DeleteCollection overrides the behavior of the generic DualWriter and deletes from both LegacyStorage and Storage. func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) { + ctx, span := tracer.Start(ctx, "dualwrite.dualWriter.DeleteCollection", + trace.WithAttributes( + attribute.Bool("errorIsOK", d.errorIsOK), + attribute.Bool("readUnified", d.readUnified))) + defer span.End() + log := logging.FromContext(ctx).With("method", "DeleteCollection", "resourceVersion", listOptions.ResourceVersion) // delete from legacy first, and anything that is successful can be deleted in unistore too. @@ -528,3 +540,30 @@ func (w *wrappedUpdateInfo) UpdatedObject(ctx context.Context, oldObj runtime.Ob meta.SetUID("") return obj, err } + +func objectInfo(obj runtime.Object) map[string]interface{} { + if obj == nil { + return map[string]interface{}{"object": "nil"} + } + + acc, err := meta.Accessor(obj) + if err != nil { + return map[string]interface{}{"object": fmt.Sprintf("%T", obj), "error": err.Error()} + } + + info := map[string]interface{}{ + "name": acc.GetName(), + } + + if ns := acc.GetNamespace(); ns != "" { + info["namespace"] = ns + } + if uid := acc.GetUID(); uid != "" { + info["uid"] = string(uid) + } + if rv := acc.GetResourceVersion(); rv != "" { + info["resourceVersion"] = rv + } + + return info +} From 7b3145a3c138bf1b63cc1582b9959e5f599899f9 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 6 Nov 2025 13:56:32 +0100 Subject: [PATCH 051/209] fix: delete subfolder dangling panels (#113419) * fix: delete subfolder dangling panels and error if used * chore: add observation about library panel DeleteInFolders - logs folders UIDs on DeleteInFolders error * chore: add integration test for blocking library panel deletion and handling dangling library panels * chore: fix integration test on mode 4 and 5 --- pkg/api/folder.go | 17 +- .../folderimpl/folder_unifiedstorage.go | 15 + .../folderimpl/folder_unifiedstorage_test.go | 27 ++ pkg/tests/apis/folder/folders_test.go | 282 ++++++++++++++++++ 4 files changed, 327 insertions(+), 14 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 958113e9f36..851910a9e1d 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -271,26 +271,15 @@ func (hs *HTTPServer) UpdateFolder(c *contextmodel.ReqContext) response.Response // 403: forbiddenError // 404: notFoundError // 500: internalServerError -func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed - err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) +func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { + uid := web.Params(c.Req)[":uid"] + err := hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser}) if err != nil { if errors.Is(err, model.ErrFolderHasConnectedLibraryElements) { return response.Error(http.StatusForbidden, "Folder could not be deleted because it contains library elements in use", err) } return apierrors.ToFolderErrorResponse(err) } - /* TODO: after a decision regarding folder deletion permissions has been made - (https://github.com/grafana/grafana-enterprise/issues/5144), - remove the previous call to hs.LibraryElementService.DeleteLibraryElementsInFolder - and remove "user" from the signature of DeleteInFolder in the folder RegistryService. - Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903 - */ - - uid := web.Params(c.Req)[":uid"] - err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser}) - if err != nil { - return apierrors.ToFolderErrorResponse(err) - } return response.JSON(http.StatusOK, util.DynMap{ "message": "Folder deleted", diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index c6a78fc0d2c..1ec9c25aac0 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -644,6 +644,21 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol return folder.ErrFolderNotEmpty.Errorf("folder contains %d alert rules", alertRulesInFolder) } + libraryPanelSrv, ok := s.registry[entity.StandardKindLibraryPanel] + if !ok { + return folder.ErrInternal.Errorf("no library panel service found in registry") + } + // /* TODO: after a decision regarding folder deletion permissions has been made + // (https://github.com/grafana/grafana-enterprise/issues/5144), + // remove the following call to DeleteInFolders + // and remove "user" from the signature of DeleteInFolder in the folder RegistryService. + // Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903 + // */ + // Obs: DeleteInFolders only deletes dangling library panels (not linked to any dashboard) and throws errors if there are connections + if err := libraryPanelSrv.DeleteInFolders(ctx, cmd.OrgID, folders, cmd.SignedInUser); err != nil { + s.log.Error("failed to delete dangling library panels in folders", "error", err, "folders", strings.Join(folders, ",")) + return err + } // We need a list of dashboard uids inside the folder to delete related dashboards & public dashboards - // we cannot use the dashboard service directly due to circular dependencies, so use the search client to get the dashboards request := &resourcepb.ResourceSearchRequest{ diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index bf4d96c976e..3952cc67687 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -17,6 +17,7 @@ import ( clientrest "k8s.io/client-go/rest" folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/bus" @@ -33,6 +34,9 @@ import ( dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/libraryelements" + "github.com/grafana/grafana/pkg/services/librarypanels" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/search/model" @@ -209,6 +213,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { {Action: dashboards.ActionFoldersDelete, Scope: dashboards.ScopeFoldersAll}, {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, {Action: accesscontrol.ActionAlertingRuleDelete, Scope: dashboards.ScopeFoldersAll}, + {Action: accesscontrol.ActionLibraryPanelsDelete, Scope: dashboards.ScopeFoldersAll}, }), }} @@ -219,6 +224,16 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, } + mockDashboardService := dashboards.NewFakeDashboardService(t) + mockFolderService := foldertest.NewFakeService() + elementService := libraryelements.ProvideService(cfg, db, routing.NewRouteRegister(), mockFolderService, featuremgmt.WithFeatures(), actest.FakeAccessControl{ExpectedEvaluate: true}, mockDashboardService, nil, nil) + lps := librarypanels.LibraryPanelService{ + Cfg: cfg, + SQLStore: db, + LibraryElementService: elementService, + FolderService: mockFolderService, + } + publicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) fakeK8sClient := new(client.MockK8sHandler) @@ -237,6 +252,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { } require.NoError(t, folderService.RegisterService(alertingStore)) + require.NoError(t, folderService.RegisterService(lps)) t.Run("Folder service tests", func(t *testing.T) { t.Run("Given user has no permissions", func(t *testing.T) { @@ -877,6 +893,17 @@ func TestIntegrationDeleteFoldersFromApiServer(t *testing.T) { } require.NoError(t, service.RegisterService(alertingStore)) + mockDashboardService := dashboards.NewFakeDashboardService(t) + mockFolderService := foldertest.NewFakeService() + elementService := libraryelements.ProvideService(cfg, db, routing.NewRouteRegister(), mockFolderService, featuremgmt.WithFeatures(), actest.FakeAccessControl{ExpectedEvaluate: true}, mockDashboardService, nil, nil) + lps := librarypanels.LibraryPanelService{ + Cfg: cfg, + SQLStore: db, + LibraryElementService: elementService, + FolderService: mockFolderService, + } + require.NoError(t, service.RegisterService(lps)) + t.Run("Should delete folder", func(t *testing.T) { publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{}).Return(nil).Once() dashboardK8sclient.On("Search", mock.Anything, int64(1), mock.Anything).Return(&resourcepb.ResourceSearchResponse{Results: &resourcepb.ResourceTable{}}, nil).Once() diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index c01b8009769..39e519f5632 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/meta" @@ -1426,6 +1427,287 @@ func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *tes } } +// Test folder deletion with connected (in-use) library panels - should be blocked +func TestIntegrationFolderDeletionBlockedByConnectedLibraryPanels(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !db.IsTestDbSQLite() { + t.Skip("test only on sqlite for now") + } + + for mode := 0; mode <= 5; mode++ { + t.Run(fmt.Sprintf("mode %v - delete blocked by connected library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, + }, + "dashboards.dashboard.grafana.app": { + DualWriterMode: modeDw, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagUnifiedStorageSearch, + }, + }) + + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + + // Create parent and child folders + uid := uuid.NewString()[:8] + parentUID := fmt.Sprintf("connected-parent-%d-%s", mode, uid) + childUID := fmt.Sprintf("connected-child-%d-%s", mode, uid) + createTestFolder(t, helper, client, parentUID, fmt.Sprintf("Parent Folder %d-%s", mode, uid), "") + createTestFolder(t, helper, client, childUID, fmt.Sprintf("Child Folder %d-%s", mode, uid), parentUID) + + // Create library panels in both folders + parentLibPanelName := fmt.Sprintf("Connected LP in parent %d-%s", mode, uid) + childLibPanelName := fmt.Sprintf("Connected LP in child %d-%s", mode, uid) + parentLibPanelUID := createTestLibraryPanel(t, helper, client, parentLibPanelName, parentUID) + childLibPanelUID := createTestLibraryPanel(t, helper, client, childLibPanelName, childUID) + + // Create dashboards using library panels (makes them connected) + parentDashUID := createDashboardWithLibraryPanel(t, helper, client, + fmt.Sprintf("Dashboard with LP in parent %d-%s", mode, uid), + parentLibPanelUID, "Connected LP in parent", parentUID) + childDashUID := createDashboardWithLibraryPanel(t, helper, client, + fmt.Sprintf("Dashboard with LP in child %d-%s", mode, uid), + childLibPanelUID, "Connected LP in child", childUID) + + // Attempt to delete the parent folder - should be blocked because library panels are connected + parentDelete := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodDelete, + Path: "/api/folders/" + parentUID, + }, &folder.Folder{}) + require.Equal(t, http.StatusForbidden, parentDelete.Response.StatusCode) + + // Verify both folders still exist + _, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{}) + require.NoError(t, getParentErr, "parent folder should still exist after failed deletion") + _, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{}) + require.NoError(t, getChildErr, "child folder should still exist after failed deletion") + + // Verify library panels still exist + verifyLibraryPanelExists(t, helper, client, parentLibPanelUID) + verifyLibraryPanelExists(t, helper, client, childLibPanelUID) + + // Verify dashboards still exist + verifyDashboardExists(t, helper, client, parentDashUID) + verifyDashboardExists(t, helper, client, childDashUID) + }) + } +} + +// Test folder deletion with dangling (unconnected) library panels - should succeed and clean up +func TestIntegrationFolderDeletionWithDanglingLibraryPanels(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !db.IsTestDbSQLite() { + t.Skip("test only on sqlite for now") + } + + for mode := 0; mode <= 5; mode++ { + t.Run(fmt.Sprintf("mode %v - delete succeeds and cleans up dangling library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagUnifiedStorageSearch, + }, + }) + + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + + // Create parent and child folders + uid := uuid.NewString()[:8] + parentUID := fmt.Sprintf("dangling-parent-%d-%s", mode, uid) + childUID := fmt.Sprintf("dangling-child-%d-%s", mode, uid) + createTestFolder(t, helper, client, parentUID, fmt.Sprintf("Parent Folder %d-%s", mode, uid), "") + createTestFolder(t, helper, client, childUID, fmt.Sprintf("Child Folder %d-%s", mode, uid), parentUID) + + // Create dangling library panels in both folders (not connected to any dashboard) + parentLibPanelUID := createTestLibraryPanel(t, helper, client, + fmt.Sprintf("Dangling LP in parent %d-%s", mode, uid), parentUID) + childLibPanelUID := createTestLibraryPanel(t, helper, client, + fmt.Sprintf("Dangling LP in child %d-%s", mode, uid), childUID) + + // Verify library panels exist before deletion + verifyLibraryPanelExists(t, helper, client, parentLibPanelUID) + verifyLibraryPanelExists(t, helper, client, childLibPanelUID) + + // Attempt to delete the parent folder - should be blocked because library panels are connected + parentDelete := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodDelete, + Path: "/api/folders/" + parentUID, + }, &folder.Folder{}) + require.Equal(t, http.StatusOK, parentDelete.Response.StatusCode, parentDelete.Body) + // Verify folders are deleted + _, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{}) + require.Error(t, getParentErr, "parent folder should not exist after deletion") + _, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{}) + require.Error(t, getChildErr, "child folder should not exist after deletion") + + // Verify dangling library panels were cleaned up + verifyLibraryPanelDeleted(t, helper, client, parentLibPanelUID, "dangling library panel in parent should be deleted") + verifyLibraryPanelDeleted(t, helper, client, childLibPanelUID, "dangling library panel in child should be deleted") + }) + } +} + +// Helper function to create a folder with specified UID and optional parent +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + require.Equal(t, uid, folderCreate.Result.UID) + + return folderCreate.Result +} + +// Helper function to create a library panel in a folder +func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, name, folderUID string) string { + t.Helper() + + libPanelPayload := fmt.Sprintf(`{ + "kind": 1, + "name": "%s", + "folderUid": "%s", + "model": { + "type": "text", + "title": "%s" + } + }`, name, folderUID, name) + + libCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/library-elements", + Body: []byte(libPanelPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, libCreate.Response) + require.Equal(t, http.StatusOK, libCreate.Response.StatusCode) + + libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string) + require.NotEmpty(t, libPanelUID) + + return libPanelUID +} + +// Helper function to create a dashboard that uses a library panel +func createDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, dashTitle, libPanelUID, libPanelName, folderUID string) string { + t.Helper() + + dashPayload := fmt.Sprintf(`{ + "dashboard": { + "title": "%s", + "panels": [{ + "id": 1, + "libraryPanel": { + "uid": "%s", + "name": "%s" + } + }] + }, + "folderUid": "%s", + "overwrite": false + }`, dashTitle, libPanelUID, libPanelName, folderUID) + + dashCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/dashboards/db", + Body: []byte(dashPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, dashCreate.Response) + require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode) + + // Extract dashboard UID from response + dashUID := (*dashCreate.Result)["uid"].(string) + require.NotEmpty(t, dashUID) + + return dashUID +} + +// Helper function to verify library panel exists +func verifyLibraryPanelExists(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, libPanelUID string) { + t.Helper() + + libGet := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: fmt.Sprintf("/api/library-elements/%s", libPanelUID), + }, &map[string]interface{}{}) + + require.Equal(t, http.StatusOK, libGet.Response.StatusCode) +} + +// Helper function to verify library panel does not exist +func verifyLibraryPanelDeleted(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, libPanelUID, message string) { + t.Helper() + + libGet := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: fmt.Sprintf("/api/library-elements/%s", libPanelUID), + }, &map[string]interface{}{}) + + require.Equal(t, http.StatusNotFound, libGet.Response.StatusCode, message) +} + +// Helper function to verify dashboard exists by UID +func verifyDashboardExists(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, dashUID string) { + t.Helper() + + dashGet := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: fmt.Sprintf("/api/dashboards/uid/%s", dashUID), + }, &map[string]interface{}{}) + + require.Equal(t, http.StatusOK, dashGet.Response.StatusCode, fmt.Sprintf("dashboard %s should still exist", dashUID)) +} + // Test moving folders to root. func TestIntegrationMoveNestedFolderToRootK8S(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) From 859865351d72fd79f0afcbb2ae467b163e06e052 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 6 Nov 2025 06:42:21 -0700 Subject: [PATCH 052/209] Dashboard Export: Don't pass already templateized ds var to ds service (#113319) --- .../scene/export/exporters.test.ts | 59 +++++++++++++++++++ .../dashboard-scene/scene/export/exporters.ts | 8 ++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/scene/export/exporters.test.ts b/public/app/features/dashboard-scene/scene/export/exporters.test.ts index 38cc91bf462..f91f1563abc 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.test.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.test.ts @@ -293,6 +293,65 @@ describe('dashboard exporter v1', () => { expect(exported.panels[0].targets[0].datasource).toEqual({ uid: '${DS_OTHER}', type: 'other' }); }); + it('should not attempt to templateize datasource variable ref if they have already been templateized', async () => { + const dashboard: Dashboard = { + title: 'My dashboard', + revision: 1, + editable: false, + graphTooltip: DashboardCursorSync.Off, + schemaVersion: 1, + timepicker: { hidden: true }, + timezone: '', + panels: [ + { + id: 1, + type: 'timeseries', + title: 'My panel title', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + datasource: { + type: 'prometheus', + uid: '${ds_var}', + }, + }, + ], + templating: { + list: [ + { + current: { + selected: false, + text: 'my-prometheus-datasource', + value: 'my-prometheus-datasource-uid', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'ds_var', + options: [], + query: 'prometheus', + refresh: 1, + regex: '', + skipUrlSync: false, + type: 'datasource', + }, + // query variable here uses the datasource variable that has already been templateized + { + name: 'query_var', + datasource: { uid: '${ds_var}', type: 'prometheus' }, + type: 'query', + }, + ], + }, + }; + const dashboardModel = new DashboardModel(dashboard, undefined, { + getVariablesFromState: () => dashboard.templating!.list! as TypedVariableModel[], + }); + const exported = (await makeExportableV1(dashboardModel)) as DashboardJson; + + // @ts-ignore + const queryVarDatasource = exported.templating?.list[1].datasource; + expect(queryVarDatasource).toEqual({ uid: '${ds_var}', type: 'prometheus' }); + }); + describe('given dashboard with repeated panels', () => { let dash: any, exported: any; diff --git a/public/app/features/dashboard-scene/scene/export/exporters.ts b/public/app/features/dashboard-scene/scene/export/exporters.ts index 01a2ea9736b..96492361e8d 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.ts @@ -128,6 +128,12 @@ export async function makeExportableV1(dashboard: DashboardModel) { if (match) { varName = match[1] || match[2] || match[4]; datasourceVariable = variableLookup[varName]; + + // if datasource variable is already templated, skip it + if (datasourceVariableRefNameMap[varName]) { + return; + } + if (datasourceVariable && datasourceVariable.current) { datasource = datasourceVariable.current.value; } @@ -172,7 +178,7 @@ export async function makeExportableV1(dashboard: DashboardModel) { }; } - // if it panel or query is relying on a datasource variable + // if panel or query is relying on a datasource variable // skip templating datasource uid but save the reference so we can set datasource variable's current prop if (datasourceVariable && varName) { datasourceVariableRefNameMap[varName] = '${' + refName + '}'; From fbf1cdd0ceeb3e1f50ec5bb9fccf6a94542d940b Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 6 Nov 2025 13:46:41 +0000 Subject: [PATCH 053/209] Folders: Remove unneeded reducer (#113506) --- public/app/core/reducers/root.ts | 2 - .../features/dashboard/state/initDashboard.ts | 10 +-- public/app/features/folders/state/actions.ts | 16 ----- .../features/folders/state/reducers.test.ts | 67 ------------------- public/app/features/folders/state/reducers.ts | 49 -------------- 5 files changed, 3 insertions(+), 141 deletions(-) delete mode 100644 public/app/features/folders/state/actions.ts delete mode 100644 public/app/features/folders/state/reducers.test.ts delete mode 100644 public/app/features/folders/state/reducers.ts diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 4d26d7bc153..d86b93c916e 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -15,7 +15,6 @@ import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/s import dashboardReducers from 'app/features/dashboard/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import exploreReducers from 'app/features/explore/state/main'; -import foldersReducers from 'app/features/folders/state/reducers'; import invitesReducers from 'app/features/invites/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; @@ -35,7 +34,6 @@ const rootReducers = { ...sharedReducers, ...alertingReducers, ...teamsReducers, - ...foldersReducers, ...dashboardReducers, ...exploreReducers, ...dataSourcesReducers, diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 774e73e37ad..81e2f878bde 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -15,8 +15,8 @@ import { HOME_DASHBOARD_CACHE_KEY, getDashboardScenePageStateManager, } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { updateNavModel } from 'app/features/dashboard-scene/pages/utils'; import { buildNewDashboardSaveModel } from 'app/features/dashboard-scene/serialization/buildNewDashboardSaveModel'; -import { getFolderByUid } from 'app/features/folders/state/actions'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { toStateKey } from 'app/features/variables/utils'; @@ -96,11 +96,7 @@ async function fetchDashboard( // get parent folder (if it exists) and put it in the store // this will be used to populate the full breadcrumb trail if (dashDTO.meta.folderUid) { - try { - await dispatch(getFolderByUid(dashDTO.meta.folderUid)); - } catch (err) { - console.warn('Error fetching parent folder', dashDTO.meta.folderUid, 'for dashboard', err); - } + await updateNavModel(dashDTO.meta.folderUid); } if (args.fixUrl && dashDTO.meta.url && !playlistSrv.state.isPlaying) { @@ -124,7 +120,7 @@ async function fetchDashboard( // get parent folder (if it exists) and put it in the store // this will be used to populate the full breadcrumb trail if (args.urlFolderUid) { - await dispatch(getFolderByUid(args.urlFolderUid)); + await updateNavModel(args.urlFolderUid); } return await buildNewDashboardSaveModel(args.urlFolderUid); } diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts deleted file mode 100644 index 27b22a3102d..00000000000 --- a/public/app/features/folders/state/actions.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { updateNavIndex } from 'app/core/actions'; -import { backendSrv } from 'app/core/services/backend_srv'; -import { FolderDTO } from 'app/types/folders'; -import { ThunkResult } from 'app/types/store'; - -import { buildNavModel } from './navModel'; -import { loadFolder } from './reducers'; - -export function getFolderByUid(uid: string): ThunkResult> { - return async (dispatch) => { - const folder = await backendSrv.getFolderByUid(uid); - dispatch(loadFolder(folder)); - dispatch(updateNavIndex(buildNavModel(folder))); - return folder; - }; -} diff --git a/public/app/features/folders/state/reducers.test.ts b/public/app/features/folders/state/reducers.test.ts deleted file mode 100644 index 33f9fe0e4e9..00000000000 --- a/public/app/features/folders/state/reducers.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { FolderDTO, FolderState } from 'app/types/folders'; - -import { reducerTester } from '../../../../test/core/redux/reducerTester'; - -import { folderReducer, initialState, loadFolder, setFolderTitle } from './reducers'; - -function getTestFolder(): FolderDTO { - return { - id: 1, - title: 'test folder', - uid: 'asd', - url: 'url', - canSave: true, - canEdit: true, - canAdmin: true, - canDelete: true, - version: 0, - created: '', - createdBy: '', - hasAcl: false, - updated: '', - updatedBy: '', - }; -} - -describe('folder reducer', () => { - describe('when loadFolder is dispatched', () => { - it('should load folder and set hasChanged to false', () => { - reducerTester() - .givenReducer(folderReducer, { ...initialState, hasChanged: true }) - .whenActionIsDispatched(loadFolder(getTestFolder())) - .thenStateShouldEqual({ - ...initialState, - hasChanged: false, - ...getTestFolder(), - }); - }); - }); - - describe('when setFolderTitle is dispatched', () => { - describe('and title has length', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(folderReducer, { ...initialState }) - .whenActionIsDispatched(setFolderTitle('ready')) - .thenStateShouldEqual({ - ...initialState, - hasChanged: true, - title: 'ready', - }); - }); - }); - - describe('and title has no length', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(folderReducer, { ...initialState }) - .whenActionIsDispatched(setFolderTitle('')) - .thenStateShouldEqual({ - ...initialState, - hasChanged: false, - title: '', - }); - }); - }); - }); -}); diff --git a/public/app/features/folders/state/reducers.ts b/public/app/features/folders/state/reducers.ts deleted file mode 100644 index 849f57e7631..00000000000 --- a/public/app/features/folders/state/reducers.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -import { endpoints } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; -import { FolderState, FolderDTO } from 'app/types/folders'; - -export const initialState: FolderState = { - id: 0, - uid: 'loading', - title: 'loading', - url: '', - canSave: false, - canDelete: false, - hasChanged: false, - version: 1, -}; - -const loadFolderReducer = (state: FolderState, action: PayloadAction): FolderState => { - return { - ...state, - ...action.payload, - hasChanged: false, - }; -}; - -const folderSlice = createSlice({ - name: 'folder', - initialState, - reducers: { - loadFolder: loadFolderReducer, - setFolderTitle: (state, action: PayloadAction): FolderState => { - return { - ...state, - title: action.payload, - hasChanged: action.payload.trim().length > 0, - }; - }, - }, - extraReducers: (builder) => { - builder.addMatcher(endpoints.getFolder.matchFulfilled, loadFolderReducer); - }, -}); - -export const { loadFolder, setFolderTitle } = folderSlice.actions; - -export const folderReducer = folderSlice.reducer; - -export default { - folder: folderReducer, -}; From e69f3c55f7a5fe316ab0b989befd1e91270e6ea5 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 6 Nov 2025 15:04:34 +0100 Subject: [PATCH 054/209] fix: delete folders using postorder (#113493) * fix: delete folders using postorder * chore: use helper function and do not add method to Folder store - addresses other review comments fixing log messages and cleans up the unit tests * chore: run library element tests on modes 2,3,5 only * chore: adjust to folder.SortByPostorder(folders []*Folder) * chore: run library panels tests in mode 2,3,5 only * chore: run tests in all modes and increase timeout - adjusting the modes and tweaking configs will be done separately --- .github/workflows/pr-test-integration.yml | 6 +- pkg/services/folder/folderimpl/folder.go | 5 +- .../folderimpl/folder_unifiedstorage.go | 2 + .../folderimpl/folder_unifiedstorage_test.go | 1 + pkg/services/folder/model.go | 47 ++++++ pkg/services/folder/model_test.go | 153 ++++++++++++++++++ pkg/services/folder/store.go | 2 +- pkg/tests/apis/folder/folders_test.go | 106 +++++++++++- 8 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 pkg/services/folder/model_test.go diff --git a/.github/workflows/pr-test-integration.yml b/.github/workflows/pr-test-integration.yml index 356f7a02b2b..51da3940973 100644 --- a/.github/workflows/pr-test-integration.yml +++ b/.github/workflows/pr-test-integration.yml @@ -68,7 +68,7 @@ jobs: run: | set -euo pipefail readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N"$SHARD" -d-)" - go test -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" + go test -tags=sqlite -timeout=12m -run '^TestIntegration' "${PACKAGES[@]}" sqlite_nocgo: needs: detect-changes @@ -109,7 +109,7 @@ jobs: # Build regex pattern like: pkg1$|pkg2$|pkg3$ SKIP_PATTERN=$(echo "$SKIP_PACKAGES" | sed '/^$/d' | sed 's|.*|&$|' | paste -sd '|' -) readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N "$SHARD" -d - | grep -Ev "($SKIP_PATTERN)")" - go test -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" + go test -tags=sqlite -timeout=12m -run '^TestIntegration' "${PACKAGES[@]}" - name: Run profiled tests id: run-profiled-tests if: matrix.shard == 'profiled' @@ -135,7 +135,7 @@ jobs: pkg_name=$(basename "$full_pkg" | tr '/' '_' | tr '.' '_') echo "📦 Running $full_pkg" set +e - go test -tags=sqlite -timeout=8m -run '^TestIntegration' \ + go test -tags=sqlite -timeout=12m -run '^TestIntegration' \ -outputdir=profiles \ -cpuprofile="cpu_${pkg_name}.prof" \ -memprofile="mem_${pkg_name}.prof" \ diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index bad681c60a8..a78d1d76b7c 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -1241,15 +1241,16 @@ func (s *Service) nestedFolderDelete(ctx context.Context, cmd *folder.DeleteFold s.log.ErrorContext(ctx, "failed to get descendant folders", "error", err) return descendantUIDs, err } + descendants = folder.SortByPostorder(descendants) for _, f := range descendants { descendantUIDs = append(descendantUIDs, f.UID) } - s.log.InfoContext(ctx, "deleting folder descendants", "org_id", cmd.OrgID, "uid", cmd.UID) + s.log.InfoContext(ctx, "deleting legacy folder descendants", "org_id", cmd.OrgID, "uid", cmd.UID, "descendantsUIDs", strings.Join(descendantUIDs, ",")) err = s.store.Delete(ctx, descendantUIDs, cmd.OrgID) if err != nil { - s.log.InfoContext(ctx, "failed deleting descendants", "org_id", cmd.OrgID, "parent_uid", cmd.UID, "err", err) + s.log.ErrorContext(ctx, "failed to delete legacy folder descendants", "org_id", cmd.OrgID, "parent_uid", cmd.UID, "descendantsUIDs", strings.Join(descendantUIDs, ","), "err", err) return descendantUIDs, err } return descendantUIDs, nil diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 1ec9c25aac0..1551d858efe 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -618,12 +618,14 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol if err != nil { return err } + descFolders = folder.SortByPostorder(descFolders) folders := []string{} for _, f := range descFolders { folders = append(folders, f.UID) } // must delete children first, then the parent folder + s.log.InfoContext(ctx, "deleting folder with descendants", "org_id", cmd.OrgID, "uid", cmd.UID, "folderUIDs", strings.Join(folders, ",")) folders = append(folders, cmd.UID) if cmd.ForceDeleteRules { diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index 3952cc67687..0ff7a25b0e9 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -880,6 +880,7 @@ func TestIntegrationDeleteFoldersFromApiServer(t *testing.T) { registry: make(map[string]folder.RegistryService), features: featuremgmt.WithFeatures(), tracer: tracer, + log: slog.New(logtest.NewNopHandler(t)), } user := &user.SignedInUser{OrgID: 1} ctx := identity.WithRequester(context.Background(), user) diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 5dbfa4e945c..3e59f5c1b6f 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -278,3 +278,50 @@ type GetDescendantCountsQuery struct { } type DescendantCounts map[string]int64 + +// SortByPostorder returns the folders in postorder traversal order. +// That is, children folders appear before their parents in the returned slice. +func SortByPostorder(folders []*Folder) []*Folder { + if len(folders) == 0 { + return folders + } + + // Build parent-to-children map + tree := make(map[string][]*Folder) + folderMap := make(map[string]*Folder) + for _, f := range folders { + folderMap[f.UID] = f + tree[f.ParentUID] = append(tree[f.ParentUID], f) + } + + // Find all roots (folders whose parents are not in the result set) + var roots []*Folder + for _, f := range folders { + if folderMap[f.ParentUID] == nil { + roots = append(roots, f) + } + } + + // Traverse in postorder + result := make([]*Folder, 0, len(folders)) + visited := make(map[string]bool) + var traverse func(f *Folder) + traverse = func(f *Folder) { + if visited[f.UID] { + return + } + visited[f.UID] = true + // First visit all children + for _, child := range tree[f.UID] { + traverse(child) + } + // Then add current folder + result = append(result, f) + } + + for _, root := range roots { + traverse(root) + } + + return result +} diff --git a/pkg/services/folder/model_test.go b/pkg/services/folder/model_test.go new file mode 100644 index 00000000000..0fb747d2a6b --- /dev/null +++ b/pkg/services/folder/model_test.go @@ -0,0 +1,153 @@ +package folder + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFoldersSortByPostorder(t *testing.T) { + t.Run("empty list returns empty list", func(t *testing.T) { + var folders []*Folder + result := SortByPostorder(folders) + require.Empty(t, result) + }) + + t.Run("single folder returns single folder", func(t *testing.T) { + folders := []*Folder{ + {UID: "a", ParentUID: "root"}, + } + result := SortByPostorder(folders) + require.Len(t, result, 1) + require.Equal(t, "a", result[0].UID) + }) + + t.Run("linear hierarchy orders children before parents", func(t *testing.T) { + // Structure: root -> a -> b -> c + folders := []*Folder{ + {UID: "a", ParentUID: "root"}, + {UID: "c", ParentUID: "b"}, + {UID: "b", ParentUID: "a"}, + } + result := SortByPostorder(folders) + require.Len(t, result, 3) + // Postorder: c, b, a (children before parents) + require.Equal(t, "c", result[0].UID) + require.Equal(t, "b", result[1].UID) + require.Equal(t, "a", result[2].UID) + }) + + t.Run("branching hierarchy orders all children before their parent", func(t *testing.T) { + // Structure: + // root + // | + // a + // / | \ + // b c d + folders := []*Folder{ + {UID: "a", ParentUID: "root"}, + {UID: "b", ParentUID: "a"}, + {UID: "c", ParentUID: "a"}, + {UID: "d", ParentUID: "a"}, + } + result := SortByPostorder(folders) + require.Len(t, result, 4) + // 'a' must come after all its children (b, c, d) + aIndex := -1 + for i, f := range result { + if f.UID == "a" { + aIndex = i + break + } + } + require.Equal(t, 3, aIndex, "parent 'a' should be last") + // All children should come before parent + for i := 0; i < 3; i++ { + require.Contains(t, []string{"b", "c", "d"}, result[i].UID) + } + }) + + t.Run("deep hierarchy orders by depth", func(t *testing.T) { + // Structure: + // root + // | + // a + // | + // b + // / \ + // c d + // | + // e + folders := []*Folder{ + {UID: "a", ParentUID: "root"}, + {UID: "b", ParentUID: "a"}, + {UID: "c", ParentUID: "b"}, + {UID: "d", ParentUID: "b"}, + {UID: "e", ParentUID: "c"}, + } + result := SortByPostorder(folders) + require.Len(t, result, 5) + // e should come before c + eIndex := -1 + cIndex := -1 + for i, f := range result { + if f.UID == "e" { + eIndex = i + } + if f.UID == "c" { + cIndex = i + } + } + require.Less(t, eIndex, cIndex, "e should come before c") + // c and d should come before b + bIndex := -1 + dIndex := -1 + for i, f := range result { + if f.UID == "b" { + bIndex = i + } + if f.UID == "d" { + dIndex = i + } + } + require.Less(t, cIndex, bIndex, "c should come before b") + require.Less(t, dIndex, bIndex, "d should come before b") + // b should come before a + aIndex := -1 + for i, f := range result { + if f.UID == "a" { + aIndex = i + } + } + require.Less(t, bIndex, aIndex, "b should come before a") + }) + + t.Run("multiple subtrees maintains postorder per subtree", func(t *testing.T) { + // Structure: + // root1 root2 + // / \ | + // a b c + // | | + // d e + folders := []*Folder{ + {UID: "a", ParentUID: "root1"}, + {UID: "b", ParentUID: "root1"}, + {UID: "d", ParentUID: "b"}, + {UID: "c", ParentUID: "root2"}, + {UID: "e", ParentUID: "c"}, + } + result := SortByPostorder(folders) + require.Len(t, result, 5) + + // Find indices + indices := make(map[string]int) + for i, f := range result { + indices[f.UID] = i + } + + // Check postorder for first subtree: d before b, both before root1 + require.Less(t, indices["d"], indices["b"], "d should come before b") + // Check postorder for second subtree: e before c, both before root2 + require.Less(t, indices["e"], indices["c"], "e should come before c") + }) +} diff --git a/pkg/services/folder/store.go b/pkg/services/folder/store.go index 4ef49d912f4..949d8c4f0ac 100644 --- a/pkg/services/folder/store.go +++ b/pkg/services/folder/store.go @@ -46,7 +46,7 @@ type Store interface { // GetFolders returns folders with given uids GetFolders(ctx context.Context, q GetFoldersFromStoreQuery) ([]*Folder, error) - // GetDescendants returns all descendants of a folder + // GetDescendants returns all descendants of a folder (with no guaranteed order) GetDescendants(ctx context.Context, orgID int64, anchestor_uid string) ([]*Folder, error) // CountInOrg returns the number of folders in the given org diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 39e519f5632..43f5d3221ea 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -1262,8 +1262,7 @@ func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) { t.Skip("test only on sqlite for now") } - // test on all dualwriter modes - for mode := 0; mode <= 2; mode++ { + for mode := 0; mode <= 5; mode++ { t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, delete blocked by library elements)", grafanarest.DualWriterMode(mode)), func(t *testing.T) { modeDw := grafanarest.DualWriterMode(mode) @@ -1782,3 +1781,106 @@ func TestIntegrationMoveNestedFolderToRootK8S(t *testing.T) { require.Equal(t, "f2", get.Result.UID) require.Equal(t, "", get.Result.ParentUID) } + +// Test deleting nested folders ensures postorder deletion +func TestIntegrationDeleteNestedFoldersPostorder(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !db.IsTestDbSQLite() { + t.Skip("test only on sqlite for now") + } + + for mode := 0; mode <= 5; mode++ { + t.Run(fmt.Sprintf("Mode %d: Delete nested folder hierarchy in postorder", mode), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagUnifiedStorageSearch, + }, + }) + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + // Helper function to create a folder and return its UID and ParentUID + createFolder := func(title, uid, parentUid string) (string, string) { + payload := fmt.Sprintf(`{"title":"%s","uid":"%s"%s}`, title, uid, func() string { + if parentUid != "" { + return fmt.Sprintf(`,"parentUid":"%s"`, parentUid) + } + return "" + }()) + create := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + require.NotNil(t, create.Result) + require.Equal(t, http.StatusOK, create.Response.StatusCode) + return create.Result.UID, create.Result.ParentUID + } + + // Create a nested folder structure: + // parent + // / \ + // child1 child2 + // | + // grandchild + + // Create parent folder + parentUID, _ := createFolder(fmt.Sprintf("Parent-%d", mode), fmt.Sprintf("parent-%d", mode), "") + + // Create child1 folder + child1UID, child1ParentUID := createFolder(fmt.Sprintf("Child1-%d", mode), fmt.Sprintf("child1-%d", mode), parentUID) + require.Equal(t, parentUID, child1ParentUID) + + // Create child2 folder + child2UID, child2ParentUID := createFolder(fmt.Sprintf("Child2-%d", mode), fmt.Sprintf("child2-%d", mode), parentUID) + require.Equal(t, parentUID, child2ParentUID) + + // Create grandchild folder under child1 + grandchildUID, grandchildParentUID := createFolder(fmt.Sprintf("Grandchild-%d", mode), fmt.Sprintf("grandchild-%d", mode), child1UID) + require.Equal(t, child1UID, grandchildParentUID) + + // Verify the structure before deletion + verifyFolderExists := func(uid string, shouldExist bool) { + _, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + if shouldExist { + require.NoError(t, err, "folder %s should exist", uid) + } else { + require.Error(t, err, "folder %s should not exist", uid) + } + } + + // All folders should exist + verifyFolderExists(parentUID, true) + verifyFolderExists(child1UID, true) + verifyFolderExists(child2UID, true) + verifyFolderExists(grandchildUID, true) + + // Delete the parent folder - this should trigger postorder deletion + parentDelete := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodDelete, + Path: "/api/folders/" + parentUID, + }, &folder.Folder{}) + require.NotNil(t, parentDelete.Result) + require.Equal(t, http.StatusOK, parentDelete.Response.StatusCode) + + // All folders should now be deleted (postorder deletion: grandchild, child1, child2, parent) + verifyFolderExists(grandchildUID, false) + verifyFolderExists(child1UID, false) + verifyFolderExists(child2UID, false) + verifyFolderExists(parentUID, false) + }) + } +} From e3d73ddb815b39be3ce95f70963fad08f98d3d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 6 Nov 2025 15:12:00 +0100 Subject: [PATCH 055/209] Bump nanogit version with delta resolution fixes (#113516) * Bump nanogit version with delta fixes * Update workspace --- apps/iam/go.sum | 4 ++-- apps/provisioning/go.mod | 2 +- apps/provisioning/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- go.work.sum | 7 +++++++ 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/iam/go.sum b/apps/iam/go.sum index c23b88ab4f1..740bfd270dc 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -882,8 +882,8 @@ github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 h1:/5LKSYgLm github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000/go.mod h1:/ZklAgE1i4f3Z8uriXwESmCr1VLF8lBGaJspuaGuf78= github.com/grafana/loki/v3 v3.2.1 h1:VB7u+KHfvL5aHAxgoVBvz5wVhsdGuqKC7uuOFOOe7jw= github.com/grafana/loki/v3 v3.2.1/go.mod h1:WvdLl6wOS+yahaeQY+xhD2m2XzkHDfKr5FZaX7D/X2Y= -github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 h1:cS0SlJGIlZbmDLctNj5vIYGemrJDLy25wwoiIyZWVN8= -github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b h1:rFjoqJFb2KxJ29K9ltuWRSsdA46SbN0GCxoQc36h5kg= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index ad4bf7e3143..919602234e8 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -10,7 +10,7 @@ require ( github.com/grafana/grafana-app-sdk/logging v0.48.1 github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 - github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 + github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b github.com/migueleliasweb/go-github-mock v1.1.0 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.32.0 diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index a51e063c607..88f7288f7ee 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -70,8 +70,8 @@ github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f h1:f+Z github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f/go.mod h1:RA8mP8KVIwKXBx3Ssqa/uEBABib5LvUWYPVMxrNvnP0= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= -github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 h1:cS0SlJGIlZbmDLctNj5vIYGemrJDLy25wwoiIyZWVN8= -github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b h1:rFjoqJFb2KxJ29K9ltuWRSsdA46SbN0GCxoQc36h5kg= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= 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= diff --git a/go.mod b/go.mod index 9657b88efca..e8946ffb679 100644 --- a/go.mod +++ b/go.mod @@ -106,7 +106,7 @@ require ( github.com/grafana/grafana-plugin-sdk-go v0.281.0 // @grafana/plugins-platform-backend github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 // @grafana/alerting-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs - github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 // indirect; @grafana/grafana-git-ui-sync-team + github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b // indirect; @grafana/grafana-git-ui-sync-team github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling diff --git a/go.sum b/go.sum index 6e7d077b31f..0c29838863d 100644 --- a/go.sum +++ b/go.sum @@ -1661,8 +1661,8 @@ github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 h1:/5LKSYgLm github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000/go.mod h1:/ZklAgE1i4f3Z8uriXwESmCr1VLF8lBGaJspuaGuf78= github.com/grafana/loki/v3 v3.2.1 h1:VB7u+KHfvL5aHAxgoVBvz5wVhsdGuqKC7uuOFOOe7jw= github.com/grafana/loki/v3 v3.2.1/go.mod h1:WvdLl6wOS+yahaeQY+xhD2m2XzkHDfKr5FZaX7D/X2Y= -github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 h1:cS0SlJGIlZbmDLctNj5vIYGemrJDLy25wwoiIyZWVN8= -github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b h1:rFjoqJFb2KxJ29K9ltuWRSsdA46SbN0GCxoQc36h5kg= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= diff --git a/go.work.sum b/go.work.sum index f2e399c3e9b..9ff7017d84d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -275,6 +275,7 @@ github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.9.1/go.mod h1:Ny github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4= github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= @@ -1277,6 +1278,7 @@ github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636 h1:aSISeOcal5irEhJd1M+IrApc0PdcN7e7Aj4yuEnOrfQ= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= @@ -1308,6 +1310,7 @@ github.com/tdewolff/minify/v2 v2.12.8 h1:Q2BqOTmlMjoutkuD/OPCnJUpIqrzT3nRPkw+q+K github.com/tdewolff/minify/v2 v2.12.8/go.mod h1:YRgk7CC21LZnbuke2fmYnCTq+zhCgpb0yJACOTUNJ1E= github.com/tdewolff/parse/v2 v2.6.7 h1:WrFllrqmzAcrKHzoYgMupqgUBIfBVOb0yscFzDf8bBg= github.com/tdewolff/parse/v2 v2.6.7/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0 h1:gUZ25e1DVE/0+ZZ0nupsIo+C1j7UNloN7Pkg3w6tceI= github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0/go.mod h1:2Fc67EpyOEexLAF99zhSuzu9H22zd83pkjxEHHTtHf4= github.com/testcontainers/testcontainers-go/modules/mongodb v0.35.0 h1:i1Kh9fmXgHG9z3uzJv5Arz7pDKVaaNpLrqyd+0xhYMA= @@ -1409,6 +1412,7 @@ github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= @@ -1761,6 +1765,7 @@ golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= @@ -1840,6 +1845,7 @@ golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= @@ -1854,6 +1860,7 @@ golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c golang.org/x/tools v0.24.1/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= From ca5d89812015ef2db3acc62826f73650450b331e Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:26:18 +0100 Subject: [PATCH 056/209] SCIM: Upgrade the User.UID field to allow for the new scim- prefix (#113500) Upgrade the User.UID field to allow for the new scim- prefix --- pkg/services/sqlstore/migrations/user_mig.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index af9f0d235cd..46ef2442e7d 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -181,6 +181,18 @@ func addUserMigrations(mg *Migrator) { mg.AddMigration("Add index on user.is_service_account and user.last_seen_at", NewAddIndexMigration(userV2, &Index{ Cols: []string{"is_service_account", "last_seen_at"}, Type: IndexType, })) + + // Expand uid column to safely accommodate 'scim-' prefix without truncation/collisions + mg.AddMigration("Expand user.uid length to 190", NewRawSQLMigration(""). + SQLite("SELECT 1;"). + Postgres("ALTER TABLE `user` ALTER COLUMN uid TYPE VARCHAR(190);"). + Mysql("ALTER TABLE user MODIFY uid VARCHAR(190);")) + + // Prefix SCIM UID for provisioned users to avoid numeric/existing-id collisions + mg.AddMigration("Prefix SCIM uid for provisioned users", NewRawSQLMigration(""). + SQLite("UPDATE user SET uid = 'scim-' || uid WHERE is_provisioned = 1 AND uid NOT LIKE 'scim-%';"). + Postgres("UPDATE `user` SET uid = 'scim-' || uid WHERE is_provisioned = TRUE AND uid NOT LIKE 'scim-%';"). + Mysql("UPDATE user SET uid = CONCAT('scim-', uid) WHERE is_provisioned = 1 AND uid NOT LIKE 'scim-%';")) } const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0; From 95ffd1a55af4e7a62462d844dc8319bdb9ad9665 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 6 Nov 2025 15:31:02 +0100 Subject: [PATCH 057/209] LibraryPanel: Cleanup service calls (#113277) * cleanup * library panel via search * test cleanup * merge main * add FindDashboards mock * no matching dashbaords should return empty * do not alllow name and libraryPanel query --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 3 ++ pkg/api/dashboard.go | 7 --- pkg/api/dashboard_test.go | 21 -------- pkg/api/http_server.go | 2 - .../dashboard/legacysearcher/search_client.go | 48 +++++++++++-------- .../legacysearcher/search_client_test.go | 5 ++ pkg/registry/apis/dashboard/search.go | 18 +++++++ pkg/services/dashboards/database/database.go | 1 + .../dashboard.grafana.app-v0alpha1.json | 8 ++++ 9 files changed, 62 insertions(+), 51 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 4d23ca92514..ed3eac501b0 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -244,6 +244,7 @@ const injectedRtkApi = api folder: queryArg.folder, facet: queryArg.facet, tags: queryArg.tags, + libraryPanel: queryArg.libraryPanel, sort: queryArg.sort, limit: queryArg.limit, explain: queryArg.explain, @@ -608,6 +609,8 @@ export type GetSearchApiArg = { facet?: string[]; /** tag query filter */ tags?: string[]; + /** find dashboards that reference a given libraryPanel */ + libraryPanel?: string; /** sortable field */ sort?: string; /** number of results to return */ diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index aedd222d94b..7bbf48ab0f2 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -438,7 +438,6 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S } ctx = c.Req.Context() - var err error var userID int64 if id, err := identity.UserIdentifier(c.GetID()); err == nil { @@ -518,12 +517,6 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S return apierrors.ToDashboardErrorResponse(ctx, hs.pluginStore, saveErr) } - // connect library panels for this dashboard after the dashboard is stored and has an ID - err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(ctx, c.SignedInUser, dashboard) - if err != nil { - return response.Error(http.StatusInternalServerError, "Error while connecting library panels", err) - } - c.TimeRequest(metrics.MApiDashboardSave) return response.JSON(http.StatusOK, util.DynMap{ "status": "success", diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 7c47656e208..362be520230 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" @@ -39,7 +38,6 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" libraryelementsfake "github.com/grafana/grafana/pkg/services/libraryelements/fake" - "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/org" @@ -265,7 +263,6 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) { hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) hs.starService = startest.NewStarServiceFake() - hs.LibraryPanelService = &mockLibraryPanelService{} hs.LibraryElementService = &libraryelementsfake.LibraryElementService{} middleware := publicdashboards.NewFakePublicDashboardMiddleware(t) @@ -791,7 +788,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), QuotaService: quotatest.New(false, nil), - LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, DashboardService: dashboardService, SQLStore: dbtest.NewFakeDB(), @@ -853,7 +849,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { hs := &HTTPServer{ Cfg: setting.NewCfg(), ProvisioningService: fakeProvisioningService, - LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, dashboardProvisioningService: dashboardProvisioningService, SQLStore: mockSQLStore, @@ -887,7 +882,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { hs := &HTTPServer{ Cfg: setting.NewCfg(), ProvisioningService: fakeProvisioningService, - LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, dashboardProvisioningService: dashboardProvisioningService, SQLStore: mockSQLStore, @@ -928,7 +922,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) { hs := &HTTPServer{ Cfg: setting.NewCfg(), - LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, SQLStore: mockSQLStore, AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, @@ -1087,7 +1080,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s Live: newTestLive(t, db.InitTestDB(t)), QuotaService: quotatest.New(false, nil), pluginStore: &pluginstore.FakePluginStore{}, - LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, DashboardService: dashboardService, folderService: folderService, @@ -1127,7 +1119,6 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), QuotaService: quotatest.New(false, nil), - LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, DashboardService: mock, SQLStore: sqlStore, @@ -1177,15 +1168,3 @@ func (s mockDashboardProvisioningService) GetProvisionedDashboardDataByDashboard ) { return nil, nil } - -type mockLibraryPanelService struct{} - -var _ librarypanels.Service = (*mockLibraryPanelService)(nil) - -func (m *mockLibraryPanelService) ConnectLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error { - return nil -} - -func (m *mockLibraryPanelService) ImportLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error { - return nil -} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index d8b252ba406..2d3bd11ab37 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -164,7 +164,6 @@ type HTTPServer struct { LoggerMiddleware loggermw.Logger SQLStore db.DB AlertNG *ngalert.AlertNG - LibraryPanelService librarypanels.Service LibraryElementService libraryelements.Service SocialService social.Service Listener net.Listener @@ -319,7 +318,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi ContextHandler: contextHandler, LoggerMiddleware: loggerMiddleware, AlertNG: alertNG, - LibraryPanelService: libraryPanelService, LibraryElementService: libraryElementService, QuotaService: quotaService, tracer: tracer, diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index 24c49cc5624..f729822e599 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -229,12 +229,32 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso return nil, fmt.Errorf("only one repo name is supported") } query.ManagerIdentity = vals[0] + case unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE: if len(vals) != 1 { return nil, fmt.Errorf("only one library panel uid is supported") } - return c.getLibraryPanelConnections(ctx, user, vals[0], req.Options.Key.Namespace) + // Make sure the query does not include incompatible combinations + for _, f := range req.Options.Fields { + switch f.Key { + case resource.SEARCH_FIELD_NAME: + return nil, fmt.Errorf("libraryPanel query must not include explicit names") + } + } + + query.DashboardUIDs, err = c.getLibraryPanelConnections(ctx, user, vals[0]) + if err != nil { + return nil, err + } + if len(query.DashboardUIDs) == 0 { + // Empty results + return &resourcepb.ResourceSearchResponse{ + TotalHits: 0, + Results: &resourcepb.ResourceTable{}, + }, nil + } + case resource.SEARCH_FIELD_TITLE_PHRASE: if len(vals) != 1 { return nil, fmt.Errorf("only one title supported") @@ -362,32 +382,18 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string } } -// retrieves all the dashboards that are connected to the given library panel -func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID, namespace string) (*resourcepb.ResourceSearchResponse, error) { +// retrieves all dashboard UIDs connected to a given library panel +func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID string) ([]string, error) { connections, err := c.dashboardStore.GetDashboardsByLibraryPanelUID(ctx, libraryElementUID, user.GetOrgID()) if err != nil { return nil, err } - columns := c.getColumns("", &dashboards.FindPersistedDashboardsQuery{}) - list := &resourcepb.ResourceSearchResponse{ - Results: &resourcepb.ResourceTable{ - Columns: columns, - }, + uids := make([]string, len(connections)) + for i, dashboard := range connections { + uids[i] = dashboard.UID } - - for _, dashboard := range connections { - cells := c.createCommonCells("", dashboard.FolderUID, dashboard.ID, nil) // nolint:staticcheck - list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{ - Key: getResourceKey(&dashboards.DashboardSearchProjection{ - UID: dashboard.UID, - }, namespace), - Cells: cells, - }) - } - - list.TotalHits = int64(len(list.Results.Rows)) - return list, nil + return uids, nil } func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest, _ ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) { diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go index 489331db2b7..74f0cc2622c 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go @@ -581,6 +581,11 @@ func TestDashboardSearchClient_Search(t *testing.T) { {UID: "dashboard2", FolderUID: "folder2", ID: 2}, }, nil).Once() + mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{ + {UID: "dashboard1", FolderUID: "folder1", ID: 1}, + {UID: "dashboard2", FolderUID: "folder2", ID: 2}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 7dba6e1f8f1..d471cba752a 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -121,6 +121,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.ArrayProperty(spec.StringProperty()), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "libraryPanel", + In: "query", + Description: "find dashboards that reference a given libraryPanel", + Required: false, + Schema: spec.StringProperty(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "sort", @@ -363,6 +372,15 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { }} } + // The libraryPanel filter + if libraryPanel, ok := queryParams["libraryPanel"]; ok { + searchRequest.Options.Fields = []*resourcepb.Requirement{{ + Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE, + Operator: "=", + Values: libraryPanel, + }} + } + // The names filter names := queryParams["name"] diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 65ca0dd7ea0..ea37867d8c0 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -630,6 +630,7 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, {SQL: "DELETE FROM dashboard_version WHERE dashboard_id = ?", args: []any{dashboard.ID}}, {SQL: "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", args: []any{dashboard.ID}}, {SQL: "DELETE FROM dashboard_acl WHERE dashboard_id = ?", args: []any{dashboard.ID}}, + {SQL: "DELETE FROM library_element_connection WHERE connection_id = ?", args: []any{dashboard.ID}}, } if dashboard.IsFolder { diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index e53e6509bc1..42af6d54a98 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1812,6 +1812,14 @@ } } }, + { + "name": "libraryPanel", + "in": "query", + "description": "find dashboards that reference a given libraryPanel", + "schema": { + "type": "string" + } + }, { "name": "sort", "in": "query", From acb03207968c9c642964ccf10793b4af6150d79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Thu, 6 Nov 2025 15:34:32 +0100 Subject: [PATCH 058/209] datasources: apiserver: do not enable extra methods by default (#113395) --- pkg/registry/apis/datasource/sub_health.go | 14 +++++++ pkg/registry/apis/datasource/sub_resource.go | 15 +++++++ pkg/tests/apis/datasource/testdata_test.go | 41 +++++++++++++------- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/pkg/registry/apis/datasource/sub_health.go b/pkg/registry/apis/datasource/sub_health.go index 01a8ea82d9e..e80e355eb84 100644 --- a/pkg/registry/apis/datasource/sub_health.go +++ b/pkg/registry/apis/datasource/sub_health.go @@ -10,6 +10,8 @@ import ( "k8s.io/apiserver/pkg/registry/rest" datasource "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type subHealthREST struct { @@ -44,7 +46,19 @@ func (r *subHealthREST) NewConnectOptions() (runtime.Object, bool, string) { return nil, false, "" } +// FIXME: this endpoint has not been tested yet, so it is not enabled by default. +var healthEnabled = false + func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + if !healthEnabled { + return nil, &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusNotImplemented, + }, + } + } + pluginCtx, err := r.builder.getPluginContext(ctx, name) if err != nil { return nil, err diff --git a/pkg/registry/apis/datasource/sub_resource.go b/pkg/registry/apis/datasource/sub_resource.go index f6d3fc04b96..02caab6995a 100644 --- a/pkg/registry/apis/datasource/sub_resource.go +++ b/pkg/registry/apis/datasource/sub_resource.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" @@ -46,7 +47,21 @@ func (r *subResourceREST) NewConnectOptions() (runtime.Object, bool, string) { return nil, true, "" } +// FIXME: this endpoint has not been tested yet, so it is not enabled by default. +// It is especially important to make sure the `ClearAuthHeadersMiddleware` is active, +// when using this endpoint. +var resourceEnabled = false + func (r *subResourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + if !resourceEnabled { + return nil, &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusNotImplemented, + }, + } + } + pluginCtx, err := r.builder.getPluginContext(ctx, name) if err != nil { return nil, err diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go index 5faf459bba9..9a94bea5dce 100644 --- a/pkg/tests/apis/datasource/testdata_test.go +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -3,10 +3,12 @@ package dashboards import ( "context" "encoding/json" + "errors" "fmt" "testing" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -101,19 +103,26 @@ func TestIntegrationTestDatasource(t *testing.T) { require.Len(t, list.Items, 1, "expected a single connection") require.Equal(t, "test", list.Items[0].GetName(), "with the test uid") - rsp, err := client.Get(ctx, "test", metav1.GetOptions{}, "health") - require.NoError(t, err) - body, err := rsp.MarshalJSON() - require.NoError(t, err) - //fmt.Printf("GOT: %v\n", string(body)) - require.JSONEq(t, `{ - "apiVersion": "testdata.datasource.grafana.app/v0alpha1", - "code": 1, - "kind": "HealthCheckResult", - "message": "Data source is working", - "status": "OK" - } - `, string(body)) + _, err = client.Get(ctx, "test", metav1.GetOptions{}, "health") + // endpoint is disabled currently because it has not been + // sufficiently tested. + // for more info see pkg/registry/apis/datasource/sub_health.go + require.Error(t, err) + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, int32(501), statusErr.ErrStatus.Code) + // require.NoError(t, err) + // body, err := rsp.MarshalJSON() + // require.NoError(t, err) + // //fmt.Printf("GOT: %v\n", string(body)) + // require.JSONEq(t, `{ + // "apiVersion": "testdata.datasource.grafana.app/v0alpha1", + // "code": 1, + // "kind": "HealthCheckResult", + // "message": "Data source is working", + // "status": "OK" + // } + // `, string(body)) // Test connecting to non-JSON marshaled data raw := apis.DoRequest[any](helper, apis.RequestParams{ @@ -121,6 +130,10 @@ func TestIntegrationTestDatasource(t *testing.T) { Method: "GET", Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource", }, nil) - require.Equal(t, `Hello world from test datasource!`, string(raw.Body)) + // endpoint is disabled currently because it has not been + // sufficiently tested. + // for more info see pkg/registry/apis/datasource/sub_resource.go + require.Equal(t, int32(501), raw.Status.Code) + // require.Equal(t, `Hello world from test datasource!`, string(raw.Body)) }) } From 4430699f2de96aecdb07da3946752ebd5b57bb1c Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 6 Nov 2025 17:35:23 +0200 Subject: [PATCH 059/209] Provisioning: Update recent jobs (#113509) * Provisioning: refactor recent jobs * Re-render only on initial load * i18n * Refactor init loading * Update spinner * upd --- .../features/provisioning/Job/RecentJobs.tsx | 127 +++++++++++------- .../provisioning/utils/repositoryStatus.ts | 2 +- public/locales/en-US/grafana.json | 4 +- 3 files changed, 78 insertions(+), 55 deletions(-) diff --git a/public/app/features/provisioning/Job/RecentJobs.tsx b/public/app/features/provisioning/Job/RecentJobs.tsx index 660851f7003..b1491ea224a 100644 --- a/public/app/features/provisioning/Job/RecentJobs.tsx +++ b/public/app/features/provisioning/Job/RecentJobs.tsx @@ -1,13 +1,14 @@ -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { intervalToAbbreviatedDurationString, TraceKeyValuePair } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { Alert, Badge, Box, Card, InteractiveTable, Spinner, Stack, Text } from '@grafana/ui'; +import { Badge, Box, Card, InteractiveTable, Spinner, Stack, Text } from '@grafana/ui'; import { Job, Repository } from 'app/api/clients/provisioning/v0alpha1'; import KeyValuesTable from 'app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable'; import { ProvisioningAlert } from '../Shared/ProvisioningAlert'; import { useRepositoryAllJobs } from '../hooks/useRepositoryAllJobs'; +import { getErrorMessage } from '../utils/httpUtils'; import { getStatusColor } from '../utils/repositoryStatus'; import { formatTimestamp } from '../utils/time'; @@ -23,6 +24,21 @@ type JobCell = { }; }; +function formatJobDuration(job: Job): string | null { + const interval = { + start: job.status?.started ?? 0, + end: job.status?.finished ?? Date.now(), + }; + if (!interval.start) { + return null; + } + const elapsed = interval.end - interval.start; + if (elapsed < 1000) { + return `${elapsed}ms`; + } + return intervalToAbbreviatedDurationString(interval, true); +} + const getJobColumns = () => [ { id: 'jobId', @@ -53,20 +69,7 @@ const getJobColumns = () => [ { id: 'duration', header: t('provisioning.recent-jobs.column-duration', 'Duration'), - cell: ({ row: { original: job } }: JobCell) => { - const interval = { - start: job.status?.started ?? 0, - end: job.status?.finished ?? Date.now(), - }; - if (!interval.start) { - return null; - } - const elapsed = interval.end - interval.start; - if (elapsed < 1000) { - return `${elapsed}ms`; - } - return intervalToAbbreviatedDurationString(interval, true); - }, + cell: ({ row: { original: job } }: JobCell) => formatJobDuration(job), }, { id: 'message', @@ -105,6 +108,14 @@ function ExpandedRow({ row }: ExpandedRowProps) { return null; } + const state = row.status?.state; + const isValidState = state && ['success', 'warning', 'error'].includes(state); + const alertProps = isValidState + ? { + [state]: { message: row.status?.errors }, + } + : null; + return ( @@ -116,13 +127,13 @@ function ExpandedRow({ row }: ExpandedRowProps) { )} - {hasErrors && } - {hasSummary && ( + {alertProps && } + {hasSummary && row.status?.summary && ( Summary - + )} @@ -140,43 +151,55 @@ function EmptyState() { ); } -function ErrorLoading(typ: string, error: string) { - return ( - -
{JSON.stringify(error)}
-
- ); -} - -function Loading() { - return ( - - - - ); -} - export function RecentJobs({ repo }: Props) { - // TODO: Decide on whether we want to wait on historic jobs to show the current ones. - // Gut feeling is that current jobs are far more important to show than historic ones. const [jobs, activeQuery, historicQuery] = useRepositoryAllJobs({ repositoryName: repo.metadata?.name ?? 'x', }); const jobColumns = useMemo(() => getJobColumns(), []); + const hasLoadedDataRef = useRef(false); - let description: JSX.Element; - if (activeQuery.isLoading || historicQuery.isLoading) { - description = Loading(); - } else if (activeQuery.isError) { - description = ErrorLoading(t('provisioning.recent-jobs.active-jobs', 'active jobs'), activeQuery.error); - // TODO: Figure out what to do if historic fails. Maybe a separate card? - } else if (!jobs?.length) { - description = ; - } else { - description = ( + if (activeQuery.data || historicQuery.data) { + hasLoadedDataRef.current = true; + } + + const renderContent = () => { + const isInitialLoading = !hasLoadedDataRef.current && (activeQuery.isLoading || historicQuery.isLoading); + + if (isInitialLoading) { + return ( + + + + ); + } + + if (activeQuery.isError) { + return ( + + ); + } + + if (historicQuery.isError) { + return ( + + ); + } + + if (!jobs?.length) { + return ; + } + + return ( ); - } + }; return ( Jobs - {description} + {renderContent()} ); } diff --git a/public/app/features/provisioning/utils/repositoryStatus.ts b/public/app/features/provisioning/utils/repositoryStatus.ts index 9d899588506..f06ca35f182 100644 --- a/public/app/features/provisioning/utils/repositoryStatus.ts +++ b/public/app/features/provisioning/utils/repositoryStatus.ts @@ -29,9 +29,9 @@ export const getStatusIcon = (state?: SyncStatus['state']): IconName => { switch (state) { case 'success': return 'check'; - case 'working': case 'warning': return 'exclamation-triangle'; + case 'working': case 'pending': return 'spinner'; case 'error': diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 140f63f511e..554150ef77d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11729,14 +11729,14 @@ "read-only-local-tooltip": "This resource is read-only and provisioned through file provisioning. To make any changes, update the connected repository. To modify the settings go to Administration > Provisioning > Repositories.", "read-only-remote-tooltip": "This resource is read-only and provisioned through Git. To make any changes, update the connected repository. To modify the settings go to Administration > Provisioning > Repositories.", "recent-jobs": { - "active-jobs": "active jobs", "column-action": "Action", "column-duration": "Duration", "column-job-id": "Job ID", "column-message": "Message", "column-started": "Started", "column-status": "Status", - "error-loading": "Error loading {{type}}", + "error-loading-active-jobs": "Error loading active jobs", + "error-loading-historic-jobs": "Error loading historic jobs", "jobs": "Jobs" }, "repository-actions": { From 6746207c367f35b673c33593a8285f2cad65ec0e Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 6 Nov 2025 16:43:42 +0100 Subject: [PATCH 060/209] Alerting: Fix url rule form parsing (#113513) Fix alertRuleFormSchema to properly handle query model objects --- .../unified/rule-editor/formDefaults.test.ts | 284 +++++++++++++++++- .../alerting/alertRuleFormSchema.test.ts | 168 +++++++++++ .../alerting/alertRuleFormSchema.ts | 70 +---- 3 files changed, 457 insertions(+), 65 deletions(-) create mode 100644 public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.test.ts diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts index 9bfe9d4e593..9c86955bbfb 100644 --- a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts @@ -13,8 +13,13 @@ jest.mock('@grafana/runtime', () => ({ getDataSourceSrv: jest.fn(), })); -import { formValuesFromQueryParams, getDefaultFormValues, getDefautManualRouting } from './formDefaults'; -import { isAlertQueryOfAlertData } from './formProcessing'; +import { + formValuesFromPrefill, + formValuesFromQueryParams, + getDefaultFormValues, + getDefautManualRouting, +} from './formDefaults'; +import { isAlertQueryOfAlertData, isExpressionQueryInAlert } from './formProcessing'; jest.mock('../utils/datasource', () => ({ ...jest.requireActual('../utils/datasource'), @@ -292,3 +297,278 @@ describe('getDefaultFormValues', () => { expect(mockGetInstanceSettings).not.toHaveBeenCalled(); }); }); + +describe('formValuesFromPrefill', () => { + it('should preserve threshold expression query structure', () => { + const prefillData = { + folder: { uid: 'test-folder', title: 'Test Folder' }, + group: 'test-group', + queries: [ + { + refId: 'A', + datasourceUid: 'gdev-prometheus', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + model: { + datasource: { type: 'prometheus', uid: 'gdev-prometheus' }, + editorMode: 'code', + exemplar: false, + expr: 'sum by (handler) (rate(grafana_http_request_duration_seconds_count[5h]))', + format: 'time_series', + instant: true, + intervalMs: 1000, + legendFormat: '__auto', + maxDataPoints: 43200, + range: false, + refId: 'A', + }, + }, + { + refId: 'C', + datasourceUid: '__expr__', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + model: { + conditions: [ + { + evaluator: { params: [0.0001, 0], type: 'gt' }, + operator: { type: 'and' }, + query: { params: [] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ], + datasource: { name: 'Expression', type: '__expr__', uid: '__expr__' }, + expression: 'A', + intervalMs: 1000, + maxDataPoints: 43200, + refId: 'C', + type: 'threshold', + }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + + const queryC = result.queries.find((q) => q.refId === 'C'); + expect(queryC?.model).toHaveProperty('type', 'threshold'); + expect(queryC?.model).toHaveProperty('conditions'); + expect(queryC?.model).toHaveProperty('expression', 'A'); + expect(queryC?.model.datasource).toEqual({ name: 'Expression', type: '__expr__', uid: '__expr__' }); + + // Should NOT have these defaults added + expect(queryC?.model).not.toHaveProperty('instant'); + expect(queryC?.model).not.toHaveProperty('range'); + }); + + it('should preserve reduce expression query structure', () => { + const prefillData = { + queries: [ + { + refId: 'B', + datasourceUid: '-100', + queryType: '', + relativeTimeRange: { from: 0, to: 0 }, + model: { + expression: 'A', + intervalMs: 1000, + maxDataPoints: 100, + reducer: 'mean', + refId: 'B', + type: 'reduce', + }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + const query = result.queries[0]; + + expect(query.model).toHaveProperty('type', 'reduce'); + expect(query.model).toHaveProperty('reducer', 'mean'); + expect(query.model).toHaveProperty('expression', 'A'); + expect(query.model).not.toHaveProperty('instant'); + expect(query.model).not.toHaveProperty('range'); + }); + + it('should preserve math expression query structure', () => { + const prefillData = { + queries: [ + { + refId: 'C', + datasourceUid: '-100', + queryType: '', + relativeTimeRange: { from: 0, to: 0 }, + model: { + conditions: [ + { + evaluator: { params: [0, 0], type: 'gt' }, + operator: { type: 'and' }, + query: { params: ['B'] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ], + datasource: { name: 'Expression', type: '__expr__', uid: '__expr__' }, + expression: '$B > 0.4', + intervalMs: 1000, + maxDataPoints: 43200, + refId: 'C', + type: 'math', + }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + const query = result.queries[0]; + + expect(query.model).toHaveProperty('type', 'math'); + expect(query.model).toHaveProperty('expression', '$B > 0.4'); + expect(query.model).toHaveProperty('conditions'); + }); + + it('should preserve classic_conditions expression query structure', () => { + const prefillData = { + queries: [ + { + refId: 'B', + datasourceUid: '-100', + queryType: '', + relativeTimeRange: { from: 0, to: 0 }, + model: { + conditions: [ + { + evaluator: { params: [10], type: 'gt' }, + operator: { type: 'and' }, + query: { params: ['A'] }, + reducer: { params: [], type: 'last' }, + type: 'query', + }, + { + evaluator: { params: [5, 15], type: 'within_range' }, + operator: { type: 'and' }, + query: { params: ['A'] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ], + datasource: { type: '__expr__', uid: '-100' }, + expression: 'A', + intervalMs: 1000, + maxDataPoints: 43200, + refId: 'B', + type: 'classic_conditions', + }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + const [query] = result.queries.filter(isExpressionQueryInAlert); + + expect(query.model).toHaveProperty('type', 'classic_conditions'); + expect(query.model.conditions).toHaveLength(2); + expect(query.model.conditions?.[0].evaluator.type).toBe('gt'); + expect(query.model.conditions?.[1].evaluator.type).toBe('within_range'); + }); + + it('should preserve resample expression query structure', () => { + const prefillData = { + queries: [ + { + refId: 'D', + datasourceUid: '-100', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + model: { + conditions: [ + { + evaluator: { params: [0, 0], type: 'gt' }, + operator: { type: 'and' }, + query: { params: [] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ], + datasource: { name: 'Expression', type: '__expr__', uid: '__expr__' }, + downsampler: 'min', + expression: 'A', + intervalMs: 1000, + maxDataPoints: 43200, + refId: 'D', + type: 'resample', + upsampler: 'backfilling', + window: '2m', + }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + const query = result.queries[0]; + + expect(query.model).toHaveProperty('type', 'resample'); + expect(query.model).toHaveProperty('downsampler', 'min'); + expect(query.model).toHaveProperty('upsampler', 'backfilling'); + expect(query.model).toHaveProperty('window', '2m'); + }); + + it('should preserve Prometheus query fields', () => { + const prefillData = { + queries: [ + { + refId: 'A', + datasourceUid: 'gdev-prometheus', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + model: { + datasource: { type: 'prometheus', uid: 'gdev-prometheus' }, + editorMode: 'code', + exemplar: false, + expr: 'rate(promhttp_metric_handler_requests_total{}[15m])', + instant: true, + intervalMs: 1000, + legendFormat: '__auto', + maxDataPoints: 43200, + range: false, + refId: 'A', + }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + const [query] = result.queries.filter(isAlertQueryOfAlertData); + + expect(query.model).toHaveProperty('expr', 'rate(promhttp_metric_handler_requests_total{}[15m])'); + expect(query.model).toHaveProperty('editorMode', 'code'); + expect(query.model).toHaveProperty('exemplar', false); + expect(query.model).toHaveProperty('legendFormat', '__auto'); + expect(query.model.instant).toBe(true); + expect(query.model.range).toBe(false); + }); + + it('should not add default values to query models', () => { + const prefillData = { + queries: [ + { + refId: 'A', + datasourceUid: 'test-uid', + queryType: '', + model: { refId: 'A' }, + }, + ], + }; + + const result = formValuesFromPrefill(prefillData); + const query = result.queries[0]; + + // Should NOT have defaults added + expect(query.model).not.toHaveProperty('instant'); + expect(query.model).not.toHaveProperty('range'); + expect(query.model).not.toHaveProperty('expression'); + expect(query.model).not.toHaveProperty('queryType'); + }); +}); diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.test.ts b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.test.ts new file mode 100644 index 00000000000..59e02a1ab92 --- /dev/null +++ b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.test.ts @@ -0,0 +1,168 @@ +import { alertingAlertRuleFormSchema, alertingModelSchema } from './alertRuleFormSchema'; + +describe('alertingModelSchema', () => { + it('should allow threshold expression model', () => { + const model = { + conditions: [ + { + evaluator: { params: [0.0001, 0], type: 'gt' }, + operator: { type: 'and' }, + query: { params: [] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ], + datasource: { name: 'Expression', type: '__expr__', uid: '__expr__' }, + expression: 'A', + intervalMs: 1000, + maxDataPoints: 43200, + refId: 'C', + type: 'threshold', + }; + + const result = alertingModelSchema.parse(model); + expect(result.type).toBe('threshold'); + expect(result.conditions).toBeDefined(); + }); + + it('should allow reduce expression model', () => { + const model = { + expression: 'A', + intervalMs: 1000, + maxDataPoints: 100, + reducer: 'mean', + refId: 'B', + type: 'reduce', + }; + + const result = alertingModelSchema.parse(model); + expect(result.type).toBe('reduce'); + expect(result.reducer).toBe('mean'); + }); + + it('should allow math expression model', () => { + const model = { + expression: '$B > 0.4', + intervalMs: 1000, + maxDataPoints: 43200, + refId: 'C', + type: 'math', + }; + + const result = alertingModelSchema.parse(model); + expect(result.type).toBe('math'); + expect(result.expression).toBe('$B > 0.4'); + }); + + it('should allow Prometheus query model', () => { + const model = { + datasource: { type: 'prometheus', uid: 'gdev-prometheus' }, + editorMode: 'code', + expr: 'up', + instant: true, + range: false, + refId: 'A', + }; + + const result = alertingModelSchema.parse(model); + expect(result.expr).toBe('up'); + expect(result.instant).toBe(true); + }); + + it('should not add default values', () => { + const model = { refId: 'A' }; + + const result = alertingModelSchema.parse(model); + expect(result).not.toHaveProperty('instant'); + expect(result).not.toHaveProperty('range'); + expect(result).not.toHaveProperty('queryType'); + expect(result).not.toHaveProperty('expression'); + }); +}); + +describe('alertingAlertRuleFormSchema', () => { + it('should validate alert rule with mixed query types', () => { + const alertRule = { + folder: { uid: 'folder-uid', title: 'Test Folder' }, + group: 'test-group', + queries: [ + { + refId: 'A', + datasourceUid: 'gdev-prometheus', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + model: { + datasource: { type: 'prometheus', uid: 'gdev-prometheus' }, + expr: 'up', + instant: true, + range: false, + refId: 'A', + }, + }, + { + refId: 'B', + datasourceUid: '__expr__', + queryType: '', + model: { + expression: 'A', + reducer: 'mean', + refId: 'B', + type: 'reduce', + }, + }, + { + refId: 'C', + datasourceUid: '__expr__', + queryType: '', + model: { + conditions: [ + { + evaluator: { params: [0], type: 'gt' }, + operator: { type: 'and' }, + query: { params: [] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ], + datasource: { type: '__expr__', uid: '__expr__' }, + expression: 'B', + refId: 'C', + type: 'threshold', + }, + }, + ], + condition: 'C', + }; + + const result = alertingAlertRuleFormSchema.parse(alertRule); + const queries = result.queries || []; + expect(queries[0].model.expr).toBe('up'); + expect(queries[1].model.type).toBe('reduce'); + expect(queries[2].model.type).toBe('threshold'); + }); + + it('should handle minimal payload from group details page', () => { + const minimalPayload = { + folder: { uid: 'folder-uid', title: 'Alpha squad' }, + group: 'alpha_squad_api_service_rules', + }; + + const result = alertingAlertRuleFormSchema.parse(minimalPayload); + expect(result.folder?.uid).toBe('folder-uid'); + expect(result.folder?.title).toBe('Alpha squad'); + expect(result.group).toBe('alpha_squad_api_service_rules'); + + // Verify no defaults are added for optional fields that weren't provided + expect(result.name).toBeUndefined(); + expect(result.condition).toBeUndefined(); + expect(result.noDataState).toBeUndefined(); + expect(result.execErrState).toBeUndefined(); + expect(result.evaluateEvery).toBeUndefined(); + expect(result.evaluateFor).toBeUndefined(); + expect(result.keepFiringFor).toBeUndefined(); + expect(result.metric).toBeUndefined(); + expect(result.targetDatasourceUid).toBeUndefined(); + expect(result.returnTo).toBeUndefined(); + expect(result.annotations).toBeUndefined(); + }); +}); diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts index 4470c195544..bcfe4c7b8d1 100644 --- a/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts +++ b/public/app/features/plugins/components/restrictedGrafanaApis/alerting/alertRuleFormSchema.ts @@ -1,68 +1,14 @@ import { z } from 'zod'; -import alertDef from 'app/features/alerting/state/alertDef'; import { RuleFormType } from 'app/features/alerting/unified/types/rule-form'; -import { ExpressionQueryType } from 'app/features/expressions/types'; import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; -// Schema for __expr__ type queries (reduce, threshold, etc.) -export const exprQuerySchema = z.object({ - refId: z.string().describe('Reference ID for the query, e.g., "B", "C", etc.'), - type: z.enum(ExpressionQueryType).describe('Expression type'), - datasource: z.object({ - uid: z.literal('__expr__').describe('Must be "__expr__" for expression queries'), - type: z.literal('__expr__').describe('Must be "__expr__" for expression queries'), - }), - conditions: z - .array( - z.object({ - type: z.string().describe('Condition type, e.g., "query"'), - evaluator: z.object({ - params: z.array(z.any()).describe('Parameters for the evaluator'), - type: z.enum(alertDef.evalFunctions.map((ef) => ef.value)).describe('Evaluator type'), - }), - operator: z.object({ - type: z.enum(alertDef.evalOperators.map((eo) => eo.value)).describe('Operator type'), - }), - query: z.object({ - params: z.array(z.string()).describe('Query parameters, typically the refId to evaluate'), - }), - reducer: z.object({ - params: z.array(z.any()).describe('Parameters for the reducer'), - type: z.string().describe('Reducer type, e.g., "last", "avg", "sum", "count", "min", "max"'), - }), - }) - ) - .optional() - .describe('Conditions for the expression query'), - reducer: z.string().optional().describe('Reducer function, e.g., "last", "avg", "sum"'), - expression: z.string().optional().describe('Expression referencing other queries, e.g., "A"'), - math: z.string().optional().describe('Math expression for math type queries'), -}); - -// Schema for regular datasource queries -export const alertingQuerySchema = z.object({ - refId: z.string().describe('Reference ID for the query, e.g., "A", "B", etc.'), - queryType: z.string().optional().default('alerting').describe('Type of query (e.g., "alerting", "recording")'), - expression: z - .string() - .optional() - .default('') - .describe('Query expression to be executed. This can not include variables (e.g. $var).'), - instant: z.boolean().optional().default(true).describe('Whether the query is an instant query'), - range: z - .boolean() - .optional() - .default(false) - .describe('Whether the query is a range query, should be false if instant is true'), - datasource: z.object({ - type: z.string().optional().describe('Datasource type or "__expr__" when it is an expression query'), - uid: z.string().optional().describe('Datasource UID'), - }), -}); - // Combined schema that supports both regular and expression queries -export const alertingModelSchema = z.union([alertingQuerySchema, exprQuerySchema]); +export const alertingModelSchema = z.looseObject({ + refId: z.string(), + maxDataPoints: z.number().optional().describe('Maximum number of data points to return'), + intervalMs: z.number().optional().describe('Interval in milliseconds'), +}); // Main navigate to alert form schema - merged from both alertingSchemaApi and formDefaults export const alertingAlertRuleFormSchema = z.object({ @@ -112,11 +58,11 @@ export const alertingAlertRuleFormSchema = z.object({ .array( z.object({ refId: z.string().describe('Reference ID for the query (e.g., "A", "B", "C")'), - queryType: z.string().optional().default('instant').describe('Type of query (e.g., "instant")'), + queryType: z.string().default('').describe('Datasource-specific'), relativeTimeRange: z .object({ from: z.number().describe('Relative time from in seconds (e.g., 3600 for 1 hour)'), - to: z.number().default(0).describe('Relative time to in seconds (usually 0 for "now")'), + to: z.number().describe('Relative time to in seconds (usually 0 for "now")'), }) .optional(), datasourceUid: z.string().describe('Datasource UID for the query'), @@ -193,8 +139,6 @@ export const alertingAlertRuleFormSchema = z.object({ // Export types for use in plugins export type AlertingAlertRuleFormSchemaType = z.infer; -export type AlertingQuerySchemaType = z.infer; -export type ExprQuerySchemaType = z.infer; export type AlertingModelSchemaType = z.infer; // Simple API that only exposes the navigate to alert rule form schema From 0ed742cad7a83bf4f7abdc30259fe1d6b3c5f523 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Thu, 6 Nov 2025 10:44:46 -0500 Subject: [PATCH 061/209] FilesView: Git sync repository -> Files list remove size column, added unit tests (#113425) * FilesView: remove size column, added unit tests --- .../provisioning/File/FilesView.test.tsx | 149 ++++++++++++++++++ .../features/provisioning/File/FilesView.tsx | 9 -- 2 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 public/app/features/provisioning/File/FilesView.test.tsx diff --git a/public/app/features/provisioning/File/FilesView.test.tsx b/public/app/features/provisioning/File/FilesView.test.tsx new file mode 100644 index 00000000000..8f3412188b1 --- /dev/null +++ b/public/app/features/provisioning/File/FilesView.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, waitFor } from 'test/test-utils'; + +import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1'; + +import { FilesView } from './FilesView'; + +jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ + useGetRepositoryFilesQuery: jest.fn(), +})); + +const mockUseGetRepositoryFilesQuery = jest.mocked(useGetRepositoryFilesQuery); +type RepositoryFilesQueryResult = ReturnType; + +const baseQueryResult = (): RepositoryFilesQueryResult => + ({ + currentData: undefined, + data: { items: [] }, + endpointName: 'getRepositoryFiles', + error: undefined, + fulfilledTimeStamp: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + originalArgs: { name: '' }, + refetch: jest.fn(), + requestId: 'test-request', + startedTimeStamp: 0, + status: 'uninitialized', + subscriptionOptions: undefined, + unsubscribe: jest.fn(), + }) satisfies RepositoryFilesQueryResult; + +const mockRepositoryFilesQuery = (overrides: Partial = {}) => { + mockUseGetRepositoryFilesQuery.mockReturnValue({ + ...baseQueryResult(), + ...overrides, + }); +}; + +const defaultRepository: Repository = { + metadata: { name: 'test-repo' }, + spec: { + title: 'Test repository', + type: 'github', + workflows: ['write'], + sync: { enabled: true, target: 'folder' }, + github: { branch: 'main' }, + }, +}; + +const localRepository: Repository = { + metadata: { name: 'local-repo' }, + spec: { + title: 'Local repository', + type: 'local', + workflows: [], + sync: { enabled: true, target: 'folder' }, + local: {}, + }, +}; + +const renderComponent = (repo: Repository = defaultRepository) => { + return render(); +}; + +describe('FilesView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders spinner while loading', () => { + mockRepositoryFilesQuery({ isLoading: true, status: 'pending', data: undefined }); + + renderComponent(); + + expect(screen.getByTestId('Spinner')).toBeInTheDocument(); + }); + + it('renders file rows with view and history links when data is available', () => { + mockRepositoryFilesQuery({ + isSuccess: true, + status: 'fulfilled', + data: { + items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }], + }, + }); + + renderComponent(); + + const viewLink = screen.getByRole('link', { name: 'View' }); + expect(viewLink).toHaveAttribute('href', '/admin/provisioning/test-repo/file/dashboards/example.json'); + + const historyLink = screen.getByRole('link', { name: 'History' }); + expect(historyLink).toHaveAttribute( + 'href', + '/admin/provisioning/test-repo/history/dashboards/example.json?repo_type=github' + ); + }); + + it('filters files using search input', async () => { + const mockItems = [ + { path: 'dashboards/example.json', hash: 'abc', size: '10' }, + { path: 'dashboards/other.yaml', hash: 'def', size: '20' }, + ]; + + mockRepositoryFilesQuery({ + isSuccess: true, + status: 'fulfilled', + data: { + items: mockItems, + }, + }); + + const { user } = renderComponent(); + + expect(screen.getAllByRole('row')).toHaveLength( + // +1 for the header row + mockItems.length + 1 + ); + + const input = screen.getByPlaceholderText('Search'); + await user.clear(input); + await user.type(input, 'other'); + + await waitFor(() => + expect(screen.getAllByRole('row')).toHaveLength( + // +1 for the header row + 2 + ) + ); + expect(screen.getByText('dashboards/other.yaml')).toBeInTheDocument(); + }); + + it('hides history link when repository type is not supported', () => { + mockRepositoryFilesQuery({ + isSuccess: true, + status: 'fulfilled', + data: { + items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }], + }, + }); + + renderComponent(localRepository); + + expect(screen.getByRole('link', { name: 'View' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/provisioning/File/FilesView.tsx b/public/app/features/provisioning/File/FilesView.tsx index 37a55b9bca5..031784c0d20 100644 --- a/public/app/features/provisioning/File/FilesView.tsx +++ b/public/app/features/provisioning/File/FilesView.tsx @@ -34,15 +34,6 @@ export function FilesView({ repo }: FilesViewProps) { return {path}; }, }, - { - id: 'size', - header: 'Size (KB)', - cell: ({ row: { original } }: FileCell<'size'>) => { - const { size } = original; - return (parseInt(size, 10) / 1024).toFixed(2); - }, - sortType: 'number', - }, { id: 'hash', header: 'Hash', From 2e0cf9bb612c1c6af1560a6e352d8856f993d4f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irene=20Rodr=C3=ADguez?= Date: Thu, 6 Nov 2025 17:02:50 +0100 Subject: [PATCH 062/209] Update grafanacli-workflows.md with command link (#113527) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../observability-as-code/grafana-cli/grafanacli-workflows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md b/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md index 33489050eed..e0c0d0fe51a 100644 --- a/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md +++ b/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md @@ -18,7 +18,7 @@ weight: 300 # Manage resources with Grafana CLI {{< admonition type="note" >}} -`grafanactl` is under active development. Command-line flags and subcommands described here may change. This document outlines the target workflows the tool is expected to support. +`grafanactl` is under active development. Command-line flags and subcommands described here may change. This document outlines the target workflows the tool is expected to support. You can find a full list of supported commands [in this page](https://grafana.github.io/grafanactl/reference/cli/grafanactl/). {{< /admonition >}} ## Migrate resources between environments From 7df35822371c6aff629dfcc3cae3661fa6f17234 Mon Sep 17 00:00:00 2001 From: Mihai Turdean <6640685+mihai-turdean@users.noreply.github.com> Date: Thu, 6 Nov 2025 09:06:42 -0700 Subject: [PATCH 063/209] Authz: Implement Query operation for Zanzana with folder parent retrieval (#113483) --- .../dualwrite/collectors_test.go | 8 + pkg/services/authz/proto/v1/extention.pb.go | 429 +++++++++++++++--- pkg/services/authz/proto/v1/extention.proto | 28 ++ .../authz/proto/v1/extention_grpc.pb.go | 38 ++ pkg/services/authz/zanzana/client.go | 1 + pkg/services/authz/zanzana/client/client.go | 7 + pkg/services/authz/zanzana/client/noop.go | 4 + .../authz/zanzana/server/server_query.go | 91 ++++ .../server/server_query_folder_test.go | 133 ++++++ .../authz/zanzana/server/server_test.go | 4 + 10 files changed, 690 insertions(+), 53 deletions(-) create mode 100644 pkg/services/authz/zanzana/server/server_query.go create mode 100644 pkg/services/authz/zanzana/server/server_query_folder_test.go diff --git a/pkg/services/accesscontrol/dualwrite/collectors_test.go b/pkg/services/accesscontrol/dualwrite/collectors_test.go index 32e021c4b1c..aef03185330 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors_test.go +++ b/pkg/services/accesscontrol/dualwrite/collectors_test.go @@ -246,6 +246,14 @@ func (m *mockZanzanaClient) Mutate(ctx context.Context, req *authzextv1.MutateRe return nil } +func (m *mockZanzanaClient) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*authzextv1.QueryResponse), args.Error(1) +} + func TestIntegrationTeamMembershipCollector(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/services/authz/proto/v1/extention.pb.go b/pkg/services/authz/proto/v1/extention.pb.go index 0cc4cb36a16..03a44978db9 100644 --- a/pkg/services/authz/proto/v1/extention.pb.go +++ b/pkg/services/authz/proto/v1/extention.pb.go @@ -1621,6 +1621,280 @@ func (x *BatchCheckGroupResource) GetItems() map[string]bool { return nil } +type QueryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Operation *QueryOperation `protobuf:"bytes,2,opt,name=operation,proto3" json:"operation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryRequest) Reset() { + *x = QueryRequest{} + mi := &file_extention_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryRequest) ProtoMessage() {} + +func (x *QueryRequest) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. +func (*QueryRequest) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{27} +} + +func (x *QueryRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *QueryRequest) GetOperation() *QueryOperation { + if x != nil { + return x.Operation + } + return nil +} + +type QueryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Result: + // + // *QueryResponse_FolderParents + Result isQueryResponse_Result `protobuf_oneof:"result"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryResponse) Reset() { + *x = QueryResponse{} + mi := &file_extention_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryResponse) ProtoMessage() {} + +func (x *QueryResponse) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead. +func (*QueryResponse) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{28} +} + +func (x *QueryResponse) GetResult() isQueryResponse_Result { + if x != nil { + return x.Result + } + return nil +} + +func (x *QueryResponse) GetFolderParents() *GetFolderParentsResult { + if x != nil { + if x, ok := x.Result.(*QueryResponse_FolderParents); ok { + return x.FolderParents + } + } + return nil +} + +type isQueryResponse_Result interface { + isQueryResponse_Result() +} + +type QueryResponse_FolderParents struct { + FolderParents *GetFolderParentsResult `protobuf:"bytes,1,opt,name=folder_parents,json=folderParents,proto3,oneof"` +} + +func (*QueryResponse_FolderParents) isQueryResponse_Result() {} + +type QueryOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Operation: + // + // *QueryOperation_GetFolderParents + Operation isQueryOperation_Operation `protobuf_oneof:"operation"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryOperation) Reset() { + *x = QueryOperation{} + mi := &file_extention_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOperation) ProtoMessage() {} + +func (x *QueryOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOperation.ProtoReflect.Descriptor instead. +func (*QueryOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{29} +} + +func (x *QueryOperation) GetOperation() isQueryOperation_Operation { + if x != nil { + return x.Operation + } + return nil +} + +func (x *QueryOperation) GetGetFolderParents() *GetFolderParentsQuery { + if x != nil { + if x, ok := x.Operation.(*QueryOperation_GetFolderParents); ok { + return x.GetFolderParents + } + } + return nil +} + +type isQueryOperation_Operation interface { + isQueryOperation_Operation() +} + +type QueryOperation_GetFolderParents struct { + GetFolderParents *GetFolderParentsQuery `protobuf:"bytes,1,opt,name=get_folder_parents,json=getFolderParents,proto3,oneof"` +} + +func (*QueryOperation_GetFolderParents) isQueryOperation_Operation() {} + +type GetFolderParentsQuery struct { + state protoimpl.MessageState `protogen:"open.v1"` + // UID of the folder + Folder string `protobuf:"bytes,1,opt,name=folder,proto3" json:"folder,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetFolderParentsQuery) Reset() { + *x = GetFolderParentsQuery{} + mi := &file_extention_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetFolderParentsQuery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFolderParentsQuery) ProtoMessage() {} + +func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFolderParentsQuery.ProtoReflect.Descriptor instead. +func (*GetFolderParentsQuery) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{30} +} + +func (x *GetFolderParentsQuery) GetFolder() string { + if x != nil { + return x.Folder + } + return "" +} + +type GetFolderParentsResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // List of parent folder UIDs + ParentUids []string `protobuf:"bytes,1,rep,name=parent_uids,json=parentUids,proto3" json:"parent_uids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetFolderParentsResult) Reset() { + *x = GetFolderParentsResult{} + mi := &file_extention_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetFolderParentsResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFolderParentsResult) ProtoMessage() {} + +func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFolderParentsResult.ProtoReflect.Descriptor instead. +func (*GetFolderParentsResult) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{31} +} + +func (x *GetFolderParentsResult) GetParentUids() []string { + if x != nil { + return x.ParentUids + } + return nil +} + var File_extention_proto protoreflect.FileDescriptor var file_extention_proto_rawDesc = string([]byte{ @@ -1861,33 +2135,66 @@ var file_extention_proto_rawDesc = string([]byte{ 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x32, 0xde, 0x02, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, - 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, - 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, - 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, - 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, - 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, - 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, - 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, - 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, - 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, - 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x38, 0x01, 0x22, 0x6e, 0x0a, 0x0c, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x12, 0x40, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x22, 0x6e, 0x0a, 0x0d, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, + 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66, 0x6f, 0x6c, 0x64, 0x65, + 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x22, 0x78, 0x0a, 0x0e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x59, 0x0a, 0x12, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, + 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x51, 0x75, 0x65, 0x72, 0x79, 0x48, 0x00, 0x52, 0x10, 0x67, + 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, + 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x2f, 0x0a, 0x15, + 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x39, 0x0a, + 0x16, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x61, 0x72, 0x65, 0x6e, + 0x74, 0x5f, 0x75, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x61, + 0x72, 0x65, 0x6e, 0x74, 0x55, 0x69, 0x64, 0x73, 0x32, 0xac, 0x03, 0x0a, 0x15, 0x41, 0x75, 0x74, + 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, + 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, + 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, + 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x51, 0x75, 0x65, + 0x72, 0x79, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, + 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, + 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -1902,7 +2209,7 @@ func file_extention_proto_rawDescGZIP() []byte { return file_extention_proto_rawDescData } -var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 34) var file_extention_proto_goTypes = []any{ (*MutateRequest)(nil), // 0: authz.extention.v1.MutateRequest (*MutateResponse)(nil), // 1: authz.extention.v1.MutateResponse @@ -1931,11 +2238,16 @@ var file_extention_proto_goTypes = []any{ (*BatchCheckItem)(nil), // 24: authz.extention.v1.BatchCheckItem (*BatchCheckResponse)(nil), // 25: authz.extention.v1.BatchCheckResponse (*BatchCheckGroupResource)(nil), // 26: authz.extention.v1.BatchCheckGroupResource - nil, // 27: authz.extention.v1.BatchCheckResponse.GroupsEntry - nil, // 28: authz.extention.v1.BatchCheckGroupResource.ItemsEntry - (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 30: google.protobuf.Struct - (*wrapperspb.Int32Value)(nil), // 31: google.protobuf.Int32Value + (*QueryRequest)(nil), // 27: authz.extention.v1.QueryRequest + (*QueryResponse)(nil), // 28: authz.extention.v1.QueryResponse + (*QueryOperation)(nil), // 29: authz.extention.v1.QueryOperation + (*GetFolderParentsQuery)(nil), // 30: authz.extention.v1.GetFolderParentsQuery + (*GetFolderParentsResult)(nil), // 31: authz.extention.v1.GetFolderParentsResult + nil, // 32: authz.extention.v1.BatchCheckResponse.GroupsEntry + nil, // 33: authz.extention.v1.BatchCheckGroupResource.ItemsEntry + (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 35: google.protobuf.Struct + (*wrapperspb.Int32Value)(nil), // 36: google.protobuf.Int32Value } var file_extention_proto_depIdxs = []int32{ 2, // 0: authz.extention.v1.MutateRequest.operations:type_name -> authz.extention.v1.MutateOperation @@ -1952,32 +2264,37 @@ var file_extention_proto_depIdxs = []int32{ 11, // 11: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission 15, // 12: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition 12, // 13: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey - 29, // 14: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp - 30, // 15: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct + 34, // 14: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp + 35, // 15: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct 17, // 16: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey - 31, // 17: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value + 36, // 17: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value 13, // 18: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple 12, // 19: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey 14, // 20: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition 19, // 21: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites 20, // 22: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes 24, // 23: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem - 27, // 24: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry - 28, // 25: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry - 26, // 26: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource - 23, // 27: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest - 16, // 28: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest - 21, // 29: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest - 0, // 30: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest - 25, // 31: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse - 18, // 32: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse - 22, // 33: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse - 1, // 34: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse - 31, // [31:35] is the sub-list for method output_type - 27, // [27:31] is the sub-list for method input_type - 27, // [27:27] is the sub-list for extension type_name - 27, // [27:27] is the sub-list for extension extendee - 0, // [0:27] is the sub-list for field type_name + 32, // 24: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry + 33, // 25: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry + 29, // 26: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation + 31, // 27: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult + 30, // 28: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery + 26, // 29: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource + 23, // 30: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest + 16, // 31: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest + 21, // 32: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest + 0, // 33: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest + 27, // 34: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest + 25, // 35: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse + 18, // 36: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse + 22, // 37: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse + 1, // 38: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse + 28, // 39: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse + 35, // [35:40] is the sub-list for method output_type + 30, // [30:35] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name } func init() { file_extention_proto_init() } @@ -1994,13 +2311,19 @@ func file_extention_proto_init() { (*MutateOperation_DeleteUserOrgRole)(nil), (*MutateOperation_AddUserOrgRole)(nil), } + file_extention_proto_msgTypes[28].OneofWrappers = []any{ + (*QueryResponse_FolderParents)(nil), + } + file_extention_proto_msgTypes[29].OneofWrappers = []any{ + (*QueryOperation_GetFolderParents)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extention_proto_rawDesc), len(file_extention_proto_rawDesc)), NumEnums: 0, - NumMessages: 29, + NumMessages: 34, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/authz/proto/v1/extention.proto b/pkg/services/authz/proto/v1/extention.proto index 37a05a5d75c..2a11b8c5b97 100644 --- a/pkg/services/authz/proto/v1/extention.proto +++ b/pkg/services/authz/proto/v1/extention.proto @@ -15,6 +15,7 @@ service AuthzExtentionService { rpc Write(WriteRequest) returns (WriteResponse); rpc Mutate(MutateRequest) returns (MutateResponse); + rpc Query(QueryRequest) returns (QueryResponse); } message MutateRequest { @@ -183,3 +184,30 @@ message BatchCheckResponse { message BatchCheckGroupResource { map items = 1; } + +message QueryRequest { + string namespace = 1; + QueryOperation operation = 2; +} + +message QueryResponse { + oneof result { + GetFolderParentsResult folder_parents = 1; + } +} + +message QueryOperation { + oneof operation { + GetFolderParentsQuery get_folder_parents = 1; + } +} + +message GetFolderParentsQuery { + // UID of the folder + string folder = 1; +} + +message GetFolderParentsResult { + // List of parent folder UIDs + repeated string parent_uids = 1; +} diff --git a/pkg/services/authz/proto/v1/extention_grpc.pb.go b/pkg/services/authz/proto/v1/extention_grpc.pb.go index f83b14c1c8d..b320dbe8c07 100644 --- a/pkg/services/authz/proto/v1/extention_grpc.pb.go +++ b/pkg/services/authz/proto/v1/extention_grpc.pb.go @@ -23,6 +23,7 @@ const ( AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read" AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write" AuthzExtentionService_Mutate_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Mutate" + AuthzExtentionService_Query_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Query" ) // AuthzExtentionServiceClient is the client API for AuthzExtentionService service. @@ -33,6 +34,7 @@ type AuthzExtentionServiceClient interface { Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) Write(ctx context.Context, in *WriteRequest, opts ...grpc.CallOption) (*WriteResponse, error) Mutate(ctx context.Context, in *MutateRequest, opts ...grpc.CallOption) (*MutateResponse, error) + Query(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*QueryResponse, error) } type authzExtentionServiceClient struct { @@ -83,6 +85,16 @@ func (c *authzExtentionServiceClient) Mutate(ctx context.Context, in *MutateRequ return out, nil } +func (c *authzExtentionServiceClient) Query(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*QueryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(QueryResponse) + err := c.cc.Invoke(ctx, AuthzExtentionService_Query_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AuthzExtentionServiceServer is the server API for AuthzExtentionService service. // All implementations should embed UnimplementedAuthzExtentionServiceServer // for forward compatibility @@ -91,6 +103,7 @@ type AuthzExtentionServiceServer interface { Read(context.Context, *ReadRequest) (*ReadResponse, error) Write(context.Context, *WriteRequest) (*WriteResponse, error) Mutate(context.Context, *MutateRequest) (*MutateResponse, error) + Query(context.Context, *QueryRequest) (*QueryResponse, error) } // UnimplementedAuthzExtentionServiceServer should be embedded to have forward compatible implementations. @@ -109,6 +122,9 @@ func (UnimplementedAuthzExtentionServiceServer) Write(context.Context, *WriteReq func (UnimplementedAuthzExtentionServiceServer) Mutate(context.Context, *MutateRequest) (*MutateResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented") } +func (UnimplementedAuthzExtentionServiceServer) Query(context.Context, *QueryRequest) (*QueryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Query not implemented") +} // UnsafeAuthzExtentionServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to AuthzExtentionServiceServer will @@ -193,6 +209,24 @@ func _AuthzExtentionService_Mutate_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _AuthzExtentionService_Query_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthzExtentionServiceServer).Query(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthzExtentionService_Query_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthzExtentionServiceServer).Query(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AuthzExtentionService_ServiceDesc is the grpc.ServiceDesc for AuthzExtentionService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -216,6 +250,10 @@ var AuthzExtentionService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Mutate", Handler: _AuthzExtentionService_Mutate_Handler, }, + { + MethodName: "Query", + Handler: _AuthzExtentionService_Query_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "extention.proto", diff --git a/pkg/services/authz/zanzana/client.go b/pkg/services/authz/zanzana/client.go index a95ead698cb..f238fd27159 100644 --- a/pkg/services/authz/zanzana/client.go +++ b/pkg/services/authz/zanzana/client.go @@ -16,4 +16,5 @@ type Client interface { BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error + Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) } diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 68809a84fa8..3c51d707561 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -90,3 +90,10 @@ func (c *Client) Mutate(ctx context.Context, req *authzextv1.MutateRequest) erro _, err := c.authzext.Mutate(ctx, req) return err } + +func (c *Client) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Query") + defer span.End() + + return c.authzext.Query(ctx, req) +} diff --git a/pkg/services/authz/zanzana/client/noop.go b/pkg/services/authz/zanzana/client/noop.go index d0397740b5e..73860cd0c4e 100644 --- a/pkg/services/authz/zanzana/client/noop.go +++ b/pkg/services/authz/zanzana/client/noop.go @@ -41,3 +41,7 @@ func (nc NoopClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckR func (nc NoopClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error { return nil } + +func (nc NoopClient) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + return nil, nil +} diff --git a/pkg/services/authz/zanzana/server/server_query.go b/pkg/services/authz/zanzana/server/server_query.go new file mode 100644 index 00000000000..596c3805a5a --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_query.go @@ -0,0 +1,91 @@ +package server + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +func (s *Server) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + ctx, span := s.tracer.Start(ctx, "server.Query") + defer span.End() + + defer func(t time.Time) { + s.metrics.requestDurationSeconds.WithLabelValues("server.Query", req.GetNamespace()).Observe(time.Since(t).Seconds()) + }(time.Now()) + + res, err := s.query(ctx, req) + if err != nil { + s.logger.Error("failed to perform query request", "error", err, "namespace", req.GetNamespace()) + return nil, errors.New("failed to perform query request") + } + + return res, nil +} + +func (s *Server) query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil { + return nil, err + } + + storeInf, err := s.getStoreInfo(ctx, req.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get openfga store: %w", err) + } + + if req.Operation == nil { + return nil, errors.New("operation cannot be nil") + } + + switch op := req.Operation.Operation.(type) { + case *authzextv1.QueryOperation_GetFolderParents: + return s.queryFolderParents(ctx, storeInf, op.GetFolderParents) + default: + return nil, errors.New("unsupported query operation type") + } +} + +func (s *Server) queryFolderParents(ctx context.Context, store *storeInfo, req *authzextv1.GetFolderParentsQuery) (*authzextv1.QueryResponse, error) { + ctx, span := s.tracer.Start(ctx, "server.queryFolderParents") + defer span.End() + + if req.GetFolder() == "" { + return nil, errors.New("folder UID cannot be empty") + } + + // Get raw tuples from OpenFGA + tuples, err := s.listFolderParents(ctx, store, req.GetFolder()) + if err != nil { + return nil, fmt.Errorf("failed to list folder parents: %w", err) + } + + // Extract parent UIDs from tuples (business logic now server-side) + parentUIDs := make([]string, 0, len(tuples)) + for _, tuple := range tuples { + // Extract UID from format "folder:UID" or "folder:UID#relation" + userParts := strings.Split(tuple.Key.User, ":") + if len(userParts) != 2 { + return nil, fmt.Errorf("invalid user format: %s, expected format: folder:UID or folder:UID#relation", tuple.Key.User) + } + + // Remove any relation part after # + uidAndRelationParts := strings.Split(userParts[1], "#") + if len(uidAndRelationParts) == 0 { + return nil, fmt.Errorf("invalid user format: %s, expected format: folder:UID or folder:UID#relation", tuple.Key.User) + } + + parentUIDs = append(parentUIDs, uidAndRelationParts[0]) + } + + return &authzextv1.QueryResponse{ + Result: &authzextv1.QueryResponse_FolderParents{ + FolderParents: &authzextv1.GetFolderParentsResult{ + ParentUids: parentUIDs, + }, + }, + }, nil +} diff --git a/pkg/services/authz/zanzana/server/server_query_folder_test.go b/pkg/services/authz/zanzana/server/server_query_folder_test.go new file mode 100644 index 00000000000..5e9284a59df --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_query_folder_test.go @@ -0,0 +1,133 @@ +package server + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func setupFolders() []*openfgav1.TupleKey { + // seed tuples with a folder hierarchy: + // folder 1 (root) + // └── folder 11 + // ├── folder 111 + // └── folder 112 + // └── folder 12 + return []*openfgav1.TupleKey{ + common.NewFolderParentTuple("11", "1"), + common.NewFolderParentTuple("12", "1"), + common.NewFolderParentTuple("111", "11"), + common.NewFolderParentTuple("112", "11"), + } +} + +func setupQueryFolders(t *testing.T, srv *Server) *Server { + t.Helper() + + tuples := []*openfgav1.TupleKey{} + tuples = append(tuples, setupFolders()...) + + return setupOpenFGADatabase(t, srv, tuples) +} + +func testQueryFolders(t *testing.T, srv *Server) { + setupQueryFolders(t, srv) + + t.Run("should query folder parents successfully", func(t *testing.T) { + res, err := srv.Query(newContextWithNamespace(), &v1.QueryRequest{ + Namespace: "default", + Operation: &v1.QueryOperation{ + Operation: &v1.QueryOperation_GetFolderParents{ + GetFolderParents: &v1.GetFolderParentsQuery{ + Folder: "11", + }, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.GetFolderParents()) + require.Len(t, res.GetFolderParents().ParentUids, 1) + require.Equal(t, "1", res.GetFolderParents().ParentUids[0]) + }) + + t.Run("should query nested folder parents successfully", func(t *testing.T) { + res, err := srv.Query(newContextWithNamespace(), &v1.QueryRequest{ + Namespace: "default", + Operation: &v1.QueryOperation{ + Operation: &v1.QueryOperation_GetFolderParents{ + GetFolderParents: &v1.GetFolderParentsQuery{ + Folder: "111", + }, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.GetFolderParents()) + require.Len(t, res.GetFolderParents().ParentUids, 1) + require.Equal(t, "11", res.GetFolderParents().ParentUids[0]) + }) + + t.Run("should return empty list for folder with no parents", func(t *testing.T) { + res, err := srv.Query(newContextWithNamespace(), &v1.QueryRequest{ + Namespace: "default", + Operation: &v1.QueryOperation{ + Operation: &v1.QueryOperation_GetFolderParents{ + GetFolderParents: &v1.GetFolderParentsQuery{ + Folder: "1", + }, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.GetFolderParents()) + require.Len(t, res.GetFolderParents().ParentUids, 0) + }) + + t.Run("should return empty list for non-existent folder", func(t *testing.T) { + res, err := srv.Query(newContextWithNamespace(), &v1.QueryRequest{ + Namespace: "default", + Operation: &v1.QueryOperation{ + Operation: &v1.QueryOperation_GetFolderParents{ + GetFolderParents: &v1.GetFolderParentsQuery{ + Folder: "non-existent", + }, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.GetFolderParents()) + require.Len(t, res.GetFolderParents().ParentUids, 0) + }) + + t.Run("should return error for empty folder UID", func(t *testing.T) { + _, err := srv.Query(newContextWithNamespace(), &v1.QueryRequest{ + Namespace: "default", + Operation: &v1.QueryOperation{ + Operation: &v1.QueryOperation_GetFolderParents{ + GetFolderParents: &v1.GetFolderParentsQuery{ + Folder: "", + }, + }, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to perform query request") + }) + + t.Run("should return error for nil operation", func(t *testing.T) { + _, err := srv.Query(newContextWithNamespace(), &v1.QueryRequest{ + Namespace: "default", + Operation: nil, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to perform query request") + }) +} diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 720ff37e486..df4f90cf85f 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -132,6 +132,10 @@ func TestIntegrationServer(t *testing.T) { t.Run("test mutate org roles", func(t *testing.T) { testMutateOrgRoles(t, srv) }) + + t.Run("test query folders", func(t *testing.T) { + testQueryFolders(t, srv) + }) } func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { From 720dfb65be62bd3e581d79cb2df8d28caa49a3bb Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 6 Nov 2025 17:39:27 +0100 Subject: [PATCH 064/209] Alerting: Alerts page performance improvements (#113391) * Add IntersectionObserver to render rule viz panel only when visible * Move data transformations to dataTransform file * Add tests for data transform * Use a new time series transformation algorithm * Reduce the number of time series data conversion * Memoize small components * Update tests * Remove some comments * Use Box component for styling. Remove unnecessary effect dependency --- .../alerting/unified/triage/Workbench.tsx | 15 +- .../unified/triage/rows/AlertRuleRow.tsx | 17 +- .../unified/triage/rows/GenericRow.tsx | 8 +- .../unified/triage/rows/InstanceRow.tsx | 16 +- .../triage/rows/OpenDrawerIconButton.tsx | 24 + .../unified/triage/scene/AlertRuleSummary.tsx | 65 +- .../unified/triage/scene/Workbench.tsx | 163 ++-- .../triage/scene/dataTransform.test.ts | 826 ++++++++++++++++++ .../unified/triage/scene/dataTransform.ts | 157 ++++ 9 files changed, 1151 insertions(+), 140 deletions(-) create mode 100644 public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx create mode 100644 public/app/features/alerting/unified/triage/scene/dataTransform.test.ts create mode 100644 public/app/features/alerting/unified/triage/scene/dataTransform.ts diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx index 1d28aa3e285..66c6ccfec5b 100644 --- a/public/app/features/alerting/unified/triage/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -27,6 +27,7 @@ type WorkbenchProps = { groupBy?: string[]; filterBy?: Filter[]; queryRunner: SceneQueryRunner; + isLoading?: boolean; hasActiveFilters?: boolean; }; @@ -115,13 +116,19 @@ function renderWorkbenchRow( │ │││ │ │ │ │ │ │ │ │ │ - │ │ │ │ - └─────────────────────────┘ └───────────────────────────────────┘ +│ │ │ │ +└─────────────────────────┘ └───────────────────────────────────┘ */ -export function Workbench({ domain, data, queryRunner, groupBy, hasActiveFilters = false }: WorkbenchProps) { +export function Workbench({ + domain, + data, + queryRunner, + groupBy, + isLoading = false, + hasActiveFilters = false, +}: WorkbenchProps) { const styles = useStyles2(getStyles); - const isLoading = !queryRunner.isDataReadyToDisplay(); const [pageIndex, setPageIndex] = useState(1); // Calculate once: show folder metadata only if not grouping by grafana_folder diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx index 965e9b5ce31..3e1a33b26aa 100644 --- a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { t } from '@grafana/i18n'; -import { IconButton, Stack, Text } from '@grafana/ui'; +import { Stack, Text } from '@grafana/ui'; import { MetaText } from '../../components/MetaText'; import { RuleDetailsDrawer } from '../rule-details/RuleDetailsDrawer'; @@ -10,6 +10,7 @@ import { AlertRuleSummary } from '../scene/AlertRuleSummary'; import { AlertRuleRow as AlertRuleRowType } from '../types'; import { GenericRow } from './GenericRow'; +import { OpenDrawerIconButton } from './OpenDrawerIconButton'; interface AlertRuleRowProps { row: AlertRuleRowType; @@ -29,13 +30,13 @@ export const AlertRuleRow = ({ const { ruleUID, folder, title } = row.metadata; const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const handleDrawerOpen = () => { + const handleDrawerOpen = useCallback(() => { setIsDrawerOpen(true); - }; + }, []); - const handleDrawerClose = () => { + const handleDrawerClose = useCallback(() => { setIsDrawerOpen(false); - }; + }, []); return ( <> @@ -44,9 +45,7 @@ export const AlertRuleRow = ({ width={leftColumnWidth} title={{title}} actions={ - diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx index b2a158f40e3..1588520fb48 100644 --- a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -54,7 +54,7 @@ export const GenericRow = ({ />
-
+
{content &&
{content}
}
@@ -79,7 +79,7 @@ const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onTog {onToggle && ( onToggle()} + onClick={onToggle} className={styles.dropdownIcon} variant="secondary" size="md" @@ -113,6 +113,10 @@ export const getStyles = (theme: GrafanaTheme2) => { leftColumn: css({ overflow: 'hidden', }), + rightColumnWrapper: css({ + minWidth: 'min-content', + flexGrow: 1, + }), columnContent: (depth?: number) => css({ padding: 5, diff --git a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx index 6bff83f9b20..a639d1c8efb 100644 --- a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { AlertLabels } from '@grafana/alerting/unstable'; import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data'; @@ -11,7 +11,6 @@ import { GraphDrawStyle, VisibilityMode } from '@grafana/schema'; import { AxisPlacement, BarAlignment, - IconButton, LegendDisplayMode, StackingMode, Text, @@ -23,6 +22,7 @@ import { overrideToFixedColor } from '../../home/Insights'; import { InstanceDetailsDrawer } from '../instance-details/InstanceDetailsDrawer'; import { GenericRow } from './GenericRow'; +import { OpenDrawerIconButton } from './OpenDrawerIconButton'; interface Instance { labels: Labels; @@ -75,13 +75,13 @@ export function InstanceRow({ const styles = useStyles2(getStyles); const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const handleDrawerOpen = () => { + const handleDrawerOpen = useCallback(() => { setIsDrawerOpen(true); - }; + }, []); - const handleDrawerClose = () => { + const handleDrawerClose = useCallback(() => { setIsDrawerOpen(false); - }; + }, []); const dataProvider = useMemo( () => @@ -117,9 +117,7 @@ export function InstanceRow({ ) } actions={ - diff --git a/public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx b/public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx new file mode 100644 index 00000000000..2a7e4df50eb --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/OpenDrawerIconButton.tsx @@ -0,0 +1,24 @@ +import { css } from '@emotion/css'; +import { memo } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { IconButton, useStyles2 } from '@grafana/ui'; + +interface OpenDrawerIconButtonProps { + onClick: () => void; + ['aria-label']: string; +} + +export const OpenDrawerIconButton = memo(function OpenDrawerIconButton({ + onClick, + ['aria-label']: ariaLabel, +}: OpenDrawerIconButtonProps) { + const styles = useStyles2(getStyles); + return ; +}); + +const getStyles = (theme: GrafanaTheme2) => ({ + iconButton: css({ + transform: 'rotate(180deg)', + }), +}); diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx index ac9cb28ef82..632c8ee453c 100644 --- a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef, useState } from 'react'; + import { VizConfigBuilders } from '@grafana/scenes'; import { VizPanel, useDataTransformer } from '@grafana/scenes-react'; import { @@ -9,6 +11,7 @@ import { TooltipDisplayMode, VisibilityMode, } from '@grafana/schema'; +import { Box } from '@grafana/ui'; import { overrideToFixedColor } from '../../home/Insights'; import { useWorkbenchContext } from '../WorkbenchContext'; @@ -41,8 +44,12 @@ export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries() ) .build(); -export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { - // Use WorkbenchContext to access the parent query runner and reuse its data +/** + * Component that contains the expensive hooks (queryRunner and data transformer). + * This component only renders when AlertRuleSummary determines the viewport is approaching, + * avoiding expensive CPU operations until necessary. + */ +function AlertRuleSummaryViz({ ruleUID }: { ruleUID: string }) { const { queryRunner } = useWorkbenchContext(); // Transform parent data to filter by this specific rule and partition by alert state @@ -91,3 +98,57 @@ export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { /> ); } + +/** + * Lazy-loaded component that uses Intersection Observer to only render the Viz component + * when the row is approaching the viewport. This prevents expensive CPU operations + * (queryRunner and data transformer) from running until necessary. + */ +export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) { + const [isVisible, setIsVisible] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + // Check if Intersection Observer is supported + if (!window.IntersectionObserver) { + // Fallback: render immediately if Intersection Observer is not supported + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + // Process only the last entry (most recent state) + const entry = entries.at(-1); + if (entry) { + setIsVisible(entry.isIntersecting); + } + }, + { + rootMargin: '100px', // Start loading when element is 100px away from viewport + } + ); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, []); + + return ( + + {isVisible ? ( + + ) : ( + // Placeholder while not visible - maintains layout space + + )} + + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx index 7df0b354c13..2c0c1431892 100644 --- a/public/app/features/alerting/unified/triage/scene/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -1,14 +1,12 @@ -import { isEmpty } from 'lodash'; -import { ArrayValues } from 'type-fest'; +import { useEffect, useState, useTransition } from 'react'; -import { DataFrame, PanelData } from '@grafana/data'; -import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { SceneObjectBase, SceneObjectState, sceneGraph, sceneUtils } 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, EmptyLabelValue, GenericGroupedRow, WorkbenchRow } from '../types'; +import { convertToWorkbenchRows } from './dataTransform'; import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils'; export class WorkbenchSceneObject extends SceneObjectBase { @@ -20,7 +18,6 @@ export function WorkbenchRenderer() { const domain = convertTimeRangeToDomain(timeRange); const [groupByKeys = []] = useVariableValues(VARIABLES.groupBy); - const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(','); const queryFilter = useQueryFilter(); @@ -32,124 +29,62 @@ export function WorkbenchRenderer() { ], }); const { data } = runner.useState(); - const rows = data ? convertToWorkbenchRows(data, groupByKeys) : []; + const [rows, setRows] = useState>([]); + const [isPending, startTransition] = useTransition(); const hasFiltersApplied = queryFilter.length > 0; + // convertToWorkbenchRows is expensive when processing large datasets. + // We use runner.subscribeToState() instead of runner.useState() to transform data + // only when it actually changes. Using useState() triggers 2-3 unnecessary calls + // to convertToWorkbenchRows per update, even when wrapped in useMemo. + // Subscribe to runner state changes and transform data + useEffect(() => { + const transformData = (newState: typeof runner.state) => { + if (newState.data?.state !== 'Done' || !newState.data?.series) { + return; + } + + // Get the groupBy from the scene directly to avoid having groupByVariable in the dependency array + let currentGroupByKeys: string[] = []; + const groupByVariable = sceneGraph.lookupVariable(VARIABLES.groupBy, runner); + + if (groupByVariable && sceneUtils.isGroupByVariable(groupByVariable)) { + const value = groupByVariable.getValue(); + if (Array.isArray(value)) { + currentGroupByKeys = value.map((value) => String(value)); + } + } + + const { series } = newState.data; + // Use transition for non-blocking update + startTransition(() => { + setRows(convertToWorkbenchRows(series, currentGroupByKeys)); + }); + }; + + // Subscribe to state changes + const subscription = runner.subscribeToState((newState, prevState) => { + // Only transform if data actually changed + if (newState.data !== prevState.data) { + transformData(newState); + } + }); + + return () => subscription.unsubscribe(); + }, [runner]); + + const isDataLoading = data?.state === 'Loading'; + const isLoading = isDataLoading || isPending; + 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 mapKey = dp[groupByKey] ?? EmptyLabelValue; - if (!grouped.has(mapKey)) { - grouped.set(mapKey, []); - } - grouped.get(mapKey)?.push(dp); - } - - const result: GenericGroupedRow[] = []; - const emptyGroups: GenericGroupedRow[] = []; - - for (const [value, rows] of grouped.entries()) { - const labelValue = isEmpty(value) ? EmptyLabelValue : value; - - const group: GenericGroupedRow = { - type: 'group', - metadata: { - label: groupByKey, - value: labelValue, - }, - rows: groupData(rows, groupBy, depth + 1), - }; - - // Separate empty label groups to append at the end - if (group.metadata.value === EmptyLabelValue) { - emptyGroups.push(group); - } else { - result.push(group); - } - } - - return [...result, ...emptyGroups]; -} - -// @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/dataTransform.test.ts b/public/app/features/alerting/unified/triage/scene/dataTransform.test.ts new file mode 100644 index 00000000000..8bbef2a70d6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/dataTransform.test.ts @@ -0,0 +1,826 @@ +import { FieldType } from '@grafana/data'; + +import { EmptyLabelValue } from '../types'; + +import { convertToWorkbenchRows } from './dataTransform'; + +/** + * convertToWorkbenchRows transforms time series alert instance data into a hierarchical structure of alert rules. + * + * Input: DataFrame[] containing alert instances (firing/pending alerts) over time. + * Each alert instance includes metadata linking it to its parent alert rule via grafana_rule_uid. + * + * Output: A hierarchical structure where: + * - Multiple alert instances from the same rule are aggregated into a single alert rule row + * - Alert rules can be grouped by label values (team, severity, etc.) + * - Empty label values are placed at the end of each group level + */ +describe('convertToWorkbenchRows', () => { + describe('empty and invalid data handling', () => { + it('should return empty array when series is empty', () => { + const result = convertToWorkbenchRows([]); + + expect(result).toEqual([]); + }); + + it('should return empty array when series has no fields', () => { + const result = convertToWorkbenchRows([ + { + fields: [], + length: 0, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing required Time field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing required alertname field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing grafana_folder field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing grafana_rule_uid field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array when frame is missing alertstate field', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['folder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid'], config: {} }, + ], + length: 1, + }, + ]); + + expect(result).toEqual([]); + }); + }); + + describe('no grouping - flat alert rule list', () => { + it('should aggregate alert instances into flat list of alert rules when no groupBy is provided', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2', 'Alert1'], config: {} }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder1'], + config: {}, + }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2', 'uid1'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending', 'firing'], config: {} }, + ], + length: 3, + }, + ]); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ]); + }); + + it('should return flat list when groupBy is empty array', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['TestAlert'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['TestFolder'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['test-uid'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing'], config: {} }, + ], + length: 1, + }, + ], + [] + ); + + expect(result).toEqual([ + { + type: 'alertRule', + metadata: { + title: 'TestAlert', + folder: 'TestFolder', + ruleUID: 'test-uid', + }, + }, + ]); + }); + + it('should aggregate multiple alert instances from the same rule into a single alert rule', () => { + const result = convertToWorkbenchRows([ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert1', 'Alert2', 'Alert1'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder1', 'Folder2', 'Folder1'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid1', 'uid2', 'uid1'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'firing'], + config: {}, + }, + ], + length: 4, + }, + ]); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ]); + }); + }); + + describe('single-level grouping', () => { + it('should group by single field', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2', 'Alert3'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2', 'Folder1'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2', 'uid3'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending', 'firing'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', 'frontend', 'backend'], config: {} }, + ], + length: 3, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: 'backend', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert3', + folder: 'Folder1', + ruleUID: 'uid3', + }, + }, + ], + }); + expect(result[1]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: 'frontend', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + }); + + it('should handle empty label values and place them at the end', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert2', 'Alert3', 'Alert4'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3', 'Folder4'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid2', 'uid3', 'uid4'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { name: 'team', type: FieldType.string, values: ['backend', '', 'frontend', ''], config: {} }, + ], + length: 4, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('group'); + expect(result[1].type).toBe('group'); + expect(result[2].type).toBe('group'); + if (result[0].type === 'group') { + expect(result[0].metadata.value).toBe('backend'); + } + if (result[1].type === 'group') { + expect(result[1].metadata.value).toBe('frontend'); + } + if (result[2].type === 'group') { + expect(result[2].metadata.value).toBe(EmptyLabelValue); + } + }); + + it('should handle undefined label values and place them at the end', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2', 'Alert3'], config: {} }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3'], + config: {}, + }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2', 'uid3'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending', 'firing'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', undefined, 'frontend'], config: {} }, + ], + length: 3, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('group'); + expect(result[1].type).toBe('group'); + expect(result[2].type).toBe('group'); + if (result[0].type === 'group') { + expect(result[0].metadata.value).toBe('backend'); + } + if (result[1].type === 'group') { + expect(result[1].metadata.value).toBe('frontend'); + } + if (result[2].type === 'group') { + expect(result[2].metadata.value).toBe(EmptyLabelValue); + } + }); + + it('should handle all empty label values', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + { name: 'team', type: FieldType.string, values: ['', ''], config: {} }, + ], + length: 2, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: EmptyLabelValue, + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + }); + }); + + describe('multi-level grouping', () => { + it('should group by two levels', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert2', 'Alert3', 'Alert4'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3', 'Folder4'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid2', 'uid3', 'uid4'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { + name: 'team', + type: FieldType.string, + values: ['backend', 'backend', 'frontend', 'frontend'], + config: {}, + }, + { + name: 'severity', + type: FieldType.string, + values: ['critical', 'warning', 'critical', 'info'], + config: {}, + }, + ], + length: 4, + }, + ], + ['team', 'severity'] + ); + + expect(result).toHaveLength(2); + + const backendGroup = result.find((r) => r.type === 'group' && r.metadata.value === 'backend'); + expect(backendGroup).toBeDefined(); + expect(backendGroup?.type).toBe('group'); + + if (backendGroup?.type === 'group') { + expect(backendGroup.rows).toHaveLength(2); + expect(backendGroup.rows[0]).toEqual({ + type: 'group', + metadata: { + label: 'severity', + value: 'critical', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + ], + }); + expect(backendGroup.rows[1]).toEqual({ + type: 'group', + metadata: { + label: 'severity', + value: 'warning', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + } + + const frontendGroup = result.find((r) => r.type === 'group' && r.metadata.value === 'frontend'); + expect(frontendGroup).toBeDefined(); + expect(frontendGroup?.type).toBe('group'); + + if (frontendGroup?.type === 'group') { + expect(frontendGroup.rows).toHaveLength(2); + } + }); + + it('should group by three levels', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', 'backend'], config: {} }, + { name: 'severity', type: FieldType.string, values: ['critical', 'critical'], config: {} }, + { name: 'region', type: FieldType.string, values: ['us-east', 'us-west'], config: {} }, + ], + length: 2, + }, + ], + ['team', 'severity', 'region'] + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('group'); + + if (result[0].type === 'group') { + expect(result[0].metadata.label).toBe('team'); + expect(result[0].metadata.value).toBe('backend'); + expect(result[0].rows).toHaveLength(1); + + const severityGroup = result[0].rows[0]; + expect(severityGroup.type).toBe('group'); + + if (severityGroup.type === 'group') { + expect(severityGroup.metadata.label).toBe('severity'); + expect(severityGroup.metadata.value).toBe('critical'); + expect(severityGroup.rows).toHaveLength(2); + + const regionGroup1 = severityGroup.rows[0]; + const regionGroup2 = severityGroup.rows[1]; + + expect(regionGroup1.type).toBe('group'); + expect(regionGroup2.type).toBe('group'); + + if (regionGroup1.type === 'group' && regionGroup2.type === 'group') { + expect(regionGroup1.metadata.label).toBe('region'); + expect(regionGroup1.metadata.value).toBe('us-east'); + expect(regionGroup1.rows).toHaveLength(1); + + expect(regionGroup2.metadata.label).toBe('region'); + expect(regionGroup2.metadata.value).toBe('us-west'); + expect(regionGroup2.rows).toHaveLength(1); + } + } + } + }); + + it('should handle empty values at multiple levels and place them at the end', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert2', 'Alert3', 'Alert4'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder2', 'Folder3', 'Folder4'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid2', 'uid3', 'uid4'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { name: 'team', type: FieldType.string, values: ['backend', '', 'backend', ''], config: {} }, + { name: 'severity', type: FieldType.string, values: ['critical', 'warning', '', 'info'], config: {} }, + ], + length: 4, + }, + ], + ['team', 'severity'] + ); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('group'); + expect(result[1].type).toBe('group'); + + if (result[0].type === 'group') { + expect(result[0].metadata.value).toBe('backend'); + expect(result[0].rows).toHaveLength(2); + const lastRow = result[0].rows[result[0].rows.length - 1]; + if (lastRow.type === 'group') { + expect(lastRow.metadata.value).toBe(EmptyLabelValue); + } + } + + if (result[1].type === 'group') { + expect(result[1].metadata.value).toBe(EmptyLabelValue); + expect(result[1].rows).toHaveLength(2); + } + }); + }); + + describe('alert instance aggregation with grouping', () => { + it('should aggregate alert instances from the same rule within groups', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000, 3000, 4000], config: {} }, + { + name: 'alertname', + type: FieldType.string, + values: ['Alert1', 'Alert1', 'Alert2', 'Alert2'], + config: {}, + }, + { + name: 'grafana_folder', + type: FieldType.string, + values: ['Folder1', 'Folder1', 'Folder2', 'Folder2'], + config: {}, + }, + { + name: 'grafana_rule_uid', + type: FieldType.string, + values: ['uid1', 'uid1', 'uid2', 'uid2'], + config: {}, + }, + { + name: 'alertstate', + type: FieldType.string, + values: ['firing', 'pending', 'firing', 'pending'], + config: {}, + }, + { + name: 'team', + type: FieldType.string, + values: ['backend', 'backend', 'frontend', 'frontend'], + config: {}, + }, + ], + length: 4, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(2); + + const backendGroup = result[0]; + if (backendGroup.type === 'group') { + expect(backendGroup.rows).toHaveLength(1); + expect(backendGroup.rows[0]).toEqual({ + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }); + } + + const frontendGroup = result[1]; + if (frontendGroup.type === 'group') { + expect(frontendGroup.rows).toHaveLength(1); + expect(frontendGroup.rows[0]).toEqual({ + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }); + } + }); + }); + + describe('grouping by non-existent field', () => { + it('should treat all values as empty when grouping by field that does not exist', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + ], + length: 2, + }, + ], + ['nonexistent'] + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'nonexistent', + value: EmptyLabelValue, + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + { + type: 'alertRule', + metadata: { + title: 'Alert2', + folder: 'Folder2', + ruleUID: 'uid2', + }, + }, + ], + }); + }); + }); + + describe('additional fields', () => { + it('should work with extra fields in the data frame', () => { + const result = convertToWorkbenchRows( + [ + { + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'alertname', type: FieldType.string, values: ['Alert1', 'Alert2'], config: {} }, + { name: 'grafana_folder', type: FieldType.string, values: ['Folder1', 'Folder2'], config: {} }, + { name: 'grafana_rule_uid', type: FieldType.string, values: ['uid1', 'uid2'], config: {} }, + { name: 'alertstate', type: FieldType.string, values: ['firing', 'pending'], config: {} }, + { name: 'team', type: FieldType.string, values: ['backend', 'frontend'], config: {} }, + { name: 'severity', type: FieldType.string, values: ['critical', 'warning'], config: {} }, + { name: 'region', type: FieldType.string, values: ['us-east', 'us-west'], config: {} }, + { name: 'extra_field', type: FieldType.string, values: ['value1', 'value2'], config: {} }, + ], + length: 2, + }, + ], + ['team'] + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'group', + metadata: { + label: 'team', + value: 'backend', + }, + rows: [ + { + type: 'alertRule', + metadata: { + title: 'Alert1', + folder: 'Folder1', + ruleUID: 'uid1', + }, + }, + ], + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/triage/scene/dataTransform.ts b/public/app/features/alerting/unified/triage/scene/dataTransform.ts new file mode 100644 index 00000000000..d8677cad946 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/dataTransform.ts @@ -0,0 +1,157 @@ +import { DataFrame } from '@grafana/data'; + +import { AlertRuleRow, EmptyLabelValue, GenericGroupedRow, WorkbenchRow } from '../types'; + +// Builds tree structure in one pass through data, avoiding intermediate row objects +export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] = []): WorkbenchRow[] { + if (!series.at(0)?.fields.length) { + return []; + } + + const frame = series[0]; + + // Build field index map + const fieldIndex = new Map(); + for (let i = 0; i < frame.fields.length; i++) { + fieldIndex.set(frame.fields[i].name, i); + } + + // Validate required fields exist + if ( + !fieldIndex.has('Time') || + !fieldIndex.has('alertname') || + !fieldIndex.has('grafana_folder') || + !fieldIndex.has('grafana_rule_uid') || + !fieldIndex.has('alertstate') + ) { + return []; + } + + // Get required field value arrays (direct columnar access) + const alertnameIndex = fieldIndex.get('alertname'); + const folderIndex = fieldIndex.get('grafana_folder'); + const ruleUIDIndex = fieldIndex.get('grafana_rule_uid'); + + // These should always exist due to validation above, but handle gracefully + if (!alertnameIndex || !folderIndex || !ruleUIDIndex) { + return []; + } + + const alertnameValues = frame.fields[alertnameIndex].values; + const folderValues = frame.fields[folderIndex].values; + const ruleUIDValues = frame.fields[ruleUIDIndex].values; + + // Get groupBy field value arrays + const groupByValueArrays = groupBy.map((key) => { + const index = fieldIndex.get(key); + return index !== undefined ? frame.fields[index]?.values : undefined; + }); + + // Fast path: no grouping - just dedupe alert rules + if (groupBy.length === 0) { + const seen = new Set(); + const result: AlertRuleRow[] = []; + + for (let i = 0; i < frame.length; i++) { + const ruleUID = ruleUIDValues[i]; + if (ruleUID && !seen.has(ruleUID)) { + seen.add(ruleUID); + result.push({ + type: 'alertRule', + metadata: { + title: alertnameValues[i], + folder: folderValues[i], + ruleUID: ruleUID, + }, + }); + } + } + return result; + } + + // Build nested group structure in single pass + interface GroupNode { + children: Map; + rowIndices: number[]; + } + + const root: GroupNode = { + children: new Map(), + rowIndices: [], + }; + + // Single pass: build entire tree + for (let rowIdx = 0; rowIdx < frame.length; rowIdx++) { + let node = root; + + // Navigate/create path through tree + for (let depth = 0; depth < groupBy.length; depth++) { + const rawValue = groupByValueArrays[depth]?.[rowIdx]; + const value = rawValue === '' || rawValue === undefined ? EmptyLabelValue : rawValue; + + let childNode = node.children.get(value); + if (!childNode) { + childNode = { + children: new Map(), + rowIndices: [], + }; + node.children.set(value, childNode); + } + + node = childNode; + } + + // At leaf level, track row index + node.rowIndices.push(rowIdx); + } + + // Convert tree to WorkbenchRow format + function nodeToRows(node: GroupNode, depth: number): WorkbenchRow[] { + if (depth >= groupBy.length) { + // Leaf level - create alert rule rows + const seen = new Set(); + const result: AlertRuleRow[] = []; + + for (const rowIdx of node.rowIndices) { + const ruleUID = ruleUIDValues[rowIdx]; + if (ruleUID && !seen.has(ruleUID)) { + seen.add(ruleUID); + result.push({ + type: 'alertRule', + metadata: { + title: alertnameValues[rowIdx], + folder: folderValues[rowIdx], + ruleUID: ruleUID, + }, + }); + } + } + + return result; + } + + const result: GenericGroupedRow[] = []; + const emptyGroups: GenericGroupedRow[] = []; + + for (const [value, childNode] of node.children.entries()) { + const group: GenericGroupedRow = { + type: 'group', + metadata: { + label: groupBy[depth], + value: value, + }, + rows: nodeToRows(childNode, depth + 1), + }; + + if (value === EmptyLabelValue) { + emptyGroups.push(group); + } else { + result.push(group); + } + } + + return [...result, ...emptyGroups]; + } + + return nodeToRows(root, 0); +} From bcc20574563f3737680a90db7595c754b9ab8f7c Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:01:25 -0600 Subject: [PATCH 065/209] Canvas: Fix Field image source when non-string field is used (#113534) --- .../app/features/dimensions/resource.test.ts | 63 ++++++++++++++++++- public/app/features/dimensions/resource.ts | 10 ++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/public/app/features/dimensions/resource.test.ts b/public/app/features/dimensions/resource.test.ts index 646bf297ff5..ad644b61b4f 100644 --- a/public/app/features/dimensions/resource.test.ts +++ b/public/app/features/dimensions/resource.test.ts @@ -1,7 +1,7 @@ import { createDataFrame } from '@grafana/data'; import { ResourceDimensionMode } from '@grafana/schema'; -import { getResourceDimension } from './resource'; +import { getPublicOrAbsoluteUrl, getResourceDimension } from './resource'; describe('getResourceDimension', () => { const publicPath = 'https://grafana.fake/public/'; @@ -63,5 +63,66 @@ describe('getResourceDimension', () => { expect(getResourceDimension(frame, config).value()).toEqual('https://3rdparty.fake/field.png'); }); + it('should return empty string for boolean field values', () => { + const frame = createDataFrame({ + fields: [ + { + name: 'image_field', + values: [true], + display: (v) => ({ + text: String(v), + numeric: NaN, + icon: undefined, + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'image_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(''); + expect(getResourceDimension(frame, config).value()).toEqual(''); + }); + + it('should return empty string for numeric field values', () => { + const frame = createDataFrame({ + fields: [ + { + name: 'image_field', + values: [123], + display: (v) => ({ + text: String(v), + numeric: Number(v), + icon: undefined, + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'image_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(''); + expect(getResourceDimension(frame, config).value()).toEqual(''); + }); + // TODO: write tests for mapping modes }); + +describe('getPublicOrAbsoluteUrl', () => { + const publicPath = 'https://grafana.fake/public/'; + beforeAll(() => { + window.__grafana_public_path__ = publicPath; + }); + + it('should handle string paths correctly', () => { + expect(getPublicOrAbsoluteUrl('icon.png')).toEqual(`${publicPath}build/icon.png`); + expect(getPublicOrAbsoluteUrl('https://example.com/icon.png')).toEqual('https://example.com/icon.png'); + }); + + it('should return empty string for non-string values', () => { + expect(getPublicOrAbsoluteUrl(true)).toEqual(''); + expect(getPublicOrAbsoluteUrl(123)).toEqual(''); + expect(getPublicOrAbsoluteUrl(null)).toEqual(''); + expect(getPublicOrAbsoluteUrl(undefined)).toEqual(''); + expect(getPublicOrAbsoluteUrl({ path: 'icon.png' })).toEqual(''); + expect(getPublicOrAbsoluteUrl(['icon.png'])).toEqual(''); + }); +}); diff --git a/public/app/features/dimensions/resource.ts b/public/app/features/dimensions/resource.ts index 278469b6a45..4eb28242550 100644 --- a/public/app/features/dimensions/resource.ts +++ b/public/app/features/dimensions/resource.ts @@ -7,8 +7,8 @@ import { findField, getLastNotNullFieldValue } from './utils'; //--------------------------------------------------------- // Resource dimension //--------------------------------------------------------- -export function getPublicOrAbsoluteUrl(path: string): string { - if (!path) { +export function getPublicOrAbsoluteUrl(path: unknown): string { + if (!path || typeof path !== 'string') { return ''; } @@ -55,7 +55,11 @@ export function getResourceDimension( } // mode === ResourceDimensionMode.Field case - const getImageOrIcon = (value: string): string => { + const getImageOrIcon = (value: unknown): string => { + if (typeof value !== 'string') { + return ''; + } + let url = value; if (field && field.display) { const displayValue = field.display(value); From 67ca3c231aaf1cc086c000310f250a4a7e71de16 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Thu, 6 Nov 2025 18:46:23 +0100 Subject: [PATCH 066/209] Test: Fix array element removal in adhoc filter e2e test (#113514) fix(test): adhoc-filter-from-panel --- e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts b/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts index 4e008fc3b5f..8ad6b634a87 100644 --- a/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts +++ b/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @@ -52,7 +52,7 @@ test.describe( // during the test, we select the "inner_eval" slice to filter; this simulates the behavior // of prometheus applying that filter and removing dataframes from the response. if (route.request().postData()?.includes('{slice=\\\"inner_eval\\\"}')) { - delete fixture.results.A.frames[1]; + fixture.results.A.frames.splice(1, 1); } await route.fulfill({ From 75afc64dd0916ccea122519e32cfefc0be687d0f Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 6 Nov 2025 18:49:44 +0100 Subject: [PATCH 067/209] fix: disable mode4,5 tests with library element table (#113539) --- pkg/tests/apis/folder/folders_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 43f5d3221ea..3fe36daa1e0 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -1340,7 +1340,9 @@ func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *tes t.Skip("test only on sqlite for now") } - for mode := 0; mode <= 5; mode++ { + // TODO: re-enable on mode 4 and 5 when we migrate /api to /apis for library connections, and begin to + // use search to return the connections, rather than the connections table. + for mode := 0; mode <= 3; mode++ { t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, delete parent blocked by library elements in child)", grafanarest.DualWriterMode(mode)), func(t *testing.T) { modeDw := grafanarest.DualWriterMode(mode) @@ -1434,7 +1436,9 @@ func TestIntegrationFolderDeletionBlockedByConnectedLibraryPanels(t *testing.T) t.Skip("test only on sqlite for now") } - for mode := 0; mode <= 5; mode++ { + // TODO: re-enable on mode 4 and 5 when we migrate /api to /apis for library connections, and begin to + // use search to return the connections, rather than the connections table. + for mode := 0; mode <= 3; mode++ { t.Run(fmt.Sprintf("mode %v - delete blocked by connected library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) { modeDw := grafanarest.DualWriterMode(mode) From 093142325975249e7a7720fa094945a675b404a9 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 6 Nov 2025 19:17:05 +0100 Subject: [PATCH 068/209] fix: use step output instead of !cancelled() in condition (#113533) * fix: use step output instead of !cancelled() in condition We are uploading profile files without need otherwise * fix: try using !cancelled() and output Otherwise, step isn't triggered due to failure of the previous one --- .github/workflows/pr-test-integration.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-test-integration.yml b/.github/workflows/pr-test-integration.yml index 51da3940973..a864f1604f0 100644 --- a/.github/workflows/pr-test-integration.yml +++ b/.github/workflows/pr-test-integration.yml @@ -150,10 +150,16 @@ jobs: echo "✅ $full_pkg passed" fi done + # Set output for artifact upload + if [ $EXIT_CODE -ne 0 ]; then + echo "upload_artifacts=true" >> "$GITHUB_OUTPUT" + else + echo "upload_artifacts=false" >> "$GITHUB_OUTPUT" + fi exit $EXIT_CODE - name: Output test profiles and traces uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 - if: (matrix.shard == 'profiled' && !cancelled()) + if: matrix.shard == 'profiled' && !cancelled() && steps.run-profiled-tests.outputs.upload_artifacts == 'true' with: name: integration-test-profiles-sqlite-nocgo-${{ github.run_number }} path: profiles/ From 95ea7584752ddeb2aa224d2d8d9aba2de81f5aa3 Mon Sep 17 00:00:00 2001 From: Serge Zaitsev Date: Thu, 6 Nov 2025 19:22:20 +0100 Subject: [PATCH 069/209] Chore: Start annotations app (#113018) * annotation legacy store with api server, read only * Add a feature flag for annotations app * implement list filters * annotations are not addressable by ID for read operations * fix registry apps test * add ownership for an app * disable linter * typo, of course * fix go workspace * update workspace * copy annotation app in dockerfile * update workspace --------- Co-authored-by: Tania B. <10127682+undef1nd@users.noreply.github.com> --- .github/CODEOWNERS | 1 + Dockerfile | 1 + apps/annotation/Makefile | 9 + apps/annotation/go.mod | 93 +++++ apps/annotation/go.sum | 240 ++++++++++++ apps/annotation/kinds/annotation.cue | 17 + apps/annotation/kinds/cue.mod/module.cue | 2 + apps/annotation/kinds/manifest.cue | 21 ++ .../v0alpha1/annotation_client_gen.go | 99 +++++ .../v0alpha1/annotation_codec_gen.go | 28 ++ .../v0alpha1/annotation_metadata_gen.go | 31 ++ .../v0alpha1/annotation_object_gen.go | 319 ++++++++++++++++ .../v0alpha1/annotation_schema_gen.go | 34 ++ .../v0alpha1/annotation_spec_gen.go | 18 + .../v0alpha1/annotation_status_gen.go | 44 +++ .../pkg/apis/annotation/v0alpha1/constants.go | 18 + .../pkg/apis/annotation_manifest.go | 124 +++++++ apps/annotation/pkg/app/app.go | 54 +++ .../v0alpha1/annotation_object_gen.ts | 49 +++ .../annotation/v0alpha1/types.metadata.gen.ts | 30 ++ .../annotation/v0alpha1/types.spec.gen.ts | 16 + .../annotation/v0alpha1/types.status.gen.ts | 30 ++ go.work | 1 + go.work.sum | 17 + .../src/types/featureToggles.gen.ts | 5 + pkg/registry/apps/annotation/register.go | 344 ++++++++++++++++++ pkg/registry/apps/apps.go | 7 + pkg/registry/apps/apps_test.go | 4 +- pkg/registry/apps/wireset.go | 2 + pkg/server/wire_gen.go | 13 +- .../annotations/annotationsimpl/xorm_store.go | 4 +- pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 + 35 files changed, 1696 insertions(+), 4 deletions(-) create mode 100644 apps/annotation/Makefile create mode 100644 apps/annotation/go.mod create mode 100644 apps/annotation/go.sum create mode 100644 apps/annotation/kinds/annotation.cue create mode 100644 apps/annotation/kinds/cue.mod/module.cue create mode 100644 apps/annotation/kinds/manifest.cue create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_client_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_codec_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_metadata_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_spec_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/annotation_status_gen.go create mode 100644 apps/annotation/pkg/apis/annotation/v0alpha1/constants.go create mode 100644 apps/annotation/pkg/apis/annotation_manifest.go create mode 100644 apps/annotation/pkg/app/app.go create mode 100644 apps/annotation/plugin/src/generated/annotation/v0alpha1/annotation_object_gen.ts create mode 100644 apps/annotation/plugin/src/generated/annotation/v0alpha1/types.metadata.gen.ts create mode 100644 apps/annotation/plugin/src/generated/annotation/v0alpha1/types.spec.gen.ts create mode 100644 apps/annotation/plugin/src/generated/annotation/v0alpha1/types.status.gen.ts create mode 100644 pkg/registry/apps/annotation/register.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b7191187e93..d891231bb37 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -98,6 +98,7 @@ /apps/correlations @grafana/datapro /apps/example/ @grafana/grafana-app-platform-squad /apps/logsdrilldown/ @grafana/observability-logs +/apps/annotation/ @grafana/grafana-backend-services-squad /pkg/api/ @grafana/grafana-backend-group /pkg/apis/ @grafana/grafana-app-platform-squad /pkg/apis/query @grafana/grafana-datasources-core-services diff --git a/Dockerfile b/Dockerfile index 909281146e7..7cb65f4a05f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,7 @@ COPY pkg/aggregator pkg/aggregator COPY apps/playlist apps/playlist COPY apps/plugins apps/plugins COPY apps/shorturl apps/shorturl +COPY apps/annotation apps/annotation COPY apps/correlations apps/correlations COPY apps/preferences apps/preferences COPY apps/provisioning apps/provisioning diff --git a/apps/annotation/Makefile b/apps/annotation/Makefile new file mode 100644 index 00000000000..230bfd4149a --- /dev/null +++ b/apps/annotation/Makefile @@ -0,0 +1,9 @@ +include ../sdk.mk + +.PHONY: generate # Run Grafana App SDK code generation +generate: install-app-sdk update-app-sdk + @$(APP_SDK_BIN) generate \ + --source=./kinds/ \ + --gogenpath=./pkg/apis \ + --grouping=group \ + --defencoding=none \ No newline at end of file diff --git a/apps/annotation/go.mod b/apps/annotation/go.mod new file mode 100644 index 00000000000..351d54e1cbd --- /dev/null +++ b/apps/annotation/go.mod @@ -0,0 +1,93 @@ +module github.com/grafana/grafana/apps/annotation + +go 1.24.0 + +require ( + github.com/grafana/grafana-app-sdk v0.48.1 + github.com/grafana/grafana-app-sdk/logging v0.48.1 + k8s.io/apimachinery v0.34.1 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/onsi/ginkgo/v2 v2.22.2 // indirect + github.com/onsi/gomega v1.36.2 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/time v0.14.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/client-go v0.34.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 + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/apps/annotation/go.sum b/apps/annotation/go.sum new file mode 100644 index 00000000000..a0e59cd5731 --- /dev/null +++ b/apps/annotation/go.sum @@ -0,0 +1,240 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +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/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.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/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/grafana-app-sdk v0.48.1 h1:bKJadWH18WCpJ+Zk8AezRFXCcZgGredRv+fRS+8zkek= +github.com/grafana/grafana-app-sdk v0.48.1/go.mod h1:5LljCz+wvmGfkQ8ZKTOfserhtXNEF0cSFthoWShvN6c= +github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= +github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= +github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= +github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +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-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +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/annotation/kinds/annotation.cue b/apps/annotation/kinds/annotation.cue new file mode 100644 index 00000000000..5df0fe9f12a --- /dev/null +++ b/apps/annotation/kinds/annotation.cue @@ -0,0 +1,17 @@ +package kinds + +annotationv0alpha1: { + kind: "Annotation" + pluralName: "Annotations" + schema: { + spec: { + text: string + time: int64 + timeEnd?: int64 + dashboardUID?: string + panelID?: int64 + tags?: [...string] + } + } +} + diff --git a/apps/annotation/kinds/cue.mod/module.cue b/apps/annotation/kinds/cue.mod/module.cue new file mode 100644 index 00000000000..a7f0a62536f --- /dev/null +++ b/apps/annotation/kinds/cue.mod/module.cue @@ -0,0 +1,2 @@ +module: "github.com/grafana/grafana/apps/annotation/kinds" +language: version: "v0.8.2" diff --git a/apps/annotation/kinds/manifest.cue b/apps/annotation/kinds/manifest.cue new file mode 100644 index 00000000000..b41931fc0c2 --- /dev/null +++ b/apps/annotation/kinds/manifest.cue @@ -0,0 +1,21 @@ +package kinds + +manifest: { + appName: "annotation" + groupOverride: "annotation.grafana.app" + versions: { + "v0alpha1": v0alpha1 + } +} + +v0alpha1: { + kinds: [annotationv0alpha1] + codegen: { + ts: { + enabled: true + } + go: { + enabled: true + } + } +} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_client_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_client_gen.go new file mode 100644 index 00000000000..c3f9cc986cc --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_client_gen.go @@ -0,0 +1,99 @@ +package v0alpha1 + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type AnnotationClient struct { + client *resource.TypedClient[*Annotation, *AnnotationList] +} + +func NewAnnotationClient(client resource.Client) *AnnotationClient { + return &AnnotationClient{ + client: resource.NewTypedClient[*Annotation, *AnnotationList](client, AnnotationKind()), + } +} + +func NewAnnotationClientFromGenerator(generator resource.ClientGenerator) (*AnnotationClient, error) { + c, err := generator.ClientFor(AnnotationKind()) + if err != nil { + return nil, err + } + return NewAnnotationClient(c), nil +} + +func (c *AnnotationClient) Get(ctx context.Context, identifier resource.Identifier) (*Annotation, error) { + return c.client.Get(ctx, identifier) +} + +func (c *AnnotationClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*AnnotationList, error) { + return c.client.List(ctx, namespace, opts) +} + +func (c *AnnotationClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*AnnotationList, error) { + resp, err := c.client.List(ctx, namespace, resource.ListOptions{ + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + for resp.GetContinue() != "" { + page, err := c.client.List(ctx, namespace, resource.ListOptions{ + Continue: resp.GetContinue(), + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + resp.SetContinue(page.GetContinue()) + resp.SetResourceVersion(page.GetResourceVersion()) + resp.SetItems(append(resp.GetItems(), page.GetItems()...)) + } + return resp, nil +} + +func (c *AnnotationClient) Create(ctx context.Context, obj *Annotation, opts resource.CreateOptions) (*Annotation, error) { + // Make sure apiVersion and kind are set + obj.APIVersion = GroupVersion.Identifier() + obj.Kind = AnnotationKind().Kind() + return c.client.Create(ctx, obj, opts) +} + +func (c *AnnotationClient) Update(ctx context.Context, obj *Annotation, opts resource.UpdateOptions) (*Annotation, error) { + return c.client.Update(ctx, obj, opts) +} + +func (c *AnnotationClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Annotation, error) { + return c.client.Patch(ctx, identifier, req, opts) +} + +func (c *AnnotationClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus AnnotationStatus, opts resource.UpdateOptions) (*Annotation, error) { + return c.client.Update(ctx, &Annotation{ + TypeMeta: metav1.TypeMeta{ + Kind: AnnotationKind().Kind(), + APIVersion: GroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: opts.ResourceVersion, + Namespace: identifier.Namespace, + Name: identifier.Name, + }, + Status: newStatus, + }, resource.UpdateOptions{ + Subresource: "status", + ResourceVersion: opts.ResourceVersion, + }) +} + +func (c *AnnotationClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + return c.client.Delete(ctx, identifier, opts) +} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_codec_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_codec_gen.go new file mode 100644 index 00000000000..3d754151d55 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_codec_gen.go @@ -0,0 +1,28 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "encoding/json" + "io" + + "github.com/grafana/grafana-app-sdk/resource" +) + +// AnnotationJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type AnnotationJSONCodec struct{} + +// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` +func (*AnnotationJSONCodec) Read(reader io.Reader, into resource.Object) error { + return json.NewDecoder(reader).Decode(into) +} + +// Write writes JSON-encoded bytes into `writer` marshaled from `from` +func (*AnnotationJSONCodec) Write(writer io.Writer, from resource.Object) error { + return json.NewEncoder(writer).Encode(from) +} + +// Interface compliance checks +var _ resource.Codec = &AnnotationJSONCodec{} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_metadata_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_metadata_gen.go new file mode 100644 index 00000000000..d35800b9804 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_metadata_gen.go @@ -0,0 +1,31 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + time "time" +) + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +type AnnotationMetadata struct { + UpdateTimestamp time.Time `json:"updateTimestamp"` + CreatedBy string `json:"createdBy"` + Uid string `json:"uid"` + CreationTimestamp time.Time `json:"creationTimestamp"` + DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` + Finalizers []string `json:"finalizers"` + ResourceVersion string `json:"resourceVersion"` + Generation int64 `json:"generation"` + UpdatedBy string `json:"updatedBy"` + Labels map[string]string `json:"labels"` +} + +// NewAnnotationMetadata creates a new AnnotationMetadata object. +func NewAnnotationMetadata() *AnnotationMetadata { + return &AnnotationMetadata{ + Finalizers: []string{}, + Labels: map[string]string{}, + } +} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go new file mode 100644 index 00000000000..db99bcffa08 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_object_gen.go @@ -0,0 +1,319 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "fmt" + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "time" +) + +// +k8s:openapi-gen=true +type Annotation struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata" yaml:"metadata"` + + // Spec is the spec of the Annotation + Spec AnnotationSpec `json:"spec" yaml:"spec"` + + Status AnnotationStatus `json:"status" yaml:"status"` +} + +func (o *Annotation) GetSpec() any { + return o.Spec +} + +func (o *Annotation) SetSpec(spec any) error { + cast, ok := spec.(AnnotationSpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *Annotation) GetSubresources() map[string]any { + return map[string]any{ + "status": o.Status, + } +} + +func (o *Annotation) GetSubresource(name string) (any, bool) { + switch name { + case "status": + return o.Status, true + default: + return nil, false + } +} + +func (o *Annotation) SetSubresource(name string, value any) error { + switch name { + case "status": + cast, ok := value.(AnnotationStatus) + if !ok { + return fmt.Errorf("cannot set status type %#v, not of type AnnotationStatus", value) + } + o.Status = cast + return nil + default: + return fmt.Errorf("subresource '%s' does not exist", name) + } +} + +func (o *Annotation) GetStaticMetadata() resource.StaticMetadata { + gvk := o.GroupVersionKind() + return resource.StaticMetadata{ + Name: o.ObjectMeta.Name, + Namespace: o.ObjectMeta.Namespace, + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} + +func (o *Annotation) SetStaticMetadata(metadata resource.StaticMetadata) { + o.Name = metadata.Name + o.Namespace = metadata.Namespace + o.SetGroupVersionKind(schema.GroupVersionKind{ + Group: metadata.Group, + Version: metadata.Version, + Kind: metadata.Kind, + }) +} + +func (o *Annotation) GetCommonMetadata() resource.CommonMetadata { + dt := o.DeletionTimestamp + var deletionTimestamp *time.Time + if dt != nil { + deletionTimestamp = &dt.Time + } + // Legacy ExtraFields support + extraFields := make(map[string]any) + if o.Annotations != nil { + extraFields["annotations"] = o.Annotations + } + if o.ManagedFields != nil { + extraFields["managedFields"] = o.ManagedFields + } + if o.OwnerReferences != nil { + extraFields["ownerReferences"] = o.OwnerReferences + } + return resource.CommonMetadata{ + UID: string(o.UID), + ResourceVersion: o.ResourceVersion, + Generation: o.Generation, + Labels: o.Labels, + CreationTimestamp: o.CreationTimestamp.Time, + DeletionTimestamp: deletionTimestamp, + Finalizers: o.Finalizers, + UpdateTimestamp: o.GetUpdateTimestamp(), + CreatedBy: o.GetCreatedBy(), + UpdatedBy: o.GetUpdatedBy(), + ExtraFields: extraFields, + } +} + +func (o *Annotation) SetCommonMetadata(metadata resource.CommonMetadata) { + o.UID = types.UID(metadata.UID) + o.ResourceVersion = metadata.ResourceVersion + o.Generation = metadata.Generation + o.Labels = metadata.Labels + o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) + if metadata.DeletionTimestamp != nil { + dt := metav1.NewTime(*metadata.DeletionTimestamp) + o.DeletionTimestamp = &dt + } else { + o.DeletionTimestamp = nil + } + o.Finalizers = metadata.Finalizers + if o.Annotations == nil { + o.Annotations = make(map[string]string) + } + if !metadata.UpdateTimestamp.IsZero() { + o.SetUpdateTimestamp(metadata.UpdateTimestamp) + } + if metadata.CreatedBy != "" { + o.SetCreatedBy(metadata.CreatedBy) + } + if metadata.UpdatedBy != "" { + o.SetUpdatedBy(metadata.UpdatedBy) + } + // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields + if metadata.ExtraFields != nil { + if annotations, ok := metadata.ExtraFields["annotations"]; ok { + if cast, ok := annotations.(map[string]string); ok { + o.Annotations = cast + } + } + if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { + if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { + o.ManagedFields = cast + } + } + if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { + if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { + o.OwnerReferences = cast + } + } + } +} + +func (o *Annotation) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *Annotation) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *Annotation) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) + return parsed +} + +func (o *Annotation) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *Annotation) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *Annotation) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +func (o *Annotation) Copy() resource.Object { + return resource.CopyObject(o) +} + +func (o *Annotation) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *Annotation) DeepCopy() *Annotation { + cpy := &Annotation{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *Annotation) DeepCopyInto(dst *Annotation) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) + o.Spec.DeepCopyInto(&dst.Spec) + o.Status.DeepCopyInto(&dst.Status) +} + +// Interface compliance compile-time check +var _ resource.Object = &Annotation{} + +// +k8s:openapi-gen=true +type AnnotationList struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ListMeta `json:"metadata" yaml:"metadata"` + Items []Annotation `json:"items" yaml:"items"` +} + +func (o *AnnotationList) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *AnnotationList) Copy() resource.ListObject { + cpy := &AnnotationList{ + TypeMeta: o.TypeMeta, + Items: make([]Annotation, len(o.Items)), + } + o.ListMeta.DeepCopyInto(&cpy.ListMeta) + for i := 0; i < len(o.Items); i++ { + if item, ok := o.Items[i].Copy().(*Annotation); ok { + cpy.Items[i] = *item + } + } + return cpy +} + +func (o *AnnotationList) GetItems() []resource.Object { + items := make([]resource.Object, len(o.Items)) + for i := 0; i < len(o.Items); i++ { + items[i] = &o.Items[i] + } + return items +} + +func (o *AnnotationList) SetItems(items []resource.Object) { + o.Items = make([]Annotation, len(items)) + for i := 0; i < len(items); i++ { + o.Items[i] = *items[i].(*Annotation) + } +} + +func (o *AnnotationList) DeepCopy() *AnnotationList { + cpy := &AnnotationList{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *AnnotationList) DeepCopyInto(dst *AnnotationList) { + resource.CopyObjectInto(dst, o) +} + +// Interface compliance compile-time check +var _ resource.ListObject = &AnnotationList{} + +// Copy methods for all subresource types + +// DeepCopy creates a full deep copy of Spec +func (s *AnnotationSpec) DeepCopy() *AnnotationSpec { + cpy := &AnnotationSpec{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies Spec into another Spec object +func (s *AnnotationSpec) DeepCopyInto(dst *AnnotationSpec) { + resource.CopyObjectInto(dst, s) +} + +// DeepCopy creates a full deep copy of AnnotationStatus +func (s *AnnotationStatus) DeepCopy() *AnnotationStatus { + cpy := &AnnotationStatus{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies AnnotationStatus into another AnnotationStatus object +func (s *AnnotationStatus) DeepCopyInto(dst *AnnotationStatus) { + resource.CopyObjectInto(dst, s) +} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go new file mode 100644 index 00000000000..3127e6a8954 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_schema_gen.go @@ -0,0 +1,34 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" +) + +// schema is unexported to prevent accidental overwrites +var ( + schemaAnnotation = resource.NewSimpleSchema("annotation.grafana.app", "v0alpha1", &Annotation{}, &AnnotationList{}, resource.WithKind("Annotation"), + resource.WithPlural("annotations"), resource.WithScope(resource.NamespacedScope)) + kindAnnotation = resource.Kind{ + Schema: schemaAnnotation, + Codecs: map[resource.KindEncoding]resource.Codec{ + resource.KindEncodingJSON: &AnnotationJSONCodec{}, + }, + } +) + +// Kind returns a resource.Kind for this Schema with a JSON codec +func AnnotationKind() resource.Kind { + return kindAnnotation +} + +// Schema returns a resource.SimpleSchema representation of Annotation +func AnnotationSchema() *resource.SimpleSchema { + return schemaAnnotation +} + +// Interface compliance checks +var _ resource.Schema = kindAnnotation diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_spec_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_spec_gen.go new file mode 100644 index 00000000000..f748c6b4253 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_spec_gen.go @@ -0,0 +1,18 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type AnnotationSpec struct { + Text string `json:"text"` + Time int64 `json:"time"` + TimeEnd *int64 `json:"timeEnd,omitempty"` + DashboardUID *string `json:"dashboardUID,omitempty"` + PanelID *int64 `json:"panelID,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// NewAnnotationSpec creates a new AnnotationSpec object. +func NewAnnotationSpec() *AnnotationSpec { + return &AnnotationSpec{} +} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_status_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_status_gen.go new file mode 100644 index 00000000000..f681750af88 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/annotation_status_gen.go @@ -0,0 +1,44 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type AnnotationstatusOperatorState struct { + // lastEvaluation is the ResourceVersion last evaluated + LastEvaluation string `json:"lastEvaluation"` + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + State AnnotationStatusOperatorStateState `json:"state"` + // descriptiveState is an optional more descriptive state field which has no requirements on format + DescriptiveState *string `json:"descriptiveState,omitempty"` + // details contains any extra information that is operator-specific + Details map[string]interface{} `json:"details,omitempty"` +} + +// NewAnnotationstatusOperatorState creates a new AnnotationstatusOperatorState object. +func NewAnnotationstatusOperatorState() *AnnotationstatusOperatorState { + return &AnnotationstatusOperatorState{} +} + +// +k8s:openapi-gen=true +type AnnotationStatus struct { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + OperatorStates map[string]AnnotationstatusOperatorState `json:"operatorStates,omitempty"` + // additionalFields is reserved for future use + AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` +} + +// NewAnnotationStatus creates a new AnnotationStatus object. +func NewAnnotationStatus() *AnnotationStatus { + return &AnnotationStatus{} +} + +// +k8s:openapi-gen=true +type AnnotationStatusOperatorStateState string + +const ( + AnnotationStatusOperatorStateStateSuccess AnnotationStatusOperatorStateState = "success" + AnnotationStatusOperatorStateStateInProgress AnnotationStatusOperatorStateState = "in_progress" + AnnotationStatusOperatorStateStateFailed AnnotationStatusOperatorStateState = "failed" +) diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/constants.go b/apps/annotation/pkg/apis/annotation/v0alpha1/constants.go new file mode 100644 index 00000000000..e28dcb0720d --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/constants.go @@ -0,0 +1,18 @@ +package v0alpha1 + +import "k8s.io/apimachinery/pkg/runtime/schema" + +const ( + // APIGroup is the API group used by all kinds in this package + APIGroup = "annotation.grafana.app" + // APIVersion is the API version used by all kinds in this package + APIVersion = "v0alpha1" +) + +var ( + // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package + GroupVersion = schema.GroupVersion{ + Group: APIGroup, + Version: APIVersion, + } +) diff --git a/apps/annotation/pkg/apis/annotation_manifest.go b/apps/annotation/pkg/apis/annotation_manifest.go new file mode 100644 index 00000000000..5e7d6ae8b07 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation_manifest.go @@ -0,0 +1,124 @@ +// +// This file is generated by grafana-app-sdk +// DO NOT EDIT +// + +package apis + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + v0alpha1 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1" +) + +var ( + rawSchemaAnnotationv0alpha1 = []byte(`{"Annotation":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"dashboardUID":{"type":"string"},"panelID":{"type":"integer"},"tags":{"items":{"type":"string"},"type":"array"},"text":{"type":"string"},"time":{"type":"integer"},"timeEnd":{"type":"integer"}},"required":["text","time"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaAnnotationv0alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaAnnotationv0alpha1, &versionSchemaAnnotationv0alpha1) +) + +var appManifestData = app.ManifestData{ + AppName: "annotation", + Group: "annotation.grafana.app", + PreferredVersion: "v0alpha1", + Versions: []app.ManifestVersion{ + { + Name: "v0alpha1", + Served: true, + Kinds: []app.ManifestVersionKind{ + { + Kind: "Annotation", + Plural: "Annotations", + Scope: "Namespaced", + Conversion: false, + Schema: &versionSchemaAnnotationv0alpha1, + }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{}, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + }, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("annotation") +} + +var kindVersionToGoType = map[string]resource.Kind{ + "Annotation/v0alpha1": v0alpha1.AnnotationKind(), +} + +// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. +// If there is no association for the provided Kind and Version, exists will return false. +func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { + goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] + return goType, exists +} + +var customRouteToGoResponseType = map[string]any{} + +// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. +// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. +// If there is no association for the provided kind, version, custom route path, and method, exists will return false. +// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) +func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoParamsType = map[string]runtime.Object{} + +func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoRequestBodyType = map[string]any{} + +func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +type GoTypeAssociator struct{} + +func NewGoTypeAssociator() *GoTypeAssociator { + return &GoTypeAssociator{} +} + +func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { + return ManifestGoTypeAssociator(kind, version) +} +func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { + return ManifestCustomRouteQueryAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) +} diff --git a/apps/annotation/pkg/app/app.go b/apps/annotation/pkg/app/app.go new file mode 100644 index 00000000000..d1665c0e776 --- /dev/null +++ b/apps/annotation/pkg/app/app.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana-app-sdk/operator" + "github.com/grafana/grafana-app-sdk/resource" + "github.com/grafana/grafana-app-sdk/simple" + "k8s.io/apimachinery/pkg/runtime/schema" + + annotationv0alpha1 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1" +) + +func New(cfg app.Config) (app.App, error) { + simpleConfig := simple.AppConfig{ + Name: "annotation", + KubeConfig: cfg.KubeConfig, + InformerConfig: simple.AppInformerConfig{ + InformerOptions: operator.InformerOptions{ + ErrorHandler: func(ctx context.Context, err error) { + logging.FromContext(ctx).Error("Informer processing error", "error", err) + }, + }, + }, + ManagedKinds: []simple.AppManagedKind{{ + Kind: annotationv0alpha1.AnnotationKind(), + }, + }, + } + + a, err := simple.NewApp(simpleConfig) + if err != nil { + return nil, err + } + + err = a.ValidateManifest(cfg.ManifestData) + if err != nil { + return nil, err + } + + return a, nil +} + +func GetKinds() map[schema.GroupVersion][]resource.Kind { + gv := schema.GroupVersion{ + Group: annotationv0alpha1.AnnotationKind().Group(), + Version: annotationv0alpha1.AnnotationKind().Version(), + } + return map[schema.GroupVersion][]resource.Kind{ + gv: {annotationv0alpha1.AnnotationKind()}, + } +} diff --git a/apps/annotation/plugin/src/generated/annotation/v0alpha1/annotation_object_gen.ts b/apps/annotation/plugin/src/generated/annotation/v0alpha1/annotation_object_gen.ts new file mode 100644 index 00000000000..2f7316bd74b --- /dev/null +++ b/apps/annotation/plugin/src/generated/annotation/v0alpha1/annotation_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Annotation { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.metadata.gen.ts b/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.spec.gen.ts b/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..e35814f387e --- /dev/null +++ b/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.spec.gen.ts @@ -0,0 +1,16 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface Spec { + text: string; + time: number; + timeEnd?: number; + dashboardUID?: string; + panelID?: number; + tags?: string[]; +} + +export const defaultSpec = (): Spec => ({ + text: "", + time: 0, +}); + diff --git a/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.status.gen.ts b/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/annotation/plugin/src/generated/annotation/v0alpha1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/go.work b/go.work index 3fa612c2fce..c1baa393967 100644 --- a/go.work +++ b/go.work @@ -9,6 +9,7 @@ use ( ./apps/alerting/alertenrichment ./apps/alerting/notifications ./apps/alerting/rules + ./apps/annotation ./apps/correlations ./apps/dashboard ./apps/example diff --git a/go.work.sum b/go.work.sum index 9ff7017d84d..7a3d0b77073 100644 --- a/go.work.sum +++ b/go.work.sum @@ -807,6 +807,23 @@ github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD github.com/grafana/go-mysql-server v0.20.1-0.20251027172658-317a8d46ffa4/go.mod h1:EeYR0apo+8j2Dyxmn2ghkPlirO2S5mT1xHBrA+Efys8= github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= +github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= +github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU= +github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY= +github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc= +github.com/grafana/grafana-app-sdk v0.47.0/go.mod h1:kywXmkppq0oReUMzkjTW8Fq2EBzyN7v914jttTWnWxA= +github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= +github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= +github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= +github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= +github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.45.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.46.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.48.0 h1:xolkQxBlA2LQF4hprKIAeu+zUem1DigYZ6XC1TOhFJE= github.com/grafana/grafana-app-sdk/logging v0.48.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f5f6a9116b6..5c44991aba8 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1246,4 +1246,9 @@ export interface FeatureToggles { * Enable template dashboards */ dashboardTemplates?: boolean; + /** + * Enables app platform API for annotations + * @default false + */ + kubernetesAnnotations?: boolean; } diff --git a/pkg/registry/apps/annotation/register.go b/pkg/registry/apps/annotation/register.go new file mode 100644 index 00000000000..40d79df30ce --- /dev/null +++ b/pkg/registry/apps/annotation/register.go @@ -0,0 +1,344 @@ +package annotation + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "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/apimachinery/pkg/selection" + "k8s.io/apiserver/pkg/registry/rest" + restclient "k8s.io/client-go/rest" + + "github.com/grafana/grafana-app-sdk/app" + appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "github.com/grafana/grafana-app-sdk/simple" + "github.com/grafana/grafana/apps/annotation/pkg/apis" + annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1" + annotationapp "github.com/grafana/grafana/apps/annotation/pkg/app" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" + apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + _ appsdkapiserver.AppInstaller = (*AnnotationAppInstaller)(nil) + _ appinstaller.LegacyStorageProvider = (*AnnotationAppInstaller)(nil) +) + +type AnnotationAppInstaller struct { + appsdkapiserver.AppInstaller + cfg *setting.Cfg + legacy *legacyStorage +} + +func RegisterAppInstaller( + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + service annotations.Repository, +) (*AnnotationAppInstaller, error) { + installer := &AnnotationAppInstaller{ + cfg: cfg, + } + provider := simple.NewAppProvider(apis.LocalManifest(), nil, annotationapp.New) + + appConfig := app.Config{ + KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method + ManifestData: *apis.LocalManifest().ManifestData, + } + i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, apis.NewGoTypeAssociator()) + if err != nil { + return nil, err + } + installer.AppInstaller = i + + if service != nil { + installer.legacy = &legacyStorage{ + service: service, + namespacer: request.GetNamespaceMapper(cfg), + } + } + + return installer, nil +} + +func (a *AnnotationAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) apiserverrest.Storage { + kind := annotationV0.AnnotationKind() + gvr := schema.GroupVersionResource{ + Group: kind.Group(), + Version: kind.Version(), + Resource: kind.Plural(), + } + if requested.String() != gvr.String() { + return nil + } + a.legacy.tableConverter = utils.NewTableConverter( + gvr.GroupResource(), + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Text", Type: "string", Format: "name"}, + }, + Reader: func(obj any) ([]any, error) { + m, ok := obj.(*annotationV0.Annotation) + if !ok { + return nil, fmt.Errorf("expected Annotation") + } + return []any{ + m.Spec.Text, + }, nil + }, + }, + ) + + return a.legacy +} + +var ( + _ rest.Scoper = (*legacyStorage)(nil) + _ rest.SingularNameProvider = (*legacyStorage)(nil) + _ rest.Getter = (*legacyStorage)(nil) + _ rest.Storage = (*legacyStorage)(nil) + _ rest.Creater = (*legacyStorage)(nil) + _ rest.Updater = (*legacyStorage)(nil) + _ rest.GracefulDeleter = (*legacyStorage)(nil) +) + +type legacyStorage struct { + service annotations.Repository + namespacer request.NamespaceMapper + tableConverter rest.TableConvertor +} + +func (s *legacyStorage) New() runtime.Object { + return annotationV0.AnnotationKind().ZeroValue() +} + +func (s *legacyStorage) Destroy() {} + +func (s *legacyStorage) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *legacyStorage) GetSingularName() string { + return strings.ToLower(annotationV0.AnnotationKind().Kind()) +} + +func (s *legacyStorage) NewList() runtime.Object { + return annotationV0.AnnotationKind().ZeroListValue() +} + +func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + orgID, err := request.OrgIDForList(ctx) + if err != nil { + return nil, err + } + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + query := &annotations.ItemQuery{OrgID: orgID, SignedInUser: user, AlertID: -1} + if options.FieldSelector != nil { + for _, r := range options.FieldSelector.Requirements() { + switch r.Field { + case "spec.dashboardUID": + if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals { + query.DashboardUID = r.Value + } else { + return nil, fmt.Errorf("unsupported operator %s for spec.dashboardUID (only = supported)", r.Operator) + } + + case "spec.panelID": + if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals { + panelID, err := strconv.ParseInt(r.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid panelID value %q: %w", r.Value, err) + } + query.PanelID = panelID + } else { + return nil, fmt.Errorf("unsupported operator %s for spec.panelID (only = supported)", r.Operator) + } + case "spec.time": + switch r.Operator { + case selection.GreaterThan: + from, err := strconv.ParseInt(r.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err) + } + query.From = from + case selection.LessThan: + to, err := strconv.ParseInt(r.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err) + } + query.To = to + default: + return nil, fmt.Errorf("unsupported operator %s for spec.time (only >, < supported for ranges)", r.Operator) + } + + case "spec.timeEnd": + switch r.Operator { + case selection.GreaterThan: + from, err := strconv.ParseInt(r.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err) + } + query.From = from + case selection.LessThan: + to, err := strconv.ParseInt(r.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err) + } + query.To = to + default: + return nil, fmt.Errorf("unsupported operator %s for spec.timeEnd (only >, < supported for ranges)", r.Operator) + } + + default: + return nil, fmt.Errorf("unsupported field selector: %s", r.Field) + } + } + } + + query.Limit = 100 + if options.Limit > 0 { + query.Limit = options.Limit + } + items, err := s.service.Find(ctx, query) + if err != nil { + return nil, err + } + list := &annotationV0.AnnotationList{ + Items: make([]annotationV0.Annotation, len(items)), + } + for i, item := range items { + c, err := toK8sResource(orgID, item, s.namespacer) + if err != nil { + return nil, err + } + list.Items[i] = *c + } + + // TODO: pagination? + return list, nil +} + +func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return nil, errors.New("fetching single annotations not supported by legacy storage") +} + +func (s *legacyStorage) Create(ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + options *metav1.CreateOptions, +) (runtime.Object, error) { + return nil, errors.New("not implemented") + // resource, ok := obj.(*correlationsV0.Correlation) + // if !ok { + // return nil, fmt.Errorf("expected correlation") + // } + // + // cmd, err := correlations.ToCreateCorrelationCommand(resource) + // if err != nil { + // return nil, err + // } + // + // out, err := s.service.CreateCorrelation(ctx, *cmd) + // if err != nil { + // return nil, err + // } + // return s.Get(ctx, out.UID, &metav1.GetOptions{}) +} + +func (s *legacyStorage) Update(ctx context.Context, + name string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + forceAllowCreate bool, + options *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + return nil, false, errors.New("not implemented") + // before, err := s.Get(ctx, name, &metav1.GetOptions{}) + // if err != nil { + // return nil, false, err + // } + // obj, err := objInfo.UpdatedObject(ctx, before) + // if err != nil { + // return nil, false, err + // } + // + // resource, ok := obj.(*correlationsV0.Correlation) + // if !ok { + // return nil, false, fmt.Errorf("expected correlation") + // } + // + // cmd, err := correlations.ToUpdateCorrelationCommand(resource) + // if err != nil { + // return nil, false, err + // } + // + // out, err := s.service.UpdateCorrelation(ctx, *cmd) + // if err != nil { + // return nil, false, err + // } + // obj, err = s.Get(ctx, out.UID, &metav1.GetOptions{}) + // return obj, false, err +} + +// GracefulDeleter +func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + return nil, false, errors.New("not implemented") + // orgID, err := request.OrgIDForList(ctx) + // if err != nil { + // return nil, false, err + // } + // err = s.service.DeleteCorrelation(ctx, correlations.DeleteCorrelationCommand{ + // OrgId: orgID, + // UID: name, + // }) + // return nil, (err == nil), err +} + +// CollectionDeleter +func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, fmt.Errorf("DeleteCollection for annotation not implemented") +} + +func toK8sResource(orgID int64, item *annotations.ItemDTO, namespacer request.NamespaceMapper) (*annotationV0.Annotation, error) { + annotation := &annotationV0.Annotation{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("a-%d", item.ID), // FIXME + Namespace: namespacer(orgID), + }, + Spec: annotationV0.AnnotationSpec{ + Text: item.Text, + Time: item.Time, + Tags: item.Tags, + }, + } + + if item.DashboardUID != nil && *item.DashboardUID != "" { + annotation.Spec.DashboardUID = item.DashboardUID + } + if item.PanelID != 0 { + annotation.Spec.PanelID = &item.PanelID + } + if item.TimeEnd != 0 { + annotation.Spec.TimeEnd = &item.TimeEnd + } + return annotation, nil +} diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index 6b48f15d708..5c039264a4b 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/advisor" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" + "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" "github.com/grafana/grafana/pkg/registry/apps/investigations" @@ -39,6 +40,7 @@ func ProvideAppInstallers( correlationsAppInstaller *correlations.AppInstaller, alertingNotificationAppInstaller *notifications.AlertingNotificationsAppInstaller, logsdrilldownAppInstaller *logsdrilldown.LogsDrilldownAppInstaller, + annotationAppInstaller *annotation.AnnotationAppInstaller, exampleAppInstaller *example.ExampleAppInstaller, ) []appsdkapiserver.AppInstaller { installers := []appsdkapiserver.AppInstaller{ @@ -65,6 +67,11 @@ func ProvideAppInstallers( if features.IsEnabledGlobally(featuremgmt.FlagKubernetesLogsDrilldown) { installers = append(installers, logsdrilldownAppInstaller) } + //nolint:staticcheck + if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAnnotations) { + installers = append(installers, annotationAppInstaller) + } + return installers } diff --git a/pkg/registry/apps/apps_test.go b/pkg/registry/apps/apps_test.go index a27819b726b..3c92bda62ad 100644 --- a/pkg/registry/apps/apps_test.go +++ b/pkg/registry/apps/apps_test.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" + "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" "github.com/grafana/grafana/pkg/registry/apps/playlist" @@ -20,6 +21,7 @@ func TestProvideAppInstallers_Table(t *testing.T) { rulesInstaller := &rules.AlertingRulesAppInstaller{} correlationsAppInstaller := &correlations.AppInstaller{} notificationsAppInstaller := ¬ifications.AlertingNotificationsAppInstaller{} + annotationAppInstaller := &annotation.AnnotationAppInstaller{} exampleAppInstaller := &example.ExampleAppInstaller{} tests := []struct { @@ -37,7 +39,7 @@ func TestProvideAppInstallers_Table(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { features := featuremgmt.WithFeatures(tt.flags...) - got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, exampleAppInstaller) + got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller) if tt.expectRulesApp { require.Contains(t, got, tt.rulesInst) } else { diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index 5961b0caf85..60038502bf1 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/advisor" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" + "github.com/grafana/grafana/pkg/registry/apps/annotation" "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" "github.com/grafana/grafana/pkg/registry/apps/investigations" @@ -27,5 +28,6 @@ var WireSet = wire.NewSet( rules.RegisterAppInstaller, notifications.RegisterAppInstaller, logsdrilldown.RegisterAppInstaller, + annotation.RegisterAppInstaller, example.RegisterAppInstaller, ) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 6fd5a56b2cd..13b764f7238 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -80,6 +80,7 @@ import ( advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor" notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" + "github.com/grafana/grafana/pkg/registry/apps/annotation" correlations2 "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/example" "github.com/grafana/grafana/pkg/registry/apps/investigations" @@ -798,11 +799,15 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } + annotationAppInstaller, err := annotation.RegisterAppInstaller(cfg, featureToggles, repositoryImpl) + if err != nil { + return nil, err + } exampleAppInstaller, err := example.RegisterAppInstaller(cfg, featureToggles) if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, exampleAppInstaller) + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { @@ -1432,11 +1437,15 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } + annotationAppInstaller, err := annotation.RegisterAppInstaller(cfg, featureToggles, repositoryImpl) + if err != nil { + return nil, err + } exampleAppInstaller, err := example.RegisterAppInstaller(cfg, featureToggles) if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, exampleAppInstaller) + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index c59dc43f43f..da44dda4d99 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -330,7 +330,9 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer params = append(params, query.AnnotationID) } - if query.AlertID != 0 { + if query.AlertID < 0 { + sql.WriteString(` AND a.alert_id = 0`) + } else if query.AlertID > 0 { sql.WriteString(` AND a.alert_id = ?`) params = append(params, query.AlertID) } else if query.AlertUID != "" { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4834439df01..60d477ad62c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2159,6 +2159,13 @@ var ( Owner: grafanaSharingSquad, FrontendOnly: false, }, + { + Name: "kubernetesAnnotations", + Description: "Enables app platform API for annotations", + Stage: FeatureStageExperimental, + Owner: grafanaBackendServicesSquad, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6f75a9c077a..98099178166 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -277,3 +277,4 @@ pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,f onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false +kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2b568416f90..60ad9a69431 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1117,4 +1117,8 @@ const ( // FlagDashboardTemplates // Enable template dashboards FlagDashboardTemplates = "dashboardTemplates" + + // FlagKubernetesAnnotations + // Enables app platform API for annotations + FlagKubernetesAnnotations = "kubernetesAnnotations" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 766e1e3a502..c656f948c18 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2153,6 +2153,19 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "kubernetesAnnotations", + "resourceVersion": "1761142826172", + "creationTimestamp": "2025-10-22T14:20:26Z" + }, + "spec": { + "description": "Enables app platform API for annotations", + "stage": "experimental", + "codeowner": "@grafana/grafana-backend-services-squad", + "expression": "false" + } + }, { "metadata": { "name": "kubernetesAuthZHandlerRedirect", From c9e4c26c118803ef0f85e6e3d8dc64ec52fb4cfc Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:36:02 -0500 Subject: [PATCH 070/209] unified-storage: add more list pagination tests (#113543) * unified-storage: add more list pagination tests --- .../unified/resource/storage_backend_test.go | 40 ++++++++++++++-- .../unified/testing/storage_backend.go | 48 ++++++++++--------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/pkg/storage/unified/resource/storage_backend_test.go b/pkg/storage/unified/resource/storage_backend_test.go index 772362aceef..85cf3331d7d 100644 --- a/pkg/storage/unified/resource/storage_backend_test.go +++ b/pkg/storage/unified/resource/storage_backend_test.go @@ -537,21 +537,51 @@ func TestKvStorageBackend_ListIterator_WithPagination(t *testing.T) { var continueToken2 string _, err = backend.ListIterator(ctx, listReq, func(iter ListIterator) error { + count := 0 for iter.Next() { if err := iter.Error(); err != nil { return err } secondPageItems = append(secondPageItems, iter.Name()) + count++ + // Simulate pagination by getting continue token after limit items + if count >= int(listReq.Limit) { + continueToken2 = iter.ContinueToken() + break + } } - // Capture continue token for potential third page - continueToken2 = iter.ContinueToken() return iter.Error() }) - // TODO: fix the ListIterator to respect the limit. This require a change to the resource server. require.NoError(t, err) - require.Equal(t, 3, len(secondPageItems)) - require.Equal(t, []string{"resource-3", "resource-4", "resource-5"}, secondPageItems) + require.Equal(t, 2, len(secondPageItems)) + require.Equal(t, []string{"resource-3", "resource-4"}, secondPageItems) require.NotEmpty(t, continueToken2) + + // third page using continue token + listReq.NextPageToken = continueToken2 + var thirdPageItems []string + var continueToken3 string + + _, err = backend.ListIterator(ctx, listReq, func(iter ListIterator) error { + count := 0 + for iter.Next() { + if err := iter.Error(); err != nil { + return err + } + thirdPageItems = append(thirdPageItems, iter.Name()) + count++ + // Simulate pagination by getting continue token after limit items + if count >= int(listReq.Limit) { + continueToken = iter.ContinueToken() + break + } + } + return iter.Error() + }) + require.NoError(t, err) + require.Equal(t, 1, len(thirdPageItems)) + require.Equal(t, []string{"resource-5"}, thirdPageItems) + require.Empty(t, continueToken3) } func TestKvStorageBackend_ListIterator_EmptyResult(t *testing.T) { backend := setupTestStorageBackend(t) diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index 440f2af8a26..18be19d407e 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -411,7 +411,7 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend require.Empty(t, res.NextPageToken) }) - t.Run("list latest first page ", func(t *testing.T) { + t.Run("fetch all latest with pagination", func(t *testing.T) { res, err := server.List(ctx, &resourcepb.ListRequest{ Limit: 3, Options: &resourcepb.ListOptions{ @@ -431,6 +431,22 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend require.Contains(t, string(res.Items[1].Value), "item2 MODIFIED") require.Contains(t, string(res.Items[2].Value), "item4 ADDED") require.GreaterOrEqual(t, continueToken.ResourceVersion, rv8) + + res, err = server.List(ctx, &resourcepb.ListRequest{ + Limit: 3, + NextPageToken: continueToken.String(), + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: ns, + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 2) + require.Empty(t, res.NextPageToken) }) t.Run("list at revision", func(t *testing.T) { @@ -454,7 +470,7 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend require.Empty(t, res.NextPageToken) }) - t.Run("fetch first page at revision with limit", func(t *testing.T) { + t.Run("list at revision with pagination", func(t *testing.T) { res, err := server.List(ctx, &resourcepb.ListRequest{ Limit: 3, ResourceVersion: rv7, @@ -470,7 +486,6 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend require.NoError(t, err) require.Nil(t, res.Error) require.Len(t, res.Items, 3) - t.Log(res.Items) require.Contains(t, string(res.Items[0].Value), "item1 ADDED") require.Contains(t, string(res.Items[1].Value), "item2 MODIFIED") require.Contains(t, string(res.Items[2].Value), "item4 ADDED") @@ -478,17 +493,11 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend continueToken, err := resource.GetContinueToken(res.NextPageToken) require.NoError(t, err) require.Equal(t, rv7, continueToken.ResourceVersion) - }) - t.Run("fetch second page at revision", func(t *testing.T) { - continueToken := &resource.ContinueToken{ - ResourceVersion: rv8, - StartOffset: 2, - SortAscending: false, - } - res, err := server.List(ctx, &resourcepb.ListRequest{ - NextPageToken: continueToken.String(), - Limit: 2, + res, err = server.List(ctx, &resourcepb.ListRequest{ + Limit: 3, + ResourceVersion: rv7, + NextPageToken: continueToken.String(), Options: &resourcepb.ListOptions{ Key: &resourcepb.ResourceKey{ Namespace: ns, @@ -498,16 +507,11 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend }, }) require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 2) - t.Log(res.Items) - require.Contains(t, string(res.Items[0].Value), "item4 ADDED") - require.Contains(t, string(res.Items[1].Value), "item5 ADDED") - - continueToken, err = resource.GetContinueToken(res.NextPageToken) require.NoError(t, err) - require.Equal(t, rv8, continueToken.ResourceVersion) - require.Equal(t, int64(4), continueToken.StartOffset) + require.Nil(t, res.Error) + require.Len(t, res.Items, 1) + require.Contains(t, string(res.Items[0].Value), "item5 ADDED") + require.Empty(t, res.NextPageToken) }) } From e953e760062a5b44feb2054b959407dcf178cfb0 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 6 Nov 2025 20:21:51 +0100 Subject: [PATCH 071/209] Log Details: Dedicated context provider + improvements (#113409) * LogDetailsContext: create component * LogListContext: extract details out of context * Refactor components to use new context provider * More component updates * Update currentLog implementation * Use new context provider * LogLineDetails: prevent cascade of listeners * LogDetailsContext: sync currentLog with changes * LogLine: use icon status to show the current log * LogLineDetails: first tab is the last open log line * LogLineDetailsLog: respect font size * Update tests * Update tests * LogList: add integration test * LogLine: use level to mark the current log * Chore: only check uids, no need for references * Fix duplicated hook usage * chore: overflow auto * LogList: consider field selector width * Revert "LogLine: use level to mark the current log" This reverts commit 2d5d54d9a7a9b4589cf2ac0344cc904fe5ce246a. * LogLine: darken details displayed, font weight bold current * LogLineMenu: icon when current log * Differenciate contrast from light and dark themes * Use angle-right for the active icon --- .../logs/components/ControlledLogRows.tsx | 1 - .../logs/components/ControlledLogsTable.tsx | 5 +- .../components/fieldSelector/FieldList.tsx | 2 +- .../panel/LogDetailsContext.test.tsx | 41 +++ .../components/panel/LogDetailsContext.tsx | 241 ++++++++++++++ .../logs/components/panel/LogLine.test.tsx | 13 +- .../logs/components/panel/LogLine.tsx | 22 +- .../components/panel/LogLineDetails.test.tsx | 95 ++---- .../logs/components/panel/LogLineDetails.tsx | 49 ++- .../panel/LogLineDetailsDisplayedFields.tsx | 4 +- .../components/panel/LogLineDetailsFields.tsx | 3 +- .../components/panel/LogLineDetailsHeader.tsx | 5 +- .../components/panel/LogLineDetailsLinks.tsx | 4 +- .../components/panel/LogLineDetailsLog.tsx | 8 +- .../components/panel/LogLineMenu.test.tsx | 13 +- .../logs/components/panel/LogLineMenu.tsx | 11 +- .../logs/components/panel/LogList.test.tsx | 295 +++++++++++------- .../logs/components/panel/LogList.tsx | 52 +-- .../logs/components/panel/LogListContext.tsx | 183 +---------- .../logs/components/panel/LogListControls.tsx | 5 +- .../panel/__mocks__/LogListContext.tsx | 31 +- .../logs/components/panel/virtualization.ts | 2 +- 22 files changed, 612 insertions(+), 473 deletions(-) create mode 100644 public/app/features/logs/components/panel/LogDetailsContext.test.tsx create mode 100644 public/app/features/logs/components/panel/LogDetailsContext.tsx diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index ee663e3d0d9..4094d4f515c 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -79,7 +79,6 @@ export const ControlledLogRows = forwardRef diff --git a/public/app/features/logs/components/fieldSelector/FieldList.tsx b/public/app/features/logs/components/fieldSelector/FieldList.tsx index bac519beafd..4ae9a01f74a 100644 --- a/public/app/features/logs/components/fieldSelector/FieldList.tsx +++ b/public/app/features/logs/components/fieldSelector/FieldList.tsx @@ -46,7 +46,7 @@ export const FieldList = ({ activeFields, clear, fields, reorder, suggestedField function getStyles(theme: GrafanaTheme2) { return { sidebarWrap: css({ - overflowY: 'scroll', + overflowY: 'auto', flex: 1, scrollbarWidth: 'thin', }), diff --git a/public/app/features/logs/components/panel/LogDetailsContext.test.tsx b/public/app/features/logs/components/panel/LogDetailsContext.test.tsx new file mode 100644 index 00000000000..cfab2d71599 --- /dev/null +++ b/public/app/features/logs/components/panel/LogDetailsContext.test.tsx @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; + +import { createLogLine } from '../mocks/logRow'; + +import { + useLogDetailsContextData, + useLogDetailsContext, + LogDetailsContext, + LogDetailsContextData, +} from './LogDetailsContext'; + +const log = createLogLine({ rowId: 'yep', uid: 'uid' }); +const contextValue: LogDetailsContextData = { + currentLog: log, + closeDetails: () => {}, + detailsDisplayed: () => false, + detailsMode: 'sidebar', + detailsWidth: 1337, + enableLogDetails: false, + setCurrentLog: () => {}, + setDetailsMode: () => {}, + setDetailsWidth: () => {}, + showDetails: [], + toggleDetails: () => {}, +}; +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +test('Provides the Log Details Context data', () => { + const { result } = renderHook(() => useLogDetailsContext(), { wrapper }); + + expect(result.current).toEqual(contextValue); +}); + +test('Allows to access context attributes', () => { + const { result } = renderHook(() => useLogDetailsContextData('detailsWidth'), { wrapper }); + + expect(result.current).toEqual(contextValue.detailsWidth); +}); diff --git a/public/app/features/logs/components/panel/LogDetailsContext.tsx b/public/app/features/logs/components/panel/LogDetailsContext.tsx new file mode 100644 index 00000000000..de0c044911e --- /dev/null +++ b/public/app/features/logs/components/panel/LogDetailsContext.tsx @@ -0,0 +1,241 @@ +import { debounce } from 'lodash'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; + +import { LogRowModel, store } from '@grafana/data'; + +import { getSidebarWidth } from '../fieldSelector/FieldSelector'; + +import { LogLineDetailsMode } from './LogLineDetails'; +import { LogListModel } from './processing'; +import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization'; + +export interface LogDetailsContextData { + currentLog: LogListModel | undefined; + closeDetails: () => void; + detailsDisplayed: (log: LogListModel) => boolean; + detailsMode: LogLineDetailsMode; + detailsWidth: number; + enableLogDetails: boolean; + setCurrentLog(log: LogListModel): void; + setDetailsMode: (mode: LogLineDetailsMode) => void; + setDetailsWidth: (width: number) => void; + showDetails: LogListModel[]; + toggleDetails: (log: LogListModel) => void; +} + +export const emptyContextData: LogDetailsContextData = { + currentLog: undefined, + closeDetails: () => {}, + detailsDisplayed: () => false, + detailsMode: 'sidebar', + detailsWidth: 0, + enableLogDetails: false, + setCurrentLog: () => {}, + setDetailsMode: () => {}, + setDetailsWidth: () => {}, + showDetails: [], + toggleDetails: () => {}, +}; +export const LogDetailsContext = createContext(emptyContextData); + +export const useLogDetailsContextData = (key: keyof LogDetailsContextData) => { + const data: LogDetailsContextData = useContext(LogDetailsContext); + return data[key]; +}; + +export const useLogDetailsContext = (): LogDetailsContextData => { + return useContext(LogDetailsContext); +}; + +export interface Props { + children?: ReactNode; + // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx + containerElement?: HTMLDivElement; + detailsMode?: LogLineDetailsMode; + enableLogDetails: boolean; + logs: LogRowModel[]; + logOptionsStorageKey?: string; + showControls: boolean; +} + +export const LogDetailsContextProvider = ({ + children, + containerElement, + enableLogDetails, + logOptionsStorageKey, + detailsMode: detailsModeProp = logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? getDefaultDetailsMode(containerElement)) + : getDefaultDetailsMode(containerElement), + logs, + showControls, +}: Props) => { + const [showDetails, setShowDetails] = useState([]); + + const [currentLog, setCurrentLog] = useState(undefined); + const [detailsWidth, setDetailsWidthState] = useState( + getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls) + ); + const [detailsMode, setDetailsMode] = useState( + detailsModeProp ?? getDefaultDetailsMode(containerElement) + ); + + // Sync details mode + useEffect(() => { + if (detailsModeProp) { + setDetailsMode(detailsModeProp); + } + }, [detailsModeProp]); + + // Sync show details + useEffect(() => { + if (!showDetails.length) { + return; + } + const newShowDetails = showDetails.filter( + (expandedLog) => logs.findIndex((log) => log.uid === expandedLog.uid) >= 0 + ); + if (newShowDetails.length !== showDetails.length) { + setShowDetails(newShowDetails); + } + }, [logs, showDetails]); + + // Sync log details inline and sidebar width + useEffect(() => { + setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls)); + }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); + + // Sync log details width + useEffect(() => { + if (!containerElement) { + return; + } + const handleResize = debounce(() => { + setDetailsWidthState((detailsWidth) => + getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls) + ); + }, 50); + const observer = new ResizeObserver(() => handleResize()); + observer.observe(containerElement); + return () => observer.disconnect(); + }, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails]); + + const closeDetails = useCallback(() => { + showDetails.forEach((log) => removeDetailsScrollPosition(log)); + setShowDetails([]); + setCurrentLog(undefined); + }, [showDetails]); + + const detailsDisplayed = useCallback( + (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), + [showDetails] + ); + + const toggleDetails = useCallback( + (log: LogListModel) => { + if (!enableLogDetails) { + return; + } + const found = showDetails.find((stateLog) => stateLog.uid === log.uid); + if (found) { + removeDetailsScrollPosition(found); + const newShowDetails = showDetails.filter((stateLog) => stateLog.uid !== log.uid); + setShowDetails(newShowDetails); + if (currentLog && currentLog.uid === log.uid) { + setCurrentLog(newShowDetails[newShowDetails.length - 1]); + } + } else { + // Supporting one displayed details for now + setShowDetails([...showDetails, log]); + setCurrentLog(log); + } + }, + [currentLog, enableLogDetails, showDetails] + ); + + const setDetailsWidth = useCallback( + (width: number) => { + if (!logOptionsStorageKey || !containerElement) { + return; + } + + const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH; + if (width > maxWidth) { + return; + } + + store.set(`${logOptionsStorageKey}.detailsWidth`, width); + setDetailsWidthState(width); + }, + [containerElement, logOptionsStorageKey] + ); + + return ( + + {children} + + ); +}; + +// Only ControlledLogRows can send an undefined containerElement. See LogList.tsx +export function getDetailsWidth( + containerElement: HTMLDivElement | undefined, + logOptionsStorageKey?: string, + currentWidth?: number, + detailsMode: LogLineDetailsMode = 'sidebar', + showControls?: boolean +) { + if (!containerElement) { + return 0; + } + const availableWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey); + if (detailsMode === 'inline') { + return availableWidth - getScrollbarWidth() - (showControls ? LOG_LIST_CONTROLS_WIDTH : 0); + } + const defaultWidth = availableWidth * 0.4; + const detailsWidth = + currentWidth || + (logOptionsStorageKey + ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`) ?? defaultWidth, 10) + : defaultWidth); + + const maxWidth = availableWidth - LOG_LIST_MIN_WIDTH; + + // The user might have resized the screen. + if (detailsWidth >= availableWidth || detailsWidth > maxWidth) { + return currentWidth ?? defaultWidth; + } + return detailsWidth; +} + +const detailsScrollMap = new Map(); + +export function saveDetailsScrollPosition(log: LogListModel, position: number) { + detailsScrollMap.set(log.uid, position); +} + +export function getDetailsScrollPosition(log: LogListModel) { + return detailsScrollMap.get(log.uid) ?? 0; +} + +export function removeDetailsScrollPosition(log: LogListModel) { + detailsScrollMap.delete(log.uid); +} + +export function getDefaultDetailsMode(container: HTMLDivElement | undefined): LogLineDetailsMode { + const width = container?.clientWidth ?? window.innerWidth; + return width > 1440 ? 'sidebar' : 'inline'; +} diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 5222fae50d9..8ac0b2281e8 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -8,6 +8,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; import { getDisplayedFieldsForLogs, OTEL_PROBE_FIELD } from '../otel/formats'; +import { emptyContextData, LogDetailsContext } from './LogDetailsContext'; import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine'; import { LogListFontSize } from './LogList'; import { LogListContextProvider, LogListContext } from './LogListContext'; @@ -551,32 +552,32 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { describe('Inline details', () => { test('Details are not rendered if details mode is not inline', () => { render( - - + ); expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); }); test('Details are rendered if details mode is inline', () => { render( - - + ); expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument(); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index d58a861439e..17f628b5914 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -23,6 +23,7 @@ import { LogMessageAnsi } from '../LogMessageAnsi'; import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats'; import { HighlightedLogRenderer } from './HighlightedLogRenderer'; +import { useLogDetailsContext } from './LogDetailsContext'; import { InlineLogLineDetails } from './LogLineDetails'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext'; @@ -113,10 +114,7 @@ const LogLineComponent = memo( wrapLogMessage, }: LogLineComponentProps) => { const { - detailsDisplayed, - detailsMode, dedupStrategy, - enableLogDetails, fontSize, hasLogsWithErrors, hasSampledLogs, @@ -124,6 +122,7 @@ const LogLineComponent = memo( timestampResolution, onLogLineHover, } = useLogListContext(); + const { currentLog, detailsDisplayed, detailsMode, enableLogDetails } = useLogDetailsContext(); const [collapsed, setCollapsed] = useState( wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined ); @@ -195,6 +194,7 @@ const LogLineComponent = memo( [log, onClick] ); + const isLogDetailsFocused = currentLog?.uid === log.uid; const detailsShown = detailsDisplayed(log); return ( @@ -202,13 +202,13 @@ const LogLineComponent = memo( {/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
- + {dedupStrategy !== LogsDedupStrategy.none && (
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} @@ -249,7 +249,7 @@ const LogLineComponent = memo(
)}
{ const setup = ( propOverrides?: Partial, rowOverrides?: Partial, - contextOverrides?: Partial + logListcontextOverrides?: Partial, + logDetailsContextOverrides?: Partial ) => { const logs = [createLogLine({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides })]; @@ -83,13 +84,22 @@ const setup = ( const contextData: LogListContextData = { ...defaultValue, + ...logListcontextOverrides, + }; + + const detailsData: LogDetailsContextData = { + ...emptyContextData, + enableLogDetails: true, showDetails: logs, - ...contextOverrides, + currentLog: logs[0], + ...logDetailsContextOverrides, }; return render( - + + + ); }; @@ -175,7 +185,10 @@ describe('LogLineDetails', () => { onClickFilterLabel: onClickFilterLabelMock, onClickFilterOutLabel: onClickFilterOutLabelMock, isLabelFilterActive: isLabelFilterActiveMock, + }, + { showDetails: [log], + currentLog: log, } ); @@ -278,7 +291,7 @@ describe('LogLineDetails', () => { } ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Fields')).toBeInTheDocument(); expect(screen.getByText('Links')).toBeInTheDocument(); @@ -347,7 +360,7 @@ describe('LogLineDetails', () => { } ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Log line')).toBeInTheDocument(); expect(screen.getByText('Fields')).toBeInTheDocument(); @@ -599,7 +612,7 @@ describe('LogLineDetails', () => { createLogLine({ uid: '1', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'First log' }), createLogLine({ uid: '2', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Second log' }), ]; - setup({ logs }, undefined, { showDetails: logs }); + setup({ logs }, undefined, undefined, { showDetails: logs, currentLog: logs[1] }); expect(screen.queryAllByRole('tab')).toHaveLength(2); @@ -607,70 +620,6 @@ describe('LogLineDetails', () => { expect(screen.getAllByText('First log')).toHaveLength(1); expect(screen.getAllByText('Second log')).toHaveLength(2); - - await userEvent.click(screen.queryAllByRole('tab')[0]); - - expect(screen.getAllByText('First log')).toHaveLength(2); - expect(screen.getAllByText('Second log')).toHaveLength(1); - }); - - test('Changes details focus when logs are added and removed', async () => { - const logs = [ - createLogLine({ uid: '1', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'First log' }), - createLogLine({ uid: '2', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Second log' }), - ]; - - const props: Props = { - containerElement: document.createElement('div'), - focusLogLine: jest.fn(), - logs: [logs[0]], - timeRange: getDefaultTimeRange(), - timeZone: 'browser', - showControls: true, - }; - - const contextData: LogListContextData = { - ...defaultValue, - showDetails: [logs[0]], - }; - - const { rerender } = render( - - - - ); - - expect(screen.queryAllByRole('tab')).toHaveLength(0); - - await userEvent.click(screen.getByText('Log line')); - // Tab not displayed, only line body - expect(screen.getAllByText('First log')).toHaveLength(1); - - contextData.showDetails = logs; - props.logs = logs; - - rerender( - - - - ); - - expect(screen.queryAllByRole('tab')).toHaveLength(2); - // Tab and log line body - expect(screen.getAllByText('Second log')).toHaveLength(2); - - contextData.showDetails = [logs[1]]; - props.logs = [logs[1]]; - - rerender( - - - - ); - - expect(screen.queryAllByRole('tab')).toHaveLength(0); - // Tab not displayed, only line body - expect(screen.getAllByText('Second log')).toHaveLength(1); }); }); @@ -733,7 +682,7 @@ describe('LogLineDetails', () => { }) ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Links')).toBeInTheDocument(); expect(screen.getByText('Trace')).toBeInTheDocument(); @@ -795,7 +744,7 @@ describe('LogLineDetails', () => { }) ); - setup({ logs: [log] }, undefined, { showDetails: [log] }); + setup({ logs: [log] }, undefined, undefined, { showDetails: [log], currentLog: log }); expect(screen.getByText('Links')).toBeInTheDocument(); expect(screen.getByText('Trace')).toBeInTheDocument(); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 2a7b63005f9..19639e61f8c 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -1,15 +1,17 @@ import { css } from '@emotion/css'; import { Resizable } from 're-resizable'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { usePrevious } from 'react-use'; +import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui'; +import { getSidebarWidth } from '../fieldSelector/FieldSelector'; + +import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsComponent } from './LogLineDetailsComponent'; -import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogListContext } from './LogListContext'; +import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; import { LOG_LIST_MIN_WIDTH } from './virtualization'; @@ -26,7 +28,8 @@ export type LogLineDetailsMode = 'inline' | 'sidebar'; export const LogLineDetails = memo( ({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls }: Props) => { - const { detailsWidth, noInteractions, setDetailsWidth } = useLogListContext(); + const { noInteractions, logOptionsStorageKey } = useLogListContext(); + const { detailsWidth, setDetailsWidth } = useLogDetailsContext(); const styles = useStyles2(getStyles, 'sidebar', showControls); const dragStyles = useStyles2(getDragStyles); const containerRef = useRef(null); @@ -45,7 +48,7 @@ export const LogLineDetails = memo( } }, [noInteractions]); - const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; + const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH; return ( ) => { - const { app, closeDetails, noInteractions, showDetails, toggleDetails, wrapLogMessage } = useLogListContext(); - const [currentLog, setCurrentLog] = useState(showDetails[0]); - const previousShowDetails = usePrevious(showDetails); + const { app, noInteractions, wrapLogMessage } = useLogListContext(); + const { currentLog, setCurrentLog, showDetails, toggleDetails } = useLogDetailsContext(); + const styles = useStyles2(getStyles, 'sidebar'); useEffect(() => { // When wrapping is enabled and details is in sidebar mode, the logs panel width changes and the // user may lose focus of the log line, so we scroll to it. - if (wrapLogMessage) { + if (wrapLogMessage && currentLog) { focusLogLine(currentLog); } if (!noInteractions) { @@ -90,25 +93,17 @@ const LogLineDetailsTabs = memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - if (!showDetails.length) { - closeDetails(); - return; - } - // Focus on the recently open - if (!previousShowDetails || showDetails.length > previousShowDetails.length) { - setCurrentLog(showDetails[showDetails.length - 1]); - return; - } else if (!showDetails.find((log) => log.uid === currentLog.uid)) { - setCurrentLog(showDetails[showDetails.length - 1]); - } - }, [closeDetails, currentLog.uid, previousShowDetails, showDetails]); + const tabs = useMemo(() => showDetails.slice().reverse(), [showDetails]); + + if (!currentLog) { + return null; + } return ( <> {showDetails.length > 1 && ( - {showDetails.map((log) => { + {tabs.map((log) => { return ( toggleDetails(log)} + onClick={(e) => { + e.stopPropagation(); + toggleDetails(log); + }} /> )} /> @@ -152,7 +150,8 @@ export interface InlineLogLineDetailsProps { } export const InlineLogLineDetails = memo(({ logs, log, onResize, timeRange, timeZone }: InlineLogLineDetailsProps) => { - const { app, detailsWidth, noInteractions } = useLogListContext(); + const { app, noInteractions } = useLogListContext(); + const { detailsWidth } = useLogDetailsContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx index 24a0c67948b..72c6ce07054 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx @@ -6,6 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Card, IconButton, useStyles2 } from '@grafana/ui'; +import { useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsMode } from './LogLineDetails'; import { useLogListContext } from './LogListContext'; import { reportInteractionOnce } from './analytics'; @@ -89,7 +90,8 @@ const DisplayedField = ({ moveField, provided, }: DraggableDisplayedFieldProps & { provided: DraggableProvided }) => { - const { detailsMode, displayedFields, onClickHideField } = useLogListContext(); + const { displayedFields, onClickHideField } = useLogListContext(); + const { detailsMode } = useLogDetailsContext(); const styles = useStyles2(getStyles, detailsMode); const nextIndex = index === displayedFields.length - 1 ? 0 : index + 1; const prevIndex = index === 0 ? displayedFields.length - 1 : index - 1; diff --git a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx index c6dbda48529..e8482b4d7c0 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx @@ -14,6 +14,7 @@ import { LogLabelStats } from '../LogLabelStats'; import { FieldDef } from '../logParser'; import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats'; +import { useLogDetailsContext } from './LogDetailsContext'; import { useLogListContext } from './LogListContext'; import { LogListModel, getNormalizedFieldName } from './processing'; @@ -139,7 +140,6 @@ export const LogLineDetailsField = ({ const [fieldStats, setFieldStats] = useState(null); const { app, - closeDetails, displayedFields, isLabelFilterActive, noInteractions, @@ -151,6 +151,7 @@ export const LogLineDetailsField = ({ pinLineButtonTooltipTitle, prettifyJSON, } = useLogListContext(); + const { closeDetails } = useLogDetailsContext(); const styles = useStyles2(getFieldStyles); diff --git a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx index 59a576f26a7..40d2b2fa14a 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx @@ -9,6 +9,7 @@ import { IconButton, Input, useStyles2 } from '@grafana/ui'; import { copyText, handleOpenLogsContextClick } from '../../utils'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsMode } from './LogLineDetails'; import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -22,14 +23,11 @@ interface Props { export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Props) => { const { - closeDetails, - detailsMode, displayedFields, getRowContextQuery, logOptionsStorageKey, logSupportsContext, noInteractions, - setDetailsMode, onClickHideField, onClickShowField, onOpenContext, @@ -40,6 +38,7 @@ export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Pr isAssistantAvailable, openAssistantByLog, } = useLogListContext(); + const { closeDetails, detailsMode, setDetailsMode } = useLogDetailsContext(); const pinned = useLogIsPinned(log); const styles = useStyles2(getStyles, detailsMode, wrapLogMessage); const containerRef = useRef(null); diff --git a/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx b/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx index c791facd8d4..6c2dc4f5b94 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLinks.tsx @@ -7,6 +7,7 @@ import { DataLinkButton, Icon, Toggletip, useStyles2 } from '@grafana/ui'; import { FieldDef } from '../logParser'; +import { useLogDetailsContext } from './LogDetailsContext'; import { filterFields, MultipleValue, SingleValue } from './LogLineDetailsFields'; import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -53,7 +54,8 @@ interface LogLineDetailsFieldProps { } export const LogLineDetailsField = ({ field, log }: LogLineDetailsFieldProps) => { - const { closeDetails, onPinLine, pinLineButtonTooltipTitle, prettifyJSON } = useLogListContext(); + const { onPinLine, pinLineButtonTooltipTitle, prettifyJSON } = useLogListContext(); + const { closeDetails } = useLogDetailsContext(); const styles = useStyles2(getFieldStyles); diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx index 28ecad3d8a3..71adc3a1c02 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx @@ -7,6 +7,7 @@ import { LogMessageAnsi } from '../LogMessageAnsi'; import { HighlightedLogRenderer } from './HighlightedLogRenderer'; import { getStyles } from './LogLine'; +import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; interface Props { @@ -15,6 +16,7 @@ interface Props { } export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: Props) => { + const { fontSize } = useLogListContext(); const logStyles = useStyles2(getStyles); const log = useMemo(() => { const log = originalLog.clone(); @@ -23,7 +25,7 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: return (
-
+
{log.hasAnsi ? ( @@ -52,4 +54,8 @@ const styles = { maxHeight: '50vh', overflow: 'auto', }), + noHover: css({ + // Disable hover style + pointerEvents: 'none', + }), }; diff --git a/public/app/features/logs/components/panel/LogLineMenu.test.tsx b/public/app/features/logs/components/panel/LogLineMenu.test.tsx index 78e11921e8a..cadd99b3c41 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.test.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.test.tsx @@ -5,6 +5,7 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana import { createLogLine } from '../mocks/logRow'; +import { LogDetailsContextProvider } from './LogDetailsContext'; import { getStyles } from './LogLine'; import { LogLineMenu, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider } from './LogListContext'; @@ -158,8 +159,10 @@ describe('LogLineMenu', () => { test('Allows to open log details', async () => { render( - - + + + + ); await userEvent.click(screen.getByLabelText('Log menu')); @@ -168,8 +171,10 @@ describe('LogLineMenu', () => { test('Does not show log details option when disabled', async () => { render( - - + + + + ); await userEvent.click(screen.getByLabelText('Log menu')); diff --git a/public/app/features/logs/components/panel/LogLineMenu.tsx b/public/app/features/logs/components/panel/LogLineMenu.tsx index a7831da2c64..f28fbe267d1 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.tsx @@ -7,6 +7,7 @@ import { Dropdown, IconButton, Menu } from '@grafana/ui'; import { copyText, handleOpenLogsContextClick } from '../../utils'; +import { useLogDetailsContext } from './LogDetailsContext'; import { LogLineStyles } from './LogLine'; import { useLogIsPinned, useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; @@ -29,14 +30,13 @@ type MenuItemDivider = { export type LogLineMenuCustomItem = MenuItem | MenuItemDivider; interface Props { + active?: boolean; log: LogListModel; styles: LogLineStyles; } -export const LogLineMenu = ({ log, styles }: Props) => { +export const LogLineMenu = ({ active, log, styles }: Props) => { const { - enableLogDetails, - detailsDisplayed, getRowContextQuery, onOpenContext, onPermalinkClick, @@ -44,10 +44,10 @@ export const LogLineMenu = ({ log, styles }: Props) => { onUnpinLine, logLineMenuCustomItems = [], logSupportsContext, - toggleDetails, isAssistantAvailable, openAssistantByLog, } = useLogListContext(); + const { enableLogDetails, detailsDisplayed, toggleDetails } = useLogDetailsContext(); const pinned = useLogIsPinned(log); const menuRef = useRef(null); @@ -158,9 +158,10 @@ export const LogLineMenu = ({ log, styles }: Props) => { ); diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 73c3a04fb02..38080a00657 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -104,112 +104,6 @@ describe('LogList', () => { expect(onLogRowHover).toHaveBeenCalledWith(expect.objectContaining(logs[0])); }); - test('Supports showing log details', async () => { - jest.spyOn(store, 'get').mockImplementation((option: string) => { - if (option === 'storage-key.detailsMode') { - return 'sidebar'; - } - return undefined; - }); - const onClickFilterLabel = jest.fn(); - const onClickFilterOutLabel = jest.fn(); - const onClickShowField = jest.fn(); - - render( - - ); - - await userEvent.click(screen.getByText('log message 1')); - await screen.findByText('Fields'); - - expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); - expect(screen.getByText('value of the label')).toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText('Filter for value in query A')); - expect(onClickFilterLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Filter out value in query A')); - expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Show this field instead of the message')); - expect(onClickShowField).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Close log details')); - - expect(screen.queryByText('Fields')).not.toBeInTheDocument(); - expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); - }); - - test('Supports showing inline log details', async () => { - jest.spyOn(store, 'get').mockImplementation((option: string) => { - if (option === 'storage-key.detailsMode') { - return 'inline'; - } - return undefined; - }); - const onClickFilterLabel = jest.fn(); - const onClickFilterOutLabel = jest.fn(); - const onClickShowField = jest.fn(); - - render( - - ); - - await userEvent.click(screen.getByText('log message 1')); - await screen.findByText('Fields'); - - expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); - expect(screen.getByText('value of the label')).toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText('Filter for value in query A')); - expect(onClickFilterLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Filter out value in query A')); - expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Show this field instead of the message')); - expect(onClickShowField).toHaveBeenCalledTimes(1); - - await userEvent.click(screen.getByLabelText('Close log details')); - - expect(screen.queryByText('Fields')).not.toBeInTheDocument(); - expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); - }); - - test('Allows people to select text without opening log details', async () => { - const spy = jest.spyOn(document, 'getSelection'); - spy.mockReturnValue({ - toString: () => 'selected log line', - removeAllRanges: () => {}, - addRange: (range: Range) => {}, - } as Selection); - - render(); - - await userEvent.click(screen.getByText('log message 1')); - - expect(screen.queryByText('name_of_the_label')).not.toBeInTheDocument(); - expect(screen.queryByText('value of the label')).not.toBeInTheDocument(); - expect(screen.queryByText('Fields')).not.toBeInTheDocument(); - expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); - - spy.mockRestore(); - }); - test('Shows controls with level filters based on the displayed logs', async () => { logs = [createLogRow({ uid: '1', logLevel: LogLevel.info }), createLogRow({ uid: '2', logLevel: LogLevel.debug })]; @@ -578,4 +472,193 @@ describe('LogList', () => { config.featureToggles.otelLogsFormatting = originalState; }); }); + + describe('Log details', () => { + test('Supports showing log details', async () => { + jest.spyOn(store, 'get').mockImplementation((option: string) => { + if (option === 'storage-key.detailsMode') { + return 'sidebar'; + } + return undefined; + }); + const onClickFilterLabel = jest.fn(); + const onClickFilterOutLabel = jest.fn(); + const onClickShowField = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText('log message 1')); + await screen.findByText('Fields'); + + expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); + expect(screen.getByText('value of the label')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + expect(onClickShowField).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Close log details')); + + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + }); + + test('Supports showing inline log details', async () => { + jest.spyOn(store, 'get').mockImplementation((option: string) => { + if (option === 'storage-key.detailsMode') { + return 'inline'; + } + return undefined; + }); + const onClickFilterLabel = jest.fn(); + const onClickFilterOutLabel = jest.fn(); + const onClickShowField = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText('log message 1')); + await screen.findByText('Fields'); + + expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); + expect(screen.getByText('value of the label')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + expect(onClickShowField).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Close log details')); + + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + }); + + test('Allows people to select text without opening log details', async () => { + const spy = jest.spyOn(document, 'getSelection'); + spy.mockReturnValue({ + toString: () => 'selected log line', + removeAllRanges: () => {}, + addRange: (range: Range) => {}, + } as Selection); + + render(); + + await userEvent.click(screen.getByText('log message 1')); + + expect(screen.queryByText('name_of_the_label')).not.toBeInTheDocument(); + expect(screen.queryByText('value of the label')).not.toBeInTheDocument(); + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + + spy.mockRestore(); + }); + + test('Renders multiple log details', async () => { + const logs = [ + createLogLine({ uid: '1', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'First log' }), + createLogLine({ uid: '2', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Second log' }), + ]; + + render(); + + // Open details of 2 logs + await userEvent.click(screen.getByText('First log')); + await userEvent.click(screen.getByText('Second log')); + + // 2 tabs + expect(screen.queryAllByRole('tab')).toHaveLength(2); + + // Expand Log line section inside Details + await userEvent.click(screen.getByText('Log line')); + + // Tab + log line in the list + expect(screen.getAllByText('First log')).toHaveLength(2); + // Tab + log line in the list + Log details (active tab) + expect(screen.getAllByText('Second log')).toHaveLength(3); + + // Make first log active + await userEvent.click(screen.queryAllByRole('tab')[1]); + + // Tab + log line in the list + Log details (active tab) + expect(screen.getAllByText('First log')).toHaveLength(3); + // Tab + log line in the list + expect(screen.getAllByText('Second log')).toHaveLength(2); + }); + + test('Changes details focus when logs are added and removed', async () => { + const logs = [ + createLogLine({ uid: '1', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'First log' }), + createLogLine({ uid: '2', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Second log' }), + createLogLine({ uid: '3', logLevel: LogLevel.error, timeEpochMs: 1546297200000, entry: 'Third log' }), + ]; + + render(); + + // No details shown + expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('First log')); + + // Details shown + expect(screen.getByPlaceholderText('Search field names and values')).toBeInTheDocument(); + + // No tabs, only one details displayed + expect(screen.queryAllByRole('tab')).toHaveLength(0); + + await userEvent.click(screen.getByText('Second log')); + + // 2 details displayed, Second log is the first tab + expect(screen.queryAllByRole('tab')).toHaveLength(2); + expect(screen.queryAllByRole('tab')[0]).toHaveTextContent('Second log'); + + await userEvent.click(screen.getByText('Third log')); + + // 3 details displayed, Second log is the first tab + expect(screen.queryAllByRole('tab')).toHaveLength(3); + expect(screen.queryAllByRole('tab')[0]).toHaveTextContent('Third log'); + + await userEvent.click(screen.getAllByText('Third log')[1]); + + // 2 details displayed, Second log is the first tab + expect(screen.queryAllByRole('tab')).toHaveLength(2); + expect(screen.queryAllByRole('tab')[0]).toHaveTextContent('Second log'); + + await userEvent.click(screen.getAllByText('Second log')[1]); + + // No tabs, only one details displayed + expect(screen.queryAllByRole('tab')).toHaveLength(0); + + await userEvent.click(screen.getByText('First log')); + + // No details shown + expect(screen.queryByPlaceholderText('Search field names and values')).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 27a9379c8b8..8a8a9b085dc 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -26,6 +26,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { LogListFieldSelector } from '../fieldSelector/FieldSelector'; import { InfiniteScrollMode, InfiniteScroll, LoadMoreLogsType } from './InfiniteScroll'; +import { LogDetailsContextProvider, useLogDetailsContext } from './LogDetailsContext'; import { getGridTemplateColumns, LogLineTimestampResolution } from './LogLine'; import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; @@ -174,9 +175,7 @@ export const LogList = ({ app={app} containerElement={containerElement} dedupStrategy={dedupStrategy} - detailsMode={detailsMode} displayedFields={displayedFields} - enableLogDetails={enableLogDetails} filterLevels={filterLevels} fontSize={fontSize} getRowContextQuery={getRowContextQuery} @@ -213,24 +212,33 @@ export const LogList = ({ timestampResolution={timestampResolution} wrapLogMessage={wrapLogMessage} > - - - + + + + + ); }; @@ -255,7 +263,6 @@ const LogListComponent = ({ app, displayedFields, dedupStrategy, - detailsMode, filterLevels, fontSize, forceEscape, @@ -265,14 +272,13 @@ const LogListComponent = ({ onClickFilterOutString, permalinkedLogId, prettifyJSON, - showDetails, showTime, showUniqueLabels, sortOrder, timestampResolution, - toggleDetails, wrapLogMessage, } = useLogListContext(); + const { detailsMode, showDetails, toggleDetails } = useLogDetailsContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState(getListHeight(containerElement, app)); const theme = useTheme2(); diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 0539743fb02..a917b0eac56 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -1,4 +1,3 @@ -import { debounce } from 'lodash'; import { createContext, Dispatch, @@ -31,22 +30,16 @@ import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFor import { getSidebarState } from '../fieldSelector/FieldSelector'; import { getDisplayedFieldsForLogs } from '../otel/formats'; +import { getDefaultDetailsMode, getDetailsWidth } from './LogDetailsContext'; import { LogLineTimestampResolution } from './LogLine'; -import { LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; 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'; export interface LogListContextData extends Omit { - closeDetails: () => void; controlsExpanded: boolean; - detailsDisplayed: (log: LogListModel) => boolean; - detailsMode: LogLineDetailsMode; - detailsWidth: number; downloadLogs: (format: DownloadFormat) => void; - enableLogDetails: boolean; filterLevels: LogLevel[]; forceEscape: boolean; hasLogsWithErrors?: boolean; @@ -55,8 +48,6 @@ export interface LogListContextData extends Omit void; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; - setDetailsMode: (mode: LogLineDetailsMode) => void; - setDetailsWidth: (width: number) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; setFontSize: (size: LogListFontSize) => void; setForceEscape: (forceEscape: boolean) => void; @@ -69,24 +60,17 @@ export interface LogListContextData extends Omit void; setTimestampResolution: (format: LogLineTimestampResolution) => void; setWrapLogMessage: (showTime: boolean) => void; - showDetails: LogListModel[]; timestampResolution: LogLineTimestampResolution; - toggleDetails: (log: LogListModel) => void; isAssistantAvailable: boolean; openAssistantByLog: ((log: LogListModel) => void) | undefined; } export const LogListContext = createContext({ app: CoreApp.Unknown, - closeDetails: () => {}, controlsExpanded: false, dedupStrategy: LogsDedupStrategy.none, - detailsDisplayed: () => false, - detailsMode: 'sidebar', - detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, - enableLogDetails: false, filterLevels: [], forceEscape: false, fontSize: 'default', @@ -94,8 +78,6 @@ export const LogListContext = createContext({ noInteractions: false, setControlsExpanded: () => {}, setDedupStrategy: () => {}, - setDetailsMode: () => {}, - setDetailsWidth: () => {}, setFilterLevels: () => {}, setFontSize: () => {}, setForceEscape: () => {}, @@ -108,12 +90,10 @@ export const LogListContext = createContext({ setSyntaxHighlighting: () => {}, setTimestampResolution: () => {}, setWrapLogMessage: () => {}, - showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, timestampResolution: 'ns', - toggleDetails: () => {}, wrapLogMessage: false, isAssistantAvailable: false, openAssistantByLog: () => {}, @@ -157,10 +137,8 @@ export interface Props { children?: ReactNode; // Only ControlledLogRows can send an undefined containerElement. See LogList.tsx containerElement?: HTMLDivElement; - detailsMode?: LogLineDetailsMode; dedupStrategy: LogsDedupStrategy; displayedFields: string[]; - enableLogDetails: boolean; filterLevels?: LogLevel[]; fontSize: LogListFontSize; getRowContextQuery?: GetRowContextQueryFn; @@ -202,11 +180,7 @@ export const LogListContextProvider = ({ app, children, containerElement, - enableLogDetails, logOptionsStorageKey, - detailsMode: detailsModeProp = logOptionsStorageKey - ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? getDefaultDetailsMode(containerElement)) - : getDefaultDetailsMode(containerElement), dedupStrategy, displayedFields, filterLevels, @@ -259,13 +233,6 @@ export const LogListContextProvider = ({ syntaxHighlighting, timestampResolution, }); - const [showDetails, setShowDetails] = useState([]); - const [detailsWidth, setDetailsWidthState] = useState( - getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls) - ); - const [detailsMode, setDetailsMode] = useState( - detailsModeProp ?? getDefaultDetailsMode(containerElement) - ); const { isAvailable: isAssistantAvailable, openAssistant } = useAssistant(); const [prettifyJSON, setPrettifyJSONState] = useState(prettifyJSONProp); const [wrapLogMessage, setWrapLogMessageState] = useState(wrapLogMessageProp); @@ -284,8 +251,10 @@ export const LogListContextProvider = ({ syntaxHighlighting, wrapLogMessage, prettifyJSON, - detailsWidth, - detailsMode, + detailsWidth: getDetailsWidth(containerElement, logOptionsStorageKey), + detailsMode: logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.detailsMode`) ?? getDefaultDetailsMode(containerElement)) + : getDefaultDetailsMode(containerElement), withDisplayedFields: displayedFields.length > 0, timestampResolution: logListState.timestampResolution, }); @@ -348,13 +317,6 @@ export const LogListContextProvider = ({ }); }, [filterLevels]); - // Sync details mode - useEffect(() => { - if (detailsModeProp) { - setDetailsMode(detailsModeProp); - } - }, [detailsModeProp]); - // Sync font size useEffect(() => { setLogListState((logListState) => ({ ...logListState, fontSize })); @@ -367,39 +329,6 @@ export const LogListContextProvider = ({ } }, [logListState, pinnedLogs]); - // Sync show details - useEffect(() => { - if (!showDetails.length) { - return; - } - const newShowDetails = showDetails.filter( - (expandedLog) => logs.findIndex((log) => log.uid === expandedLog.uid) >= 0 - ); - if (newShowDetails.length !== showDetails.length) { - setShowDetails(newShowDetails); - } - }, [logs, showDetails]); - - // Sync log details inline and sidebar width - useEffect(() => { - setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls)); - }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); - - // Sync log details width - useEffect(() => { - if (!containerElement) { - return; - } - const handleResize = debounce(() => { - setDetailsWidthState((detailsWidth) => - getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls) - ); - }, 50); - const observer = new ResizeObserver(() => handleResize()); - observer.observe(containerElement); - return () => observer.disconnect(); - }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); - // Sync prettifyJSON useEffect(() => { if (prettifyJSONProp !== undefined) { @@ -434,11 +363,6 @@ export const LogListContextProvider = ({ // If the user has a large viewport, show the expanded state by default const [controlsExpanded, setControlsExpanded] = useState(controlsExpandedFromStore); - const detailsDisplayed = useCallback( - (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), - [showDetails] - ); - const setDedupStrategy = useCallback( (dedupStrategy: LogsDedupStrategy) => { setLogListState({ ...logListState, dedupStrategy }); @@ -564,45 +488,6 @@ export const LogListContextProvider = ({ [displayedFields, logListState.filterLevels, logs, logsMeta] ); - const closeDetails = useCallback(() => { - showDetails.forEach((log) => removeDetailsScrollPosition(log)); - setShowDetails([]); - }, [showDetails]); - - const toggleDetails = useCallback( - (log: LogListModel) => { - if (!enableLogDetails) { - return; - } - const found = showDetails.find((stateLog) => stateLog === log || stateLog.uid === log.uid); - if (found) { - removeDetailsScrollPosition(found); - setShowDetails(showDetails.filter((stateLog) => stateLog !== log && stateLog.uid !== log.uid)); - } else { - // Supporting one displayed details for now - setShowDetails([...showDetails, log]); - } - }, - [enableLogDetails, showDetails] - ); - - const setDetailsWidth = useCallback( - (width: number) => { - if (!logOptionsStorageKey || !containerElement) { - return; - } - - const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; - if (width > maxWidth) { - return; - } - - store.set(`${logOptionsStorageKey}.detailsWidth`, width); - setDetailsWidthState(width); - }, - [containerElement, logOptionsStorageKey] - ); - const setTimestampResolution = useCallback( (timestampResolution: LogLineTimestampResolution) => { if (logOptionsStorageKey) { @@ -634,15 +519,10 @@ export const LogListContextProvider = ({ = containerElement.clientWidth || detailsWidth > maxWidth) { - return currentWidth ?? defaultWidth; - } - return detailsWidth; -} - -const detailsScrollMap = new Map(); - -export function saveDetailsScrollPosition(log: LogListModel, position: number) { - detailsScrollMap.set(log.uid, position); -} - -export function getDetailsScrollPosition(log: LogListModel) { - return detailsScrollMap.get(log.uid) ?? 0; -} - -export function removeDetailsScrollPosition(log: LogListModel) { - detailsScrollMap.delete(log.uid); -} - async function handleOpenAssistant(openAssistant: (props: OpenAssistantProps) => void, log: LogListModel) { const datasource = await getDataSourceSrv().get(log.datasourceUid); const context = []; @@ -793,11 +625,6 @@ ${log.entry.replaceAll('`', '\\`')} }); } -export function getDefaultDetailsMode(container: HTMLDivElement | undefined): LogLineDetailsMode { - const width = container?.clientWidth ?? window.innerWidth; - return width > 1440 ? 'sidebar' : 'inline'; -} - export function getDefaultControlsExpandedMode(container: HTMLDivElement | null): boolean { const width = container?.clientWidth ?? window.innerWidth; return width > 1200; diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 0f7074c7972..5b482af2aad 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -22,7 +22,7 @@ import { DownloadFormat } from '../../utils'; import { useLogListContext } from './LogListContext'; import { LogListControlsOption, LogListControlsSelectOption } from './LogListControlsOption'; import { useLogListSearchContext } from './LogListSearchContext'; -import { ScrollToLogsEvent } from './virtualization'; +import { LOG_LIST_CONTROLS_WIDTH, ScrollToLogsEvent } from './virtualization'; type Props = { eventBus: EventBus; @@ -757,7 +757,6 @@ const getWrapButtonStyles = (theme: GrafanaTheme2, expanded: boolean) => { }; }; -export const CONTROLS_WIDTH = 35; export const CONTROLS_WIDTH_EXPANDED = 176; const getStyles = (theme: GrafanaTheme2, controlsExpanded: boolean) => { @@ -769,7 +768,7 @@ const getStyles = (theme: GrafanaTheme2, controlsExpanded: boolean) => { gap: theme.spacing(3), flexDirection: 'column', justifyContent: 'flex-start', - width: controlsExpanded ? CONTROLS_WIDTH_EXPANDED : CONTROLS_WIDTH, + width: controlsExpanded ? CONTROLS_WIDTH_EXPANDED : LOG_LIST_CONTROLS_WIDTH, paddingTop: theme.spacing(0.75), paddingLeft: theme.spacing(1), borderLeft: `solid 1px ${theme.colors.border.medium}`, diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index af289c71f2c..5960b82d5f5 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -17,19 +17,14 @@ jest.mock('@grafana/assistant', () => { export const LogListContext = createContext({ app: CoreApp.Unknown, - closeDetails: () => {}, dedupStrategy: LogsDedupStrategy.none, - detailsDisplayed: () => false, - detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, - enableLogDetails: false, filterLevels: [], fontSize: 'default', forceEscape: false, hasUnescapedContent: false, setDedupStrategy: () => {}, - setDetailsWidth: () => {}, setFilterLevels: () => {}, setFontSize: () => {}, setForceEscape: () => {}, @@ -42,15 +37,11 @@ export const LogListContext = createContext({ setSyntaxHighlighting: () => {}, setTimestampResolution: () => {}, setWrapLogMessage: () => {}, - showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, timestampResolution: 'ns', - toggleDetails: () => {}, wrapLogMessage: false, - detailsMode: 'sidebar', - setDetailsMode: () => {}, isAssistantAvailable: false, openAssistantByLog: () => {}, controlsExpanded: false, @@ -77,8 +68,6 @@ export const useLogIsPermalinked = (log: LogListModel) => { }; export const defaultValue: LogListContextData = { - detailsMode: 'sidebar', - setDetailsMode: jest.fn(), setDedupStrategy: jest.fn(), setFilterLevels: jest.fn(), setFontSize: jest.fn(), @@ -92,18 +81,11 @@ export const defaultValue: LogListContextData = { setSyntaxHighlighting: jest.fn(), setTimestampResolution: jest.fn(), setWrapLogMessage: jest.fn(), - closeDetails: jest.fn(), - detailsDisplayed: jest.fn(), - detailsWidth: 300, downloadLogs: jest.fn(), - enableLogDetails: false, filterLevels: [], fontSize: 'default', forceEscape: false, hasUnescapedContent: false, - setDetailsWidth: jest.fn(), - showDetails: [], - toggleDetails: jest.fn(), app: CoreApp.Explore, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], @@ -122,7 +104,6 @@ export const defaultProps: Props = { containerElement: document.createElement('div'), dedupStrategy: LogsDedupStrategy.none, displayedFields: [], - enableLogDetails: false, filterLevels: [], fontSize: 'default', getRowContextQuery: jest.fn(), @@ -146,7 +127,6 @@ export const LogListContextProvider = ({ children, dedupStrategy = LogsDedupStrategy.none, displayedFields = [], - enableLogDetails = false, filterLevels = [], getRowContextQuery = jest.fn(), logLineMenuCustomItems = undefined, @@ -159,13 +139,12 @@ export const LogListContextProvider = ({ onUnpinLine = jest.fn(), permalinkedLogId, pinnedLogs = [], - showDetails = [], showTime = true, sortOrder = LogsSortOrder.Descending, syntaxHighlighting = true, timestampResolution = 'ms', wrapLogMessage = true, -}: Partial & { showDetails?: LogListModel[] }) => { +}: Partial) => { const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log)); const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log)); @@ -177,7 +156,6 @@ export const LogListContextProvider = ({ dedupStrategy, displayedFields, downloadLogs: jest.fn(), - enableLogDetails, hasLogsWithErrors, hasSampledLogs, filterLevels, @@ -203,7 +181,6 @@ export const LogListContextProvider = ({ setSortOrder: jest.fn(), setSyntaxHighlighting: jest.fn(), setWrapLogMessage: jest.fn(), - showDetails, showTime, sortOrder, syntaxHighlighting, @@ -215,9 +192,3 @@ export const LogListContextProvider = ({ ); }; - -export const saveDetailsScrollPosition = jest.fn(); - -export const getDetailsScrollPosition = jest.fn(); - -export const removeDetailsScrollPosition = jest.fn(); diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index fde63fc3422..4c2c77b7c64 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -13,7 +13,7 @@ export const FIELD_GAP_MULTIPLIER = 1.5; export const DEFAULT_LINE_HEIGHT = 22; -export const LOG_LIST_CONTROLS_WIDTH = 32; +export const LOG_LIST_CONTROLS_WIDTH = 35; export class LogLineVirtualization { private ctx: CanvasRenderingContext2D | null = null; From 2ac4d0a13e2ea4bf8b225b8fee91a0d49de07c3e Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 6 Nov 2025 14:11:53 -0600 Subject: [PATCH 072/209] Chore: Remove Intl.DurationFormat polyfill from runtime (#113475) --- packages/grafana-i18n/src/types/dates.d.ts | 2 +- public/app/app.ts | 1 - public/app/types/intl.d.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/grafana-i18n/src/types/dates.d.ts b/packages/grafana-i18n/src/types/dates.d.ts index 7a4ac64930c..3d728f70d85 100644 --- a/packages/grafana-i18n/src/types/dates.d.ts +++ b/packages/grafana-i18n/src/types/dates.d.ts @@ -1,4 +1,4 @@ -import { +import type { DurationFormatConstructor, DurationFormatOptions as _DurationFormatOptions, DurationInput as _DurationInput, diff --git a/public/app/app.ts b/public/app/app.ts index 76a77a46fd7..b95b60fac40 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -1,7 +1,6 @@ import 'symbol-observable'; import 'regenerator-runtime/runtime'; -import '@formatjs/intl-durationformat/polyfill'; import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering import 'file-saver'; import 'jquery'; diff --git a/public/app/types/intl.d.ts b/public/app/types/intl.d.ts index 7a4ac64930c..3d728f70d85 100644 --- a/public/app/types/intl.d.ts +++ b/public/app/types/intl.d.ts @@ -1,4 +1,4 @@ -import { +import type { DurationFormatConstructor, DurationFormatOptions as _DurationFormatOptions, DurationInput as _DurationInput, From 69060f543735dec1c09ed80badfe76860ad504d3 Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Thu, 6 Nov 2025 12:19:39 -0800 Subject: [PATCH 073/209] CloudWatch Logs: Limit CloudWatch logs queries to use logGroupIdentifiers only for monitoring accounts (#113137) * Set the log group name when executing log queries from the frontend * Add helper for a data source instance to check if its a monitoring account * Execute log queries with log group identifiers only for monitoring account queries * fix cloudwatch datasource.ts tests * remove unneeded check --- pkg/tsdb/cloudwatch/cloudwatch.go | 35 +++++- pkg/tsdb/cloudwatch/log_actions.go | 51 +++++++-- pkg/tsdb/cloudwatch/log_actions_test.go | 100 ++++++++++++++++-- .../datasource/cloudwatch/datasource.test.ts | 9 +- .../CloudWatchLogsQueryRunner.test.ts | 59 ++++++++++- .../query-runner/CloudWatchLogsQueryRunner.ts | 18 +++- 6 files changed, 240 insertions(+), 32 deletions(-) diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 3ebe95515c0..7272c08836e 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "slices" + "sync" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -54,9 +55,10 @@ type DataSource struct { ProxyOpts *proxy.Options AWSConfigProvider awsauth.ConfigProvider - logger log.Logger - tagValueCache *cache.Cache - resourceHandler backend.CallResourceHandler + logger log.Logger + tagValueCache *cache.Cache + resourceHandler backend.CallResourceHandler + monitoringAccountCache sync.Map } func (ds *DataSource) newAWSConfig(ctx context.Context, region string) (aws.Config, error) { @@ -273,6 +275,33 @@ func (ds *DataSource) getRGTAClient(ctx context.Context, region string) (resourc return NewRGTAClient(cfg), nil } +func (ds *DataSource) isMonitoringAccount(ctx context.Context, region string) (bool, error) { + if value, ok := ds.monitoringAccountCache.Load(region); ok { + cached := value.(bool) + return cached, nil + } + + client, err := ds.GetAccountsService(ctx, region) + if err != nil { + return false, err + } + + accounts, err := client.GetAccountsForCurrentUserOrRole(ctx) + if err != nil { + return false, err + } + + for _, account := range accounts { + if account.Value.IsMonitoringAccount { + ds.monitoringAccountCache.Store(region, true) + return true, nil + } + } + + ds.monitoringAccountCache.Store(region, false) + return false, nil +} + var terminatedStates = []cloudwatchlogstypes.QueryStatus{ cloudwatchlogstypes.QueryStatusComplete, cloudwatchlogstypes.QueryStatusCancelled, diff --git a/pkg/tsdb/cloudwatch/log_actions.go b/pkg/tsdb/cloudwatch/log_actions.go index 8a6022d44fa..60ebad06442 100644 --- a/pkg/tsdb/cloudwatch/log_actions.go +++ b/pkg/tsdb/cloudwatch/log_actions.go @@ -213,17 +213,48 @@ func (ds *DataSource) executeStartQuery(ctx context.Context, logsClient models.C // log group identifiers can be left out if the query is an SQL query if *logsQuery.QueryLanguage != dataquery.LogsQueryLanguageSQL { - if len(logsQuery.LogGroups) > 0 && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) { - var logGroupIdentifiers []string - for _, lg := range logsQuery.LogGroups { - arn := lg.Arn - // due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error - logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*")) + useLogGroupIdentifiers := false + logGroupsFromQuery := len(logsQuery.LogGroups) > 0 + if logGroupsFromQuery && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) { + region := logsQuery.Region + if region == "" || region == defaultRegion { + region = ds.Settings.Region + } + if region != "" { + isMonitoringAccount, err := ds.isMonitoringAccount(ctx, region) + if err != nil { + ds.logger.FromContext(ctx).Debug("failed to determine monitoring account status", "err", err) + } else if isMonitoringAccount { + // monitoring accounts require querying by log group identifiers because log group names are not unique across accounts. + var logGroupIdentifiers []string + for _, lg := range logsQuery.LogGroups { + // due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error + arn := strings.TrimSuffix(lg.Arn, "*") + logGroupIdentifiers = append(logGroupIdentifiers, arn) + } + startQueryInput.LogGroupIdentifiers = logGroupIdentifiers + useLogGroupIdentifiers = true + } + } + } + + if !useLogGroupIdentifiers { + // even though logsQuery.LogGroupNames is deprecated, we still need to support it for backwards compatibility and alert queries + startQueryInput.LogGroupNames = append([]string(nil), logsQuery.LogGroupNames...) + if len(startQueryInput.LogGroupNames) == 0 && logGroupsFromQuery { + // deduplicate log group names because we only deduplicate log groups by their ARNs instead of their names when the query is created + seenLogGroupNames := make(map[string]struct{}, len(logsQuery.LogGroups)) + for _, lg := range logsQuery.LogGroups { + if lg.Name == "" { + continue + } + if _, exists := seenLogGroupNames[lg.Name]; exists { + continue + } + seenLogGroupNames[lg.Name] = struct{}{} + startQueryInput.LogGroupNames = append(startQueryInput.LogGroupNames, lg.Name) + } } - startQueryInput.LogGroupIdentifiers = logGroupIdentifiers - } else { - // even though log group names are being phased out, we still need to support them for backwards compatibility and alert queries - startQueryInput.LogGroupNames = logsQuery.LogGroupNames } } diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index ae1a79434bd..142b1d0e24d 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -445,7 +445,9 @@ func Test_executeStartQuery(t *testing.T) { t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled", func(t *testing.T) { cli = fakeCWLogsClient{} - ds := newTestDatasource() + ds := newTestDatasource(func(ds *DataSource) { + ds.monitoringAccountCache.Store("us-east-1", true) + }) _, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -459,7 +461,8 @@ func Test_executeStartQuery(t *testing.T) { "limit": 12, "queryLanguage": "CWLI", "queryString":"fields @message", - "logGroups":[{"arn": "fakeARN"}] + "logGroups":[{"arn": "fakeARN"}], + "region": "us-east-1" }`), }, }, @@ -480,7 +483,9 @@ func Test_executeStartQuery(t *testing.T) { t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled and strips out trailing *", func(t *testing.T) { cli = fakeCWLogsClient{} - ds := newTestDatasource() + ds := newTestDatasource(func(ds *DataSource) { + ds.monitoringAccountCache.Store("us-east-1", true) + }) _, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, @@ -493,7 +498,8 @@ func Test_executeStartQuery(t *testing.T) { "subtype": "StartQuery", "limit": 12, "queryString":"fields @message", - "logGroups":[{"arn": "*fake**ARN*"}] + "logGroups":[{"arn": "*fake**ARN*"}], + "region": "us-east-1" }`), }, }, @@ -512,6 +518,44 @@ func Test_executeStartQuery(t *testing.T) { }, cli.calls.startQuery) }) + t.Run("queries by LogGroupNames on StartQueryInput when queried region is not a monitoring account region for the data source", func(t *testing.T) { + cli = fakeCWLogsClient{} + ds := newTestDatasource(func(ds *DataSource) { + // note that the query's region is set to us-east-2, but the data source is only a monitoring account in us-east-1 so it should query by LogGroupNames + ds.monitoringAccountCache.Store("us-east-1", true) + }) + + _, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12, + "queryString":"fields @message", + "logGroups":[{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group","name":"/log-group"}], + "region": "us-east-2" + }`), + }, + }, + }) + + assert.NoError(t, err) + assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ + { + StartTime: aws.Int64(0), + EndTime: aws.Int64(1), + Limit: aws.Int32(12), + QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupNames: []string{"/log-group"}, + QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli, + }, + }, cli.calls.startQuery) + }) + t.Run("uses LogGroupNames if the cross account feature flag is not enabled, and log group names is present", func(t *testing.T) { cli = fakeCWLogsClient{} ds := newTestDatasource() @@ -545,6 +589,42 @@ func Test_executeStartQuery(t *testing.T) { }, cli.calls.startQuery) }) + t.Run("deduplicates log group names when derived from logGroups", func(t *testing.T) { + cli = fakeCWLogsClient{} + ds := newTestDatasource() + + _, err := ds.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12, + "queryString":"fields @message", + "logGroups":[ + {"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group1","name":"/log-group"}, + {"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group2","name":"/log-group"} + ] + }`), + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ + { + StartTime: aws.Int64(0), + EndTime: aws.Int64(1), + Limit: aws.Int32(12), + QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupNames: []string{"/log-group"}, + QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli, + }, + }, cli.calls.startQuery) + }) + t.Run("ignores logGroups if feature flag is disabled even if logGroupNames is not present", func(t *testing.T) { cli = fakeCWLogsClient{} ds := newTestDatasource() @@ -600,12 +680,12 @@ func Test_executeStartQuery(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ { - StartTime: aws.Int64(0), - EndTime: aws.Int64(1), - Limit: aws.Int32(12), - QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), - LogGroupIdentifiers: []string{"*fake**ARN"}, - QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli, + StartTime: aws.Int64(0), + EndTime: aws.Int64(1), + Limit: aws.Int32(12), + QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupNames: []string{"/log-group"}, + QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli, }, }, cli.calls.startQuery) }) diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index 3186c92121c..9368ed93d5b 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -263,8 +263,8 @@ describe('datasource', () => { expect(queryMock.mock.calls[0][0].targets[0]).toMatchObject({ queryString: 'fields templatedField', logGroups: [ - { name: 'templatedGroup-arn-1', arn: 'templatedGroup-arn-1' }, - { name: 'templatedGroup-arn-2', arn: 'templatedGroup-arn-2' }, + { name: 'templatedGroup-1', arn: 'templatedGroup-arn-1' }, + { name: 'templatedGroup-2', arn: 'templatedGroup-arn-2' }, ], logGroupNames: ['/some/group'], region: 'templatedRegion', @@ -394,8 +394,9 @@ describe('datasource', () => { expect(templateService.replace).toHaveBeenNthCalledWith(1, '$regionVar', {}); expect(templateService.replace).toHaveBeenNthCalledWith(2, '$groups', {}, 'pipe'); - expect(templateService.replace).toHaveBeenNthCalledWith(3, '$expressionVar', {}, undefined); - expect(templateService.replace).toHaveBeenCalledTimes(3); + expect(templateService.replace).toHaveBeenNthCalledWith(3, '$groups', {}, 'text'); + expect(templateService.replace).toHaveBeenNthCalledWith(4, '$expressionVar', {}, undefined); + expect(templateService.replace).toHaveBeenCalledTimes(4); }); it('should replace correct variables in CloudWatchMetricsQuery', () => { diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts index ec7715dc4df..55d411585a3 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts @@ -10,7 +10,7 @@ import { LogRowModel, } from '@grafana/data'; -import { regionVariable } from '../mocks/CloudWatchDataSource'; +import { logGroupNamesVariable, regionVariable } from '../mocks/CloudWatchDataSource'; import { setupMockedLogsQueryRunner } from '../mocks/LogsQueryRunner'; import { LogsRequestMock } from '../mocks/Request'; import { validLogsQuery } from '../mocks/queries'; @@ -28,6 +28,63 @@ describe('CloudWatchLogsQueryRunner', () => { jest.clearAllMocks(); }); + describe('interpolateLogsQueryVariables', () => { + it('returns logGroups with arn and name values sourced from the log group template variable', () => { + const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] }); + + const query: CloudWatchLogsQuery = { + ...validLogsQuery, + logGroups: [{ arn: '$groups', name: '$groups' }], + }; + + const { logGroups } = runner.interpolateLogsQueryVariables(query, {}); + + expect(logGroups).toEqual([ + { arn: 'templatedGroup-arn-1', name: 'templatedGroup-1' }, + { arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' }, + ]); + }); + + it('filters out duplicate log group arns when query already includes an expanded value', () => { + const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] }); + + const query: CloudWatchLogsQuery = { + ...validLogsQuery, + logGroups: [ + { arn: 'templatedGroup-arn-1', name: 'existing-group-name' }, + { arn: '$groups', name: '$groups' }, + ], + }; + + const { logGroups } = runner.interpolateLogsQueryVariables(query, {}); + + expect(logGroups).toEqual([ + { arn: 'templatedGroup-arn-1', name: 'existing-group-name' }, + { arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' }, + ]); + }); + + it('keeps log groups with duplicate names as long as arns are unique', () => { + const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] }); + + const query: CloudWatchLogsQuery = { + ...validLogsQuery, + logGroups: [ + { arn: 'arn-1', name: 'templatedGroup-1' }, + { arn: '$groups', name: '$groups' }, + ], + }; + + const { logGroups } = runner.interpolateLogsQueryVariables(query, {}); + + expect(logGroups).toEqual([ + { arn: 'arn-1', name: 'templatedGroup-1' }, + { arn: 'templatedGroup-arn-1', name: 'templatedGroup-1' }, + { arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' }, + ]); + }); + }); + describe('getLogRowContext', () => { it('replaces parameters correctly in the query', async () => { const { runner, queryMock } = setupMockedLogsQueryRunner({ variables: [regionVariable] }); diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts index 7f1ba927f47..cd8e8b29f1c 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts @@ -1,4 +1,4 @@ -import { set, uniq } from 'lodash'; +import { set, uniq, uniqBy } from 'lodash'; import { concatMap, finalize, @@ -253,9 +253,19 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest { (query.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.arn), scopedVars ); + const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable( + this.templateSrv, + (query.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.name), + scopedVars, + 'text' + ); + const interpolatedLogGroups = interpolatedLogGroupArns.map((arn, index) => ({ + arn, + name: interpolatedLogGroupNames[index] ?? arn, + })); // need to support legacy format variables too - const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable( + const interpolatedLegacyLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable( this.templateSrv, query.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [], scopedVars, @@ -264,8 +274,8 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest { // if a log group template variable expands to log group that has already been selected in the log group picker, we need to remove duplicates. // Otherwise the StartLogQuery API will return a permission error - const logGroups = uniq(interpolatedLogGroupArns).map((arn) => ({ arn, name: arn })); - const logGroupNames = uniq(interpolatedLogGroupNames); + const logGroups = uniqBy(interpolatedLogGroups, 'arn'); + const logGroupNames = uniq(interpolatedLegacyLogGroupNames); const logsSQLCustomerFormatter = (value: unknown, model: Partial) => { if ( From f468597ad83b4a69fb4f38fda578e042c76b4f0b Mon Sep 17 00:00:00 2001 From: Taygun Bulmus Date: Fri, 7 Nov 2025 00:10:25 +0300 Subject: [PATCH 074/209] Document rule_version_record_limit setting (#113511) * Document rule_version_record_limit setting Add documentation for rule_version_record_limit configuration. * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Jacob Valdez * Update docs/sources/setup-grafana/configure-grafana/_index.md * run prettier --------- Co-authored-by: Jacob Valdez --- docs/sources/setup-grafana/configure-grafana/_index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index ea83a3bb553..795b529ca85 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1969,6 +1969,12 @@ If a rule frequency is lower than this value, then this value is enforced.
+#### `rule_version_record_limit` + +Defines the limits for how many alert rule versions are stored in the database per alert rule. + +The default `0` value means there's no limit. + ### `[unified_alerting.screenshots]` For more information about screenshots, refer to [Images in notifications](../../alerting/configure-notifications/template-notifications/images-in-notifications/). From b9b1028b91f918061d2e9dbfb163e04ea0829a88 Mon Sep 17 00:00:00 2001 From: Adam Yeats <16296989+adamyeats@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:20:08 +0000 Subject: [PATCH 075/209] Elasticsearch: Handle keyed filters buckets and emit frames (#113478) --- pkg/tsdb/elasticsearch/response_parser.go | 279 +++++++++++++----- .../elasticsearch/response_parser_test.go | 136 +++++++++ 2 files changed, 335 insertions(+), 80 deletions(-) diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index b38b2c1cc07..08b951c353a 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -748,78 +748,191 @@ func processMetrics(esAgg *simplejson.Json, target *Query, query *backend.DataRe return nil } -func processAggregationDocs(esAgg *simplejson.Json, aggDef *BucketAgg, target *Query, - queryResult *backend.DataResponse, props map[string]string) error { - propKeys := createPropKeys(props) - frames := data.Frames{} - fields := createFields(queryResult.Frames, propKeys) - - for _, v := range esAgg.Get("buckets").MustArray() { - bucket := simplejson.NewFromAny(v) - var values []interface{} - - found := false - for _, field := range fields { - for _, propKey := range propKeys { - if field.Name == propKey { - value := props[propKey] - field.Append(&value) - } - } - if field.Name == aggDef.Field { - found = true - if key, err := bucket.Get("key").String(); err == nil { - field.Append(&key) - } else { - f, err := bucket.Get("key").Float64() - if err != nil { - return fmt.Errorf("error appending bucket key to existing field with name %s: %w", field.Name, err) - } - field.Append(&f) - } - } - } - - if !found { - var aggDefField *data.Field - if key, err := bucket.Get("key").String(); err == nil { - aggDefField = extractDataField(aggDef.Field, &key) - aggDefField.Append(&key) - } else { - f, err := bucket.Get("key").Float64() - if err != nil { - return fmt.Errorf("error appending bucket key to new field with name %s: %w", aggDef.Field, err) - } - aggDefField = extractDataField(aggDef.Field, &f) - aggDefField.Append(&f) - } - fields = append(fields, aggDefField) - } - - for _, metric := range target.Metrics { - switch metric.Type { - case countType: - addMetricValueToFields(&fields, values, getMetricName(metric.Type), castToFloat(bucket.Get("doc_count"))) - case extendedStatsType: - addExtendedStatsToFields(&fields, bucket, metric, values) - case percentilesType: - addPercentilesToFields(&fields, bucket, metric, values) - case topMetricsType: - addTopMetricsToFields(&fields, bucket, metric, values) - default: - addOtherMetricsToFields(&fields, bucket, metric, values, target) - } - } - - var dataFields []*data.Field - dataFields = append(dataFields, fields...) - - frames = data.Frames{ - &data.Frame{ - Fields: dataFields, - }} +// ensurePropFields guarantees all property columns exist even if prior frames lacked them +func ensurePropFields(fields *[]*data.Field, keys []string) { + have := map[string]bool{} + for _, f := range *fields { + have[f.Name] = true } - queryResult.Frames = frames + for _, k := range keys { + if !have[k] { + d := "" + f := extractDataField(k, &d) + *fields = append(*fields, f) + } + } +} + +// appendPropsRow appends one row of property values; skipKey avoids double-append +func appendPropsRow(fields *[]*data.Field, props map[string]string, propKeys []string, skipKey string) { + for _, f := range *fields { + for _, pk := range propKeys { + if pk == skipKey { + continue + } + if f.Name == pk { + val := props[pk] + f.Append(&val) + } + } + } +} + +// appendMetrics appends all metric values for a single bucket/row +func appendMetrics(fields *[]*data.Field, bucket *simplejson.Json, target *Query) { + var values []interface{} + for _, metric := range target.Metrics { + switch metric.Type { + case countType: + addMetricValueToFields(fields, values, getMetricName(metric.Type), castToFloat(bucket.Get("doc_count"))) + case extendedStatsType: + addExtendedStatsToFields(fields, bucket, metric, values) + case percentilesType: + addPercentilesToFields(fields, bucket, metric, values) + case topMetricsType: + addTopMetricsToFields(fields, bucket, metric, values) + default: + addOtherMetricsToFields(fields, bucket, metric, values, target) + } + } +} + +// appendKeyColumnString appends a string key to an existing field or creates it +func appendKeyColumnString(fields *[]*data.Field, fieldName, key string) { + for _, f := range *fields { + if f.Name == fieldName { + k := key + f.Append(&k) + return + } + } + k := key + f := extractDataField(fieldName, &k) + f.Append(&k) + *fields = append(*fields, f) +} + +// appendBucketKeyValue appends the bucket's "key" (string or number) to fieldName +func appendBucketKeyValue(fields *[]*data.Field, fieldName string, bucket *simplejson.Json) error { + for _, f := range *fields { + if f.Name == fieldName { + if s, err := bucket.Get("key").String(); err == nil { + f.Append(&s) + return nil + } + num, err := bucket.Get("key").Float64() + if err != nil { + return fmt.Errorf("error appending bucket key to existing field %q: %w", fieldName, err) + } + f.Append(&num) + return nil + } + } + + // field not present yet + if s, err := bucket.Get("key").String(); err == nil { + f := extractDataField(fieldName, &s) + f.Append(&s) + *fields = append(*fields, f) + return nil + } + + num, err := bucket.Get("key").Float64() + if err != nil { + return fmt.Errorf("error appending bucket key to new field %q: %w", fieldName, err) + } + + f := extractDataField(fieldName, &num) + f.Append(&num) + *fields = append(*fields, f) + + return nil +} + +func processAggregationDocs( + esAgg *simplejson.Json, + aggDef *BucketAgg, + target *Query, + queryResult *backend.DataResponse, + props map[string]string, +) error { + propKeys := createPropKeys(props) + buckets := esAgg.Get("buckets") + + if arr := buckets.MustArray(); len(arr) > 0 { + fields := createFields(queryResult.Frames, propKeys) + ensurePropFields(&fields, propKeys) + + for _, v := range arr { + bucket := simplejson.NewFromAny(v) + + appendPropsRow(&fields, props, propKeys, "") + if aggDef.Field != "" { + if err := appendBucketKeyValue(&fields, aggDef.Field, bucket); err != nil { + return err + } + } + appendMetrics(&fields, bucket, target) + } + + queryResult.Frames = data.Frames{&data.Frame{Fields: fields}} + return nil + } + + if m := buckets.MustMap(); len(m) > 0 { + // default key column to "filter" for leaf filters + keyFieldName := aggDef.Field + if keyFieldName == "" { + keyFieldName = "filter" + } + + // ensure "filter" exists among props + hasFilter := false + for _, pk := range propKeys { + if pk == "filter" { + hasFilter = true + break + } + } + if !hasFilter { + propKeys = append(propKeys, "filter") + } + + fields := createFields(queryResult.Frames, propKeys) + ensurePropFields(&fields, propKeys) + + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + bucket := simplejson.NewFromAny(m[k]) + + locProps := make(map[string]string, len(props)+1) + for kk, vv := range props { + locProps[kk] = vv + } + locProps["filter"] = k + + // avoid double-append when the key column is "filter" + skip := "" + if keyFieldName == "filter" { + skip = "filter" + } + + appendPropsRow(&fields, locProps, propKeys, skip) + appendKeyColumnString(&fields, keyFieldName, k) + appendMetrics(&fields, bucket, target) + } + + queryResult.Frames = data.Frames{&data.Frame{Fields: fields}} + return nil + } + + // no buckets present + queryResult.Frames = data.Frames{} return nil } @@ -1223,17 +1336,23 @@ func setLogsCustomMeta(frame *data.Frame, searchWords map[string]bool, limit int func createFields(frames data.Frames, propKeys []string) []*data.Field { var fields []*data.Field - // Otherwise use the fields from frames - if frames != nil { - for _, frame := range frames { - fields = append(fields, frame.Fields...) - } - // If we have no frames, we create fields from propKeys - } else { - for _, propKey := range propKeys { - fields = append(fields, data.NewField(propKey, nil, []*string{})) + have := map[string]bool{} + + // collect existing fields + for _, frame := range frames { + for _, f := range frame.Fields { + fields = append(fields, f) + have[f.Name] = true } } + + // add missing prop fields + for _, pk := range propKeys { + if !have[pk] { + fields = append(fields, data.NewField(pk, nil, []*string{})) + } + } + return fields } diff --git a/pkg/tsdb/elasticsearch/response_parser_test.go b/pkg/tsdb/elasticsearch/response_parser_test.go index 85239537bad..0032796cadf 100644 --- a/pkg/tsdb/elasticsearch/response_parser_test.go +++ b/pkg/tsdb/elasticsearch/response_parser_test.go @@ -3648,6 +3648,142 @@ func TestTrimEdges(t *testing.T) { requireFrameLength(t, frames[0], 1) } +func TestFiltersAggregation_KeyedBuckets(t *testing.T) { + t.Run("Leaf filters (keyed buckets) returns a table with filter | Count", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [{ + "type": "filters", + "id": "2", + "settings": { + "filters": [ + { "label": "a 0-1 min", "query": "duration_seconds:[0 TO 60}" }, + { "label": "b 1-5 min", "query": "duration_seconds:[60 TO 300}" } + ] + } + }] + }`, + } + + // ES returns a keyed map for filters buckets (labels -> bucket) + response := `{ + "responses": [{ + "aggregations": { + "2": { + "buckets": { + "a 0-1 min": { "doc_count": 12 }, + "b 1-5 min": { "doc_count": 39 } + } + } + } + }] + }` + + result, err := parseTestResponse(targets, response, false) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + res := result.Responses["A"] + require.NotNil(t, res) + require.NoError(t, res.Error) + + frames := res.Frames + require.Len(t, frames, 1) + + frame := frames[0] + // expect exactly 2 rows (one per filter bucket) and 2 columns: filter | Count + requireFrameLength(t, frame, 2) + require.Len(t, frame.Fields, 2) + + // build field map for stable assertions + fieldMap := map[string]*data.Field{} + for _, f := range frame.Fields { + fieldMap[f.Name] = f + } + + require.Contains(t, fieldMap, "filter") + require.Contains(t, fieldMap, "Count") + + // keys are sorted lexicographically in the parser, so "a 0-1 min" then "b 1-5 min" + requireStringAt(t, "a 0-1 min", fieldMap["filter"], 0) + requireStringAt(t, "b 1-5 min", fieldMap["filter"], 1) + + requireFloatAt(t, 12, fieldMap["Count"], 0) + requireFloatAt(t, 39, fieldMap["Count"], 1) + }) + + t.Run("Filters -> Terms keeps the filter column and yields filter | type | Count", func(t *testing.T) { + targets := map[string]string{ + "A": `{ + "metrics": [{ "type": "count", "id": "1" }], + "bucketAggs": [ + { + "type": "filters", + "id": "2", + "settings": { + "filters": [ + { "label": "A", "query": "duration_seconds:[0 TO 60}" }, + { "label": "B", "query": "duration_seconds:[60 TO 300}" } + ] + } + }, + { "type": "terms", "field": "type", "id": "3" } + ] + }`, + } + + response := `{ + "responses": [{ + "aggregations": { + "2": { + "buckets": { + "A": { "3": { "buckets": [ { "key": "pull_request", "doc_count": 10 } ] } }, + "B": { "3": { "buckets": [ { "key": "pull_request", "doc_count": 5 } ] } } + } + } + } + }] + }` + + result, err := parseTestResponse(targets, response, false) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + res := result.Responses["A"] + require.NotNil(t, res) + require.NoError(t, res.Error) + + frames := res.Frames + require.Len(t, frames, 1) + + frame := frames[0] + // expect 2 rows (A, B) and 3 columns: filter | type | Count + requireFrameLength(t, frame, 2) + require.Len(t, frame.Fields, 3) + + fieldMap := map[string]*data.Field{} + for _, f := range frame.Fields { + fieldMap[f.Name] = f + } + + require.Contains(t, fieldMap, "filter") + require.Contains(t, fieldMap, "type") + require.Contains(t, fieldMap, "Count") + + // filters are sorted lexicographically: A, B + requireStringAt(t, "A", fieldMap["filter"], 0) + requireStringAt(t, "B", fieldMap["filter"], 1) + + // terms field "type" should repeat "pull_request" on each row + requireStringAt(t, "pull_request", fieldMap["type"], 0) + requireStringAt(t, "pull_request", fieldMap["type"], 1) + + requireFloatAt(t, 10, fieldMap["Count"], 0) + requireFloatAt(t, 5, fieldMap["Count"], 1) + }) +} + func parseTestResponse(tsdbQueries map[string]string, responseBody string, keepLabelsInResponse bool) (*backend.QueryDataResponse, error) { from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) From 54041155bd42888a09337ba790894f701b1ac186 Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Thu, 6 Nov 2025 18:54:10 -0500 Subject: [PATCH 076/209] fix import path for annotation app --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index e8946ffb679..2b430ce7342 100644 --- a/go.mod +++ b/go.mod @@ -237,6 +237,7 @@ require ( github.com/grafana/grafana/apps/alerting/alertenrichment v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/notifications v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/rules v0.0.0 // @grafana/alerting-backend + github.com/grafana/grafana/apps/annotation v0.0.0 // @grafana/grafana-backend-services-squad github.com/grafana/grafana/apps/correlations v0.0.0 // @grafana/datapro github.com/grafana/grafana/apps/dashboard v0.0.0 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b // @grafana/grafana-app-platform-squad @@ -268,6 +269,7 @@ replace ( github.com/grafana/grafana/apps/alerting/alertenrichment => ./apps/alerting/alertenrichment github.com/grafana/grafana/apps/alerting/notifications => ./apps/alerting/notifications github.com/grafana/grafana/apps/alerting/rules => ./apps/alerting/rules + github.com/grafana/grafana/apps/annotation => ./apps/annotation github.com/grafana/grafana/apps/correlations => ./apps/correlations github.com/grafana/grafana/apps/dashboard => ./apps/dashboard github.com/grafana/grafana/apps/folder => ./apps/folder From 06e1c83276ae92a209098b02083f73cde19fee9e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 7 Nov 2025 10:11:05 +0100 Subject: [PATCH 077/209] Chore: Bump plugin-e2e (#113578) * bump plugin-e2e * use plugin-e2e selector * update lock file --- .../as-admin-user/panelDataAssertion.spec.ts | 4 +-- package.json | 2 +- yarn.lock | 25 +++++++++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts index e1fef705a88..0133a3e3712 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts @@ -38,7 +38,7 @@ test.describe( formatExpectError('Could not locate header elements in table panel') ).toContainText(['col1', 'col2']); await expect( - panelEditPage.panel.locator.getByRole('gridcell'), + panelEditPage.panel.data, formatExpectError('Could not locate headers in table panel') ).toContainText(['val1', 'val2', 'val3', 'val4']); }); @@ -58,7 +58,7 @@ test.describe( formatExpectError('Could not locate header elements in table panel') ).toContainText(['col1', 'col2']); await expect( - panelEditPage.panel.locator.getByRole('gridcell'), + panelEditPage.panel.data, formatExpectError('Could not locate data elements in table panel') ).toContainText(['val1', 'val2', 'val3', 'val4']); }); diff --git a/package.json b/package.json index 6b4cc94733f..f626ebc1486 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@emotion/eslint-plugin": "11.12.0", "@grafana/eslint-config": "8.2.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", - "@grafana/plugin-e2e": "2.1.7", + "@grafana/plugin-e2e": "^3.0.1", "@grafana/test-utils": "workspace:*", "@manypkg/get-packages": "^3.0.0", "@npmcli/package-json": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 423b8de21cd..5b1cbe5e476 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3397,17 +3397,17 @@ __metadata: languageName: unknown linkType: soft -"@grafana/plugin-e2e@npm:2.1.7": - version: 2.1.7 - resolution: "@grafana/plugin-e2e@npm:2.1.7" +"@grafana/plugin-e2e@npm:^3.0.1": + version: 3.0.1 + resolution: "@grafana/plugin-e2e@npm:3.0.1" dependencies: - "@grafana/e2e-selectors": "npm:^12.1.0-254610" + "@grafana/e2e-selectors": "npm:^12.2.0-255920" semver: "npm:^7.5.4" - uuid: "npm:^11.0.2" + uuid: "npm:^13.0.0" yaml: "npm:^2.3.4" peerDependencies: "@playwright/test": ^1.52.0 - checksum: 10/4e14bda1c6304c89a1ffca972dda18d3ab1fad59ee30e6735112d75f7c5c40856091e2e17f46451aec5261b6368f67c245edb112072d98c9067723f4ca2c71c3 + checksum: 10/1cd95c1e4365f93e884d7ae4d77961e038cd4fcc235e313dd9f96bd0e21090c5aa26a765bedf45cc13cc0dc913da79007172fd9680088b356ef485672ce34681 languageName: node linkType: hard @@ -18861,7 +18861,7 @@ __metadata: "@grafana/llm": "npm:0.22.1" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" - "@grafana/plugin-e2e": "npm:2.1.7" + "@grafana/plugin-e2e": "npm:^3.0.1" "@grafana/plugin-ui": "npm:^0.10.10" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" @@ -32996,7 +32996,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:11.1.0, uuid@npm:^11.0.0, uuid@npm:^11.0.2, uuid@npm:^11.0.5": +"uuid@npm:11.1.0, uuid@npm:^11.0.0, uuid@npm:^11.0.5": version: 11.1.0 resolution: "uuid@npm:11.1.0" bin: @@ -33014,6 +33014,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^13.0.0": + version: 13.0.0 + resolution: "uuid@npm:13.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10/2742b24d1e00257e60612572e4d28679423469998cafbaf1fe9f1482e3edf9c40754b31bfdb3d08d71b29239f227a304588f75210b3b48f2609f0673f1feccef + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" From f4b23253b19e057d6b3ea405acf662ae65084a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20V=C4=93rzemnieks?= Date: Fri, 7 Nov 2025 01:15:27 -0800 Subject: [PATCH 078/209] DataSources: Update SDKs in support of auth service (#112101) * DataSources: Update SDKs for auth service * Fix deprecated methods & types for new arrow-go version --- apps/advisor/go.mod | 24 +-- apps/advisor/go.sum | 80 +++------- apps/iam/go.mod | 32 ++-- apps/iam/go.sum | 79 ++++------ go.mod | 24 +-- go.sum | 48 +++--- go.work.sum | 202 ++++++++++++++++++++++++++ pkg/storage/unified/parquet/writer.go | 4 +- pkg/tsdb/influxdb/fsql/arrow.go | 4 +- pkg/tsdb/influxdb/fsql/arrow_test.go | 32 +--- pkg/tsdb/parca/query.go | 9 +- pkg/tsdb/parca/query_test.go | 4 +- 12 files changed, 321 insertions(+), 221 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 4b7e830796b..e7fb63026ac 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -68,13 +68,13 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.4 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.38.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect github.com/aws/smithy-go v1.23.1 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect @@ -91,7 +91,6 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect @@ -159,7 +158,7 @@ require ( github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.2.0 // indirect + github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana/apps/plugins v0.0.0 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect @@ -199,7 +198,6 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/lestrrat-go/strftime v1.0.4 // indirect github.com/lib/pq v1.10.9 // indirect - github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -252,7 +250,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect @@ -266,10 +263,6 @@ require ( github.com/tetratelabs/wazero v1.8.2 // indirect github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect github.com/tjhop/slog-gokit v0.1.3 // indirect - github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect - github.com/unknwon/com v1.0.1 // indirect - github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect - github.com/urfave/cli v1.22.17 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect @@ -319,7 +312,6 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 657be70c25b..90a5901f2f3 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -173,42 +173,42 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8= -github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= +github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.2 h1:NOaSZpVGEH2Np/c1toSeW0jooNl+9ALmsUTZ8YvkJR0= -github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= -github.com/aws/aws-sdk-go-v2/credentials v1.18.6 h1:AmmvNEYrru7sYNJnp3pf57lGbiarX4T9qU/6AZ9SucU= -github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 h1:lpdMwTzmuDLkgW7086jE94HweHCqG+uOJwHf3LZs7T0= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= +github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= +github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 h1:ueB2Te0NacDMnaC+68za9jLwkjzxGWm0KB5HTUHjLTI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 h1:ve9dYBB8CfJGTFqcQ3ZLAAb/KXWgYlgu/2R2TZL2Ko0= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 h1:pd9G9HQaM6UZAZh19pYOkpKSQkyQQ9ftnl/LttQOcGI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 h1:iV1Ko4Em/lkJIsoKyGfc0nQySi+v0Udxr6Igq+y9JZc= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= @@ -334,10 +334,7 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= -github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -663,9 +660,6 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -689,8 +683,8 @@ github.com/grafana/grafana-app-sdk v0.48.1 h1:bKJadWH18WCpJ+Zk8AezRFXCcZgGredRv+ github.com/grafana/grafana-app-sdk v0.48.1/go.mod h1:5LljCz+wvmGfkQ8ZKTOfserhtXNEF0cSFthoWShvN6c= github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-aws-sdk v1.2.0 h1:LLR4/g91WBuCRwm2cbWfCREq565+GxIFe08nqqIcIuw= -github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A= +github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= +github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= @@ -833,9 +827,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 h1:SwcnSwBR7X/5EHJQlXBockkJVIMRVt5yKaesBPMtyZQ= github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:WrYiIuiXUMIvTDAQw97C+9l0CnBmCcvosPjN3XDqS/o= -github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= @@ -882,8 +873,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/madflojo/testcerts v1.4.0 h1:I09gN0C1ly9IgeVNcAqKk8RAKIJTe3QnFrrPBDyvzN4= github.com/madflojo/testcerts v1.4.0/go.mod h1:MW8sh39gLnkKh4K0Nc55AyHEDl9l/FBLDUsQhpmkuo0= -github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= -github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= @@ -1115,8 +1104,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= @@ -1132,7 +1119,6 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -1141,10 +1127,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= -github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -1189,7 +1171,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= @@ -1213,16 +1194,6 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= -github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8/go.mod h1:fVle4kNr08ydeohzYafr20oZzbAkhQT39gKK/pFQ5M4= -github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= -github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= -github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a h1:vcrhXnj9g9PIE+cmZgaPSwOyJ8MAQTRmsgGrB0x5rF4= -github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= -github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= github.com/wk8/go-ordered-map v1.0.0 h1:BV7z+2PaK8LTSd/mWgY12HyMAo5CEgkHqbkVq2thqr8= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= @@ -1528,7 +1499,6 @@ golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1892,8 +1862,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= -gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index b98eea39b77..1fccf63d242 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -100,24 +100,24 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.4 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.6 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect github.com/aws/smithy-go v1.23.1 // indirect github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -151,7 +151,6 @@ require ( github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dgraph-io/badger/v4 v4.7.0 // indirect @@ -235,7 +234,7 @@ require ( github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.2.0 // indirect + github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.281.0 // indirect github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect @@ -295,7 +294,6 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/strftime v1.0.4 // indirect github.com/lib/pq v1.10.9 // indirect - github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect @@ -361,7 +359,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect @@ -385,10 +382,6 @@ require ( github.com/tjhop/slog-gokit v0.1.3 // indirect github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect - github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect - github.com/unknwon/com v1.0.1 // indirect - github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect - github.com/urfave/cli v1.22.17 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -455,7 +448,6 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 740bfd270dc..2c8041281e9 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -237,22 +237,22 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8= -github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= +github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.2 h1:NOaSZpVGEH2Np/c1toSeW0jooNl+9ALmsUTZ8YvkJR0= -github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= -github.com/aws/aws-sdk-go-v2/credentials v1.18.6 h1:AmmvNEYrru7sYNJnp3pf57lGbiarX4T9qU/6AZ9SucU= -github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 h1:lpdMwTzmuDLkgW7086jE94HweHCqG+uOJwHf3LZs7T0= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= +github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= +github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= @@ -263,12 +263,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 h1:ueB2Te0NacDMnaC+68za9jLwkjzxGWm0KB5HTUHjLTI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= @@ -279,12 +279,12 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 h1:ve9dYBB8CfJGTFqcQ3ZLAAb/KXWgYlgu/2R2TZL2Ko0= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 h1:pd9G9HQaM6UZAZh19pYOkpKSQkyQQ9ftnl/LttQOcGI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 h1:iV1Ko4Em/lkJIsoKyGfc0nQySi+v0Udxr6Igq+y9JZc= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s= @@ -444,8 +444,8 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -826,9 +826,6 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -858,8 +855,8 @@ github.com/grafana/grafana-app-sdk v0.48.1 h1:bKJadWH18WCpJ+Zk8AezRFXCcZgGredRv+ github.com/grafana/grafana-app-sdk v0.48.1/go.mod h1:5LljCz+wvmGfkQ8ZKTOfserhtXNEF0cSFthoWShvN6c= github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-aws-sdk v1.2.0 h1:LLR4/g91WBuCRwm2cbWfCREq565+GxIFe08nqqIcIuw= -github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A= +github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= +github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= @@ -1067,9 +1064,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 h1:SwcnSwBR7X/5EHJQlXBockkJVIMRVt5yKaesBPMtyZQ= github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:WrYiIuiXUMIvTDAQw97C+9l0CnBmCcvosPjN3XDqS/o= -github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= @@ -1124,8 +1118,6 @@ github.com/m3db/prometheus_remote_client_golang v0.4.4 h1:DsAIjVKoCp7Ym35tAOFL1O github.com/m3db/prometheus_remote_client_golang v0.4.4/go.mod h1:wHfVbA3eAK6dQvKjCkHhusWYegCk3bDGkA15zymSHdc= github.com/madflojo/testcerts v1.4.0 h1:I09gN0C1ly9IgeVNcAqKk8RAKIJTe3QnFrrPBDyvzN4= github.com/madflojo/testcerts v1.4.0/go.mod h1:MW8sh39gLnkKh4K0Nc55AyHEDl9l/FBLDUsQhpmkuo0= -github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= -github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -1426,8 +1418,8 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -1458,7 +1450,6 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -1467,11 +1458,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= @@ -1526,7 +1512,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= @@ -1559,16 +1544,7 @@ github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= -github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8/go.mod h1:fVle4kNr08ydeohzYafr20oZzbAkhQT39gKK/pFQ5M4= -github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= -github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= -github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a h1:vcrhXnj9g9PIE+cmZgaPSwOyJ8MAQTRmsgGrB0x5rF4= -github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= -github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -1916,7 +1892,6 @@ golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2293,8 +2268,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= -gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= diff --git a/go.mod b/go.mod index 2b430ce7342..ced133ac668 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/apache/arrow-go/v18 v18.4.1 // @grafana/plugins-platform-backend github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources - github.com/aws/aws-sdk-go-v2 v1.38.1 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2 v1.39.1 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources @@ -98,7 +98,7 @@ require ( github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend github.com/grafana/grafana-app-sdk v0.48.1 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-app-sdk/logging v0.48.1 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-aws-sdk v1.2.0 // @grafana/aws-datasources + github.com/grafana/grafana-aws-sdk v1.3.0 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources @@ -334,23 +334,23 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.6 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect diff --git a/go.sum b/go.sum index 0c29838863d..f99d65d2a2d 100644 --- a/go.sum +++ b/go.sum @@ -846,22 +846,22 @@ github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8= -github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= +github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.2 h1:NOaSZpVGEH2Np/c1toSeW0jooNl+9ALmsUTZ8YvkJR0= -github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= -github.com/aws/aws-sdk-go-v2/credentials v1.18.6 h1:AmmvNEYrru7sYNJnp3pf57lGbiarX4T9qU/6AZ9SucU= -github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 h1:lpdMwTzmuDLkgW7086jE94HweHCqG+uOJwHf3LZs7T0= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= +github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= +github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= @@ -872,12 +872,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 h1:ueB2Te0NacDMnaC+68za9jLwkjzxGWm0KB5HTUHjLTI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= @@ -888,12 +888,12 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 h1:ve9dYBB8CfJGTFqcQ3ZLAAb/KXWgYlgu/2R2TZL2Ko0= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 h1:pd9G9HQaM6UZAZh19pYOkpKSQkyQQ9ftnl/LttQOcGI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 h1:iV1Ko4Em/lkJIsoKyGfc0nQySi+v0Udxr6Igq+y9JZc= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s= @@ -1637,8 +1637,8 @@ github.com/grafana/grafana-app-sdk v0.48.1 h1:bKJadWH18WCpJ+Zk8AezRFXCcZgGredRv+ github.com/grafana/grafana-app-sdk v0.48.1/go.mod h1:5LljCz+wvmGfkQ8ZKTOfserhtXNEF0cSFthoWShvN6c= github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-aws-sdk v1.2.0 h1:LLR4/g91WBuCRwm2cbWfCREq565+GxIFe08nqqIcIuw= -github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A= +github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= +github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= diff --git a/go.work.sum b/go.work.sum index 7a3d0b77073..2110918b035 100644 --- a/go.work.sum +++ b/go.work.sum @@ -433,12 +433,30 @@ github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/V github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U= github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/service/kms v1.35.3 h1:UPTdlTOwWUX49fVi7cymEN6hDqCwe3LNv1vi7TXUutk= +github.com/aws/aws-sdk-go-v2/service/kms v1.35.3/go.mod h1:gjDP16zn+WWalyaUqwCCioQ8gU8lzttCCc9jYsiQI/8= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4 h1:NgRFYyFpiMD62y4VPXh4DosPFbZd4vdMVBWKk0VmWXc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4/go.mod h1:TKKN7IQoM7uTnyuFm9bm9cw5P//ZYTl4m3htBWQ1G/c= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.2 h1:vlYXbindmagyVA3RS2SPd47eKZ00GZZQcr+etTviHtc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.2/go.mod h1:yGhDiLKguA3iFJYxbrQkQiNzuy+ddxesSZYWVeeEH5Q= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.7 h1:d+mnMa4JbJlooSbYQfrJpit/YINaB30JEVgrhtjZneA= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.7/go.mod h1:1X1NotbcGHH7PCQJ98PsExSxsJj/VWzz8MfFz43+02M= +github.com/aws/aws-sdk-go-v2/service/sns v1.31.3 h1:eSTEdxkfle2G98FE+Xl3db/XAXXVTJPNQo9K/Ar8oAI= +github.com/aws/aws-sdk-go-v2/service/sns v1.31.3/go.mod h1:1dn0delSO3J69THuty5iwP0US2Glt0mx2qBBlI13pvw= +github.com/aws/aws-sdk-go-v2/service/sns v1.34.2 h1:PajtbJ/5bEo6iUAIGMYnK8ljqg2F1h4mMCGh1acjN30= +github.com/aws/aws-sdk-go-v2/service/sns v1.34.2/go.mod h1:PJtxxMdj747j8DeZENRTTYAz/lx/pADn/U0k7YNNiUY= github.com/aws/aws-sdk-go-v2/service/sns v1.34.7 h1:OBuZE9Wt8h2imuRktu+WfjiTGrnYdCIJg8IX92aalHE= github.com/aws/aws-sdk-go-v2/service/sns v1.34.7/go.mod h1:4WYoZAhHt+dWYpoOQUgkUKfuQbE6Gg/hW4oXE0pKS9U= +github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3 h1:Vjqy5BZCOIsn4Pj8xzyqgGmsSqzz7y/WXbN3RgOoVrc= +github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3/go.mod h1:L0enV3GCRd5iG9B64W35C4/hwsCB00Ib+DKVGTadKHI= +github.com/aws/aws-sdk-go-v2/service/sqs v1.38.3 h1:j5BchjfDoS7K26vPdyJlyxBIIBGDflq3qjjJKBDlbcI= +github.com/aws/aws-sdk-go-v2/service/sqs v1.38.3/go.mod h1:Bar4MrRxeqdn6XIh8JGfiXuFRmyrrsZNTJotxEJmWW0= github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8 h1:80dpSqWMwx2dAm30Ib7J6ucz1ZHfiv5OCRwN/EnCOXQ= github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8/go.mod h1:IzNt/udsXlETCdvBOL0nmyMe2t9cGmXmZgsdoZGYYhI= +github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4 h1:hgSBvRT7JEWx2+vEGI9/Ld5rZtl7M5lu8PqdvOmbRHw= +github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4/go.mod h1:v7NIzEFIHBiicOMaMTuEmbnzGnqW0d+6ulNALul6fYE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.58.0 h1:zQz6Q5uaC8s9734DV9UDAm2q1TEEfOvEejDBSulOapI= +github.com/aws/aws-sdk-go-v2/service/ssm v1.58.0/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9kgtA/YhbxnSnHP+yQqzrM8= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV8Mw4TCa8IyNO0as= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= @@ -446,6 +464,9 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSr github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf6t3voxpvUDikOU9LY= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -555,6 +576,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= @@ -663,6 +688,9 @@ github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/X github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/fgprof v0.9.4 h1:ocDNwMFlnA0NU0zSB3I52xkO4sFXk80VK9lXjLClu88= @@ -798,6 +826,7 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= @@ -831,6 +860,128 @@ github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ3 github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= +github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= +github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= +github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU= +github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY= +github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc= +github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= +github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= +github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= +github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= +github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.45.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.48.0 h1:xolkQxBlA2LQF4hprKIAeu+zUem1DigYZ6XC1TOhFJE= +github.com/grafana/grafana-app-sdk/logging v0.48.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8= +github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0= +github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= +github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s= +github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= +github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= +github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= +github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= +github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= +github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= +github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= +github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU= +github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= +github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= +github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY= +github.com/grafana/grafana/apps/alerting/rules v0.0.0-20250731223157-26b18dda3364/go.mod h1:wi4njPm5mJ8IpK13h57be8sWoxOhqr1UQOwmXhRM9Gk= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:eR8wca74ADgxBrvX0uNpdB1qnPaGx/KhCm4Xj8oqHfQ= +github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI= +github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff h1:JDT0Mcfpi3c525xzeli+v5dR9pf5HhdFjr8djRdhs10= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924 h1:uGXX6gCF1q2ytIL0w1X3UAKgF/UZ7eDDAgOaSqLOeW8= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001 h1:y2AHkdji2I+zXv8rsSC8OjWEzJJjqW5OlmCsZR5+RuU= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= +github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= +github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= +github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU= +github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY= +github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc= +github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= +github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= +github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= +github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= +github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.45.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.48.0 h1:xolkQxBlA2LQF4hprKIAeu+zUem1DigYZ6XC1TOhFJE= +github.com/grafana/grafana-app-sdk/logging v0.48.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8= +github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0= +github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= +github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s= +github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= +github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= +github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= +github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= +github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= +github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= +github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= +github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU= +github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= +github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= +github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= +github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY= +github.com/grafana/grafana/apps/alerting/rules v0.0.0-20250731223157-26b18dda3364/go.mod h1:wi4njPm5mJ8IpK13h57be8sWoxOhqr1UQOwmXhRM9Gk= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:eR8wca74ADgxBrvX0uNpdB1qnPaGx/KhCm4Xj8oqHfQ= +github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI= +github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff h1:JDT0Mcfpi3c525xzeli+v5dR9pf5HhdFjr8djRdhs10= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924 h1:uGXX6gCF1q2ytIL0w1X3UAKgF/UZ7eDDAgOaSqLOeW8= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001 h1:y2AHkdji2I+zXv8rsSC8OjWEzJJjqW5OlmCsZR5+RuU= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -1020,6 +1171,7 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/maxbrunsfeld/counterfeiter/v6 v6.11.2 h1:yVCLo4+ACVroOEr4iFU1iH46Ldlzz2rTuu18Ra7M8sU= @@ -1224,6 +1376,7 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= github.com/prometheus/statsd_exporter v0.21.0/go.mod h1:rbT83sZq2V+p73lHhPZfMc3MLCHmSHelCh9hSGYNLTQ= @@ -1738,6 +1891,10 @@ go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= @@ -1746,6 +1903,7 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= gocloud.dev v0.42.0/go.mod h1:zkaYAapZfQisXOA4bzhsbA4ckiStGQ3Psvs9/OQ5dPM= gocloud.dev/secrets/hashivault v0.42.0/go.mod h1:LXprr1XLEAT7BVZ+Y66dJEHQMzDsowIExj5Ktr9HLvM= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -1770,6 +1928,9 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= @@ -1840,6 +2001,22 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -1883,6 +2060,18 @@ golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -1918,6 +2107,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go. google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= @@ -1956,6 +2147,12 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -1969,6 +2166,9 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= @@ -2018,6 +2218,8 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbF k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg= modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= diff --git a/pkg/storage/unified/parquet/writer.go b/pkg/storage/unified/parquet/writer.go index 1b418526de8..c35cf69b38b 100644 --- a/pkg/storage/unified/parquet/writer.go +++ b/pkg/storage/unified/parquet/writer.go @@ -114,9 +114,7 @@ func (w *parquetWriter) Close() error { // writes the current buffer to parquet and re-inits the arrow buffer func (w *parquetWriter) flush() error { w.logger.Info("flush", "count", w.rv.Len()) - //TODO: fix deprecation warning - //nolint:staticcheck - rec := array.NewRecord(w.schema, []arrow.Array{ + rec := array.NewRecordBatch(w.schema, []arrow.Array{ w.rv.NewArray(), w.namespace.NewArray(), w.group.NewArray(), diff --git a/pkg/tsdb/influxdb/fsql/arrow.go b/pkg/tsdb/influxdb/fsql/arrow.go index 832074576e6..9e8c3ffd08d 100644 --- a/pkg/tsdb/influxdb/fsql/arrow.go +++ b/pkg/tsdb/influxdb/fsql/arrow.go @@ -26,9 +26,7 @@ const rowLimit = 1_000_000 type recordReader interface { Next() bool Schema() *arrow.Schema - //TODO: fix deprecation warning - //nolint:staticcheck - Record() arrow.Record + Record() arrow.RecordBatch Err() error } diff --git a/pkg/tsdb/influxdb/fsql/arrow_test.go b/pkg/tsdb/influxdb/fsql/arrow_test.go index 6200e6a29ea..dec0fa4044b 100644 --- a/pkg/tsdb/influxdb/fsql/arrow_test.go +++ b/pkg/tsdb/influxdb/fsql/arrow_test.go @@ -77,12 +77,8 @@ func TestNewQueryDataResponse(t *testing.T) { arr = append(arr, tarr) } - //TODO: fix deprecation warning - //nolint:staticcheck - record := array.NewRecord(schema, arr, -1) - //TODO: fix deprecation warning - //nolint:staticcheck - records := []arrow.Record{record} + record := array.NewRecordBatch(schema, arr, -1) + records := []arrow.RecordBatch{record} reader, err := array.NewRecordReader(schema, records) assert.NoError(t, err) @@ -206,12 +202,8 @@ func TestNewQueryDataResponse_Error(t *testing.T) { ) assert.NoError(t, err) - //TODO: fix deprecation warning - //nolint:staticcheck - record := array.NewRecord(schema, []arrow.Array{i64s, f64s}, -1) - //TODO: fix deprecation warning - //nolint:staticcheck - records := []arrow.Record{record} + record := array.NewRecordBatch(schema, []arrow.Array{i64s, f64s}, -1) + records := []arrow.RecordBatch{record} reader, err := array.NewRecordReader(schema, records) assert.NoError(t, err) @@ -255,12 +247,8 @@ func TestNewQueryDataResponse_WideTable(t *testing.T) { ) assert.NoError(t, err) - //TODO: fix deprecation warning - //nolint:staticcheck - record := array.NewRecord(schema, []arrow.Array{times, strs, i64s}, -1) - //TODO: fix deprecation warning - //nolint:staticcheck - records := []arrow.Record{record} + record := array.NewRecordBatch(schema, []arrow.Array{times, strs, i64s}, -1) + records := []arrow.RecordBatch{record} reader, err := array.NewRecordReader(schema, records) assert.NoError(t, err) @@ -534,12 +522,8 @@ func TestCustomMetadata(t *testing.T) { ) assert.NoError(t, err) - //TODO: fix deprecation warning - //nolint:staticcheck - record := array.NewRecord(schema, []arrow.Array{i64s}, -1) - //TODO: fix deprecation warning - //nolint:staticcheck - records := []arrow.Record{record} + record := array.NewRecordBatch(schema, []arrow.Array{i64s}, -1) + records := []arrow.RecordBatch{record} reader, err := array.NewRecordReader(schema, records) assert.NoError(t, err) diff --git a/pkg/tsdb/parca/query.go b/pkg/tsdb/parca/query.go index 7c8d5931cb7..db259a4d56b 100644 --- a/pkg/tsdb/parca/query.go +++ b/pkg/tsdb/parca/query.go @@ -196,9 +196,7 @@ func arrowToNestedSetDataFrame(flamegraph *v1alpha1.FlamegraphArrow) (*data.Fram defer arrowReader.Release() arrowReader.Next() - //TODO: fix deprecation warning - //nolint:staticcheck - rec := arrowReader.Record() + rec := arrowReader.RecordBatch() fi, err := newFlamegraphIterator(rec) if err != nil { @@ -238,10 +236,7 @@ type flamegraphIterator struct { addressBuilder *bytes.Buffer } -// TODO: fix deprecation warning -// -//nolint:staticcheck -func newFlamegraphIterator(rec arrow.Record) (*flamegraphIterator, error) { +func newFlamegraphIterator(rec arrow.RecordBatch) (*flamegraphIterator, error) { schema := rec.Schema() columnChildren := rec.Column(schema.FieldIndices(FlamegraphFieldChildren)[0]).(*array.List) diff --git a/pkg/tsdb/parca/query_test.go b/pkg/tsdb/parca/query_test.go index 6a18993ba98..0ed26d7eab0 100644 --- a/pkg/tsdb/parca/query_test.go +++ b/pkg/tsdb/parca/query_test.go @@ -278,9 +278,7 @@ func flamegraphResponse() *connect.Response[v1alpha1.QueryResponse] { builderFlat.Append(columns.flat[i]) } - //TODO: fix deprecation warning - //nolint:staticcheck - record := array.NewRecord( + record := array.NewRecordBatch( arrow.NewSchema(fields, nil), []arrow.Array{ builderLocationAddress.NewArray(), From 4bbbd19049b39c33b75403918f6dbe59fea093f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Fri, 7 Nov 2025 10:30:24 +0100 Subject: [PATCH 079/209] CloudWatch: Make match exact toggle false by default (#113314) --- public/app/plugins/datasource/cloudwatch/datasource.test.ts | 2 +- public/app/plugins/datasource/cloudwatch/defaultQueries.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index 9368ed93d5b..2a84dca2490 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -461,7 +461,7 @@ describe('datasource', () => { expect((datasource.getDefaultQuery(CoreApp.PanelEditor) as CloudWatchDefaultQuery).metricEditorMode).toEqual( MetricEditorMode.Builder ); - expect((datasource.getDefaultQuery(CoreApp.PanelEditor) as CloudWatchDefaultQuery).matchExact).toEqual(true); + expect((datasource.getDefaultQuery(CoreApp.PanelEditor) as CloudWatchDefaultQuery).matchExact).toEqual(false); }); it('should set default values from logs query', () => { const defaultLogGroups = [{ name: 'logName', arn: 'logARN' }]; diff --git a/public/app/plugins/datasource/cloudwatch/defaultQueries.ts b/public/app/plugins/datasource/cloudwatch/defaultQueries.ts index bf66bf59151..22055f17256 100644 --- a/public/app/plugins/datasource/cloudwatch/defaultQueries.ts +++ b/public/app/plugins/datasource/cloudwatch/defaultQueries.ts @@ -24,7 +24,7 @@ export const DEFAULT_METRICS_QUERY: Omit = { metricEditorMode: MetricEditorMode.Builder, sql: undefined, sqlExpression: '', - matchExact: true, + matchExact: false, }; export const DEFAULT_ANNOTATIONS_QUERY: Omit = { From f75c853b906df6b9f9d0a2c74203423a8f510829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Fri, 7 Nov 2025 10:47:53 +0100 Subject: [PATCH 080/209] Provisioning: Update slog-gokit to v0.1.5 to fix data race (#113455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use fork of slog-gokit to fix data race Replace github.com/tjhop/slog-gokit with fork that includes fix for data race in handler. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Update workspace * Bump github.com/tjhop/slog-gokit to v0.1.5 * Update go.sum --------- Co-authored-by: Claude --- apps/advisor/go.mod | 4 +- apps/advisor/go.sum | 8 +- apps/iam/go.mod | 4 +- apps/iam/go.sum | 8 +- go.mod | 4 +- go.sum | 8 +- go.work.sum | 252 ++++++++++++-------------------------------- 7 files changed, 83 insertions(+), 205 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index e7fb63026ac..d646f81e055 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -113,7 +113,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-ldap/ldap/v3 v3.4.4 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.24.0 // indirect @@ -262,7 +262,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wazero v1.8.2 // indirect github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect - github.com/tjhop/slog-gokit v0.1.3 // indirect + github.com/tjhop/slog-gokit v0.1.5 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 90a5901f2f3..3ec40f156ef 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -453,8 +453,8 @@ github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXg github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -1183,8 +1183,8 @@ github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574 github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= -github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= +github.com/tjhop/slog-gokit v0.1.5 h1:ayloIUi5EK2QYB8eY4DOPO95/mRtMW42lUkp3quJohc= +github.com/tjhop/slog-gokit v0.1.5/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 1fccf63d242..bc185ae88f3 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -181,7 +181,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-ldap/ldap/v3 v3.4.4 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.24.0 // indirect @@ -379,7 +379,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.8.2 // indirect github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect - github.com/tjhop/slog-gokit v0.1.3 // indirect + github.com/tjhop/slog-gokit v0.1.5 // indirect github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 2c8041281e9..31148a98766 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -595,8 +595,8 @@ github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXg github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -1526,8 +1526,8 @@ github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574 github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= -github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= +github.com/tjhop/slog-gokit v0.1.5 h1:ayloIUi5EK2QYB8eY4DOPO95/mRtMW42lUkp3quJohc= +github.com/tjhop/slog-gokit v0.1.5/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= diff --git a/go.mod b/go.mod index ced133ac668..873633a0dad 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.2 // @grafana/identity-access-team github.com/go-kit/log v0.2.1 // @grafana/grafana-backend-group github.com/go-ldap/ldap/v3 v3.4.4 // @grafana/identity-access-team - github.com/go-logfmt/logfmt v0.6.0 // @grafana/oss-big-tent + github.com/go-logfmt/logfmt v0.6.1 // @grafana/oss-big-tent github.com/go-openapi/loads v0.23.1 // @grafana/alerting-backend github.com/go-openapi/runtime v0.28.0 // @grafana/alerting-backend github.com/go-openapi/strfmt v0.24.0 // @grafana/alerting-backend @@ -172,7 +172,7 @@ require ( github.com/stretchr/testify v1.11.1 // @grafana/grafana-backend-group github.com/testcontainers/testcontainers-go v0.36.0 //@grafana/grafana-app-platform-squad github.com/thomaspoignant/go-feature-flag v1.42.0 // @grafana/grafana-backend-group - github.com/tjhop/slog-gokit v0.1.3 // @grafana/grafana-app-platform-squad + github.com/tjhop/slog-gokit v0.1.5 // @grafana/grafana-app-platform-squad github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 // @grafana/grafana-backend-group github.com/urfave/cli v1.22.17 // indirect; @grafana/grafana-backend-group github.com/urfave/cli/v2 v2.27.7 // @grafana/grafana-backend-group diff --git a/go.sum b/go.sum index f99d65d2a2d..5717702b7a3 100644 --- a/go.sum +++ b/go.sum @@ -1248,8 +1248,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -2499,8 +2499,8 @@ github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= -github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= +github.com/tjhop/slog-gokit v0.1.5 h1:ayloIUi5EK2QYB8eY4DOPO95/mRtMW42lUkp3quJohc= +github.com/tjhop/slog-gokit v0.1.5/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= diff --git a/go.work.sum b/go.work.sum index 2110918b035..be369dac104 100644 --- a/go.work.sum +++ b/go.work.sum @@ -336,6 +336,8 @@ github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4Oe github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= +github.com/MissingRoberto/slog-gokit v0.0.0-20251105092822-783f72952ce4 h1:gTtFbl79tuZSeJuSO7kXSbmXSvKSa/PoUXda1tuz0O8= +github.com/MissingRoberto/slog-gokit v0.0.0-20251105092822-783f72952ce4/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= @@ -408,33 +410,41 @@ github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7Rfg github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1 h1:nMp7diZObd4XEVUR0pEvn7/E13JIgManMX79Q6quV6E= github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTClvBjYZrg/EPd3ts885KrNTI= github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= +github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= +github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= +github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5 h1:oUEqVqonG3xuarrsze1KVJ30KagNYDemikTbdu8KlN8= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5/go.mod h1:VNM08cHlOsIbSHRqb6D/M2L4kKXfJv3A2/f0GNbOQSc= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87 h1:oDPArGgCrG/4aTi86ij3S2PB59XXkTSKYVNQlmqRHXQ= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87/go.mod h1:ZeQC4gVarhdcWeM1c90DyBLaBCNhEeAbKUXwVI/byvw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0 h1:A99gjqZDbdhjtjJVZrmVzVKO2+p3MSg35bDWtbMQVxw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0 h1:0wOCTKrmwkyC8Bk76hYH/B4IJn5MGt6gMkSXc0A2uyc= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/Vmmy0p9vwHhxw+V+tVjg= github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U= -github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= github.com/aws/aws-sdk-go-v2/service/kms v1.35.3 h1:UPTdlTOwWUX49fVi7cymEN6hDqCwe3LNv1vi7TXUutk= github.com/aws/aws-sdk-go-v2/service/kms v1.35.3/go.mod h1:gjDP16zn+WWalyaUqwCCioQ8gU8lzttCCc9jYsiQI/8= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4 h1:NgRFYyFpiMD62y4VPXh4DosPFbZd4vdMVBWKk0VmWXc= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4/go.mod h1:TKKN7IQoM7uTnyuFm9bm9cw5P//ZYTl4m3htBWQ1G/c= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.2 h1:vlYXbindmagyVA3RS2SPd47eKZ00GZZQcr+etTviHtc= @@ -460,10 +470,12 @@ github.com/aws/aws-sdk-go-v2/service/ssm v1.58.0/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9 github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV8Mw4TCa8IyNO0as= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= +github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= @@ -571,15 +583,10 @@ github.com/couchbase/ghistogram v0.1.0 h1:b95QcQTCzjTUocDXp/uMgSNQi8oj1tGwnJ4bOD github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k= github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o= github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs= -github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= @@ -688,9 +695,6 @@ github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/X github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/fgprof v0.9.4 h1:ocDNwMFlnA0NU0zSB3I52xkO4sFXk80VK9lXjLClu88= @@ -734,6 +738,7 @@ github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535/go.mod h1: github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81 h1:6zl3BbBhdnMkpSj2YY30qV3gDcVBGtFgVsV3+/i+mKQ= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= @@ -826,7 +831,6 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= @@ -834,10 +838,9 @@ github.com/grafana/dskit v0.0.0-20250818234656-8ff9c6532e85/go.mod h1:kImsvJ1xnm github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak= github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90= github.com/grafana/go-mysql-server v0.20.1-0.20251027172658-317a8d46ffa4/go.mod h1:EeYR0apo+8j2Dyxmn2ghkPlirO2S5mT1xHBrA+Efys8= -github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU= github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY= github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc= @@ -847,6 +850,7 @@ github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+ github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= @@ -854,134 +858,58 @@ github.com/grafana/grafana-app-sdk/logging v0.45.0/go.mod h1:Gh/nBWnspK3oDNWtiM5 github.com/grafana/grafana-app-sdk/logging v0.46.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-app-sdk/logging v0.48.0 h1:xolkQxBlA2LQF4hprKIAeu+zUem1DigYZ6XC1TOhFJE= github.com/grafana/grafana-app-sdk/logging v0.48.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8= +github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0= +github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= +github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s= +github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= +github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= +github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A= +github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= +github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= +github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= +github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= +github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= +github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU= github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= +github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= +github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= +github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY= +github.com/grafana/grafana/apps/alerting/rules v0.0.0-20250731223157-26b18dda3364/go.mod h1:wi4njPm5mJ8IpK13h57be8sWoxOhqr1UQOwmXhRM9Gk= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:eR8wca74ADgxBrvX0uNpdB1qnPaGx/KhCm4Xj8oqHfQ= +github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI= +github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff h1:JDT0Mcfpi3c525xzeli+v5dR9pf5HhdFjr8djRdhs10= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924 h1:uGXX6gCF1q2ytIL0w1X3UAKgF/UZ7eDDAgOaSqLOeW8= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001 h1:y2AHkdji2I+zXv8rsSC8OjWEzJJjqW5OlmCsZR5+RuU= +github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= +github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= +github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= -github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= -github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU= -github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY= -github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc= -github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= -github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= -github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= -github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= -github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= -github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= -github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-app-sdk/logging v0.45.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-app-sdk/logging v0.48.0 h1:xolkQxBlA2LQF4hprKIAeu+zUem1DigYZ6XC1TOhFJE= -github.com/grafana/grafana-app-sdk/logging v0.48.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8= -github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0= -github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= -github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s= -github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= -github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= -github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= -github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= -github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= -github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= -github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= -github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU= -github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= -github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= -github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY= -github.com/grafana/grafana/apps/alerting/rules v0.0.0-20250731223157-26b18dda3364/go.mod h1:wi4njPm5mJ8IpK13h57be8sWoxOhqr1UQOwmXhRM9Gk= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:eR8wca74ADgxBrvX0uNpdB1qnPaGx/KhCm4Xj8oqHfQ= -github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI= -github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff h1:JDT0Mcfpi3c525xzeli+v5dR9pf5HhdFjr8djRdhs10= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924 h1:uGXX6gCF1q2ytIL0w1X3UAKgF/UZ7eDDAgOaSqLOeW8= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001 h1:y2AHkdji2I+zXv8rsSC8OjWEzJJjqW5OlmCsZR5+RuU= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= -github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= -github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= -github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= -github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= -github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= -github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= -github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= -github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU= -github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY= -github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc= -github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= -github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= -github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= -github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= -github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= -github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M= -github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-app-sdk/logging v0.45.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-app-sdk/logging v0.48.0 h1:xolkQxBlA2LQF4hprKIAeu+zUem1DigYZ6XC1TOhFJE= -github.com/grafana/grafana-app-sdk/logging v0.48.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8= -github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0= -github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= -github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s= -github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= -github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= -github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= -github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= -github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= -github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= -github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= -github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU= -github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= -github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= -github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= -github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY= -github.com/grafana/grafana/apps/alerting/rules v0.0.0-20250731223157-26b18dda3364/go.mod h1:wi4njPm5mJ8IpK13h57be8sWoxOhqr1UQOwmXhRM9Gk= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:eR8wca74ADgxBrvX0uNpdB1qnPaGx/KhCm4Xj8oqHfQ= -github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI= -github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff h1:JDT0Mcfpi3c525xzeli+v5dR9pf5HhdFjr8djRdhs10= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805113453-4b17c24d67ff/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924 h1:uGXX6gCF1q2ytIL0w1X3UAKgF/UZ7eDDAgOaSqLOeW8= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001 h1:y2AHkdji2I+zXv8rsSC8OjWEzJJjqW5OlmCsZR5+RuU= -github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= -github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= -github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= -github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= -github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= -github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= -github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= -github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -1171,7 +1099,6 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/maxbrunsfeld/counterfeiter/v6 v6.11.2 h1:yVCLo4+ACVroOEr4iFU1iH46Ldlzz2rTuu18Ra7M8sU= @@ -1376,7 +1303,6 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= github.com/prometheus/statsd_exporter v0.21.0/go.mod h1:rbT83sZq2V+p73lHhPZfMc3MLCHmSHelCh9hSGYNLTQ= @@ -1499,6 +1425,7 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= @@ -1891,10 +1818,6 @@ go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= @@ -1903,7 +1826,6 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= gocloud.dev v0.42.0/go.mod h1:zkaYAapZfQisXOA4bzhsbA4ckiStGQ3Psvs9/OQ5dPM= gocloud.dev/secrets/hashivault v0.42.0/go.mod h1:LXprr1XLEAT7BVZ+Y66dJEHQMzDsowIExj5Ktr9HLvM= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -1928,9 +1850,6 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= @@ -2001,22 +1920,6 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -2060,18 +1963,6 @@ golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -2107,8 +1998,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go. google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= @@ -2147,12 +2036,6 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -2166,9 +2049,6 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= @@ -2218,8 +2098,6 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbF k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg= modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= From 488423abfc7af942378d86e6a12cf56a7b723868 Mon Sep 17 00:00:00 2001 From: Elliot Kirk Date: Fri, 7 Nov 2025 04:53:42 -0500 Subject: [PATCH 081/209] Icons: add hand pointer icon (#113255) add hand pointer icon --- packages/grafana-data/src/types/icon.ts | 1 + public/img/icons/unicons/hand-pointer.svg | 1 + 2 files changed, 2 insertions(+) create mode 100644 public/img/icons/unicons/hand-pointer.svg diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 6a6f91be45c..b49be0d5363 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -162,6 +162,7 @@ export const availableIconsIndex = { globe: true, grafana: true, 'graph-bar': true, + 'hand-pointer': true, heart: true, 'heart-rate': true, 'heart-break': true, diff --git a/public/img/icons/unicons/hand-pointer.svg b/public/img/icons/unicons/hand-pointer.svg new file mode 100644 index 00000000000..af9825dc305 --- /dev/null +++ b/public/img/icons/unicons/hand-pointer.svg @@ -0,0 +1 @@ + \ No newline at end of file From 942b847952d0a8b5cb6cd89235cc7afb0f8206d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Fri, 7 Nov 2025 10:54:24 +0100 Subject: [PATCH 082/209] CloudWatch: Add anomaly command to language support, add documentation for anomaly queries (#113311) --- .../aws-cloudwatch/query-editor/index.md | 15 ++++++++++++++- .../dataquery/x/CloudWatchDataQuery_types.gen.ts | 6 +++--- .../kinds/dataquery/types_dataquery_gen.go | 6 +++--- .../LogsQueryEditor/LogsQueryEditor.tsx | 2 +- .../plugins/datasource/cloudwatch/dataquery.cue | 6 +++--- .../datasource/cloudwatch/dataquery.gen.ts | 6 +++--- .../cloudwatch/language/logs/language.ts | 3 ++- .../CloudWatchLogsQueryRunner.test.ts | 2 +- 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md index 02b229e50f9..9bc7ab64047 100644 --- a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md +++ b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md @@ -250,6 +250,19 @@ You can query CloudWatch Logs using three supported query language options: 1. Select a region. 1. Select **CloudWatch Logs** from the query type drop-down. +1. Select the Logs Mode depending on whether you would like to query CloudWatch Logs Insights or Log Anomalies + +**Log Anomalies** + +[Anomaly detection](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/LogsAnomalyDetection.html) uses machine-learning and pattern recognition to establish baselines of typical log content. +The Log Anomalies query editor fetches the list of anomalies detected in your CloudWatch service. In order to query log anomalies in the editor, a log anomaly detector must be created in the AWS CloudWatch console first. +The log trend cell shows the number of occurrences of the pattern over the selected query time range. +The table shows 50 log anomalies at a time. If you would like to narrow down the list, you can filter anomalies by their ARN and suppressed state. + +In addition to this, you can use the Logs Insights QL editor and the `anomaly` command together with the `patterns` command to define and display log anomalies in real time. See the [CloudWatch Logs Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/LogsAnomalyDetection-Insights.html) documentation for more info. + +**Logs Insights** + 1. Select the query language you would like to use in the **Query Language** drop-down. 1. Click **Select log groups** and choose up to 20 log groups to query. 1. Use the main input area to write your logs query. Amazon CloudWatch only supports a subset of OpenSearch SQL and PPL commands. To find out more about the syntax supported, consult [Amazon CloudWatch Logs documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_AnalyzeLogData_Languages.html) @@ -258,7 +271,7 @@ You can query CloudWatch Logs using three supported query language options: You must specify the region and log groups when querying with **Logs Insights QL** and **OpenSearch PPL**. **OpenSearch SQL** doesn't require log group selection. However, selecting log groups simplifies query writing by populating syntax suggestions with discovered log group fields. {{< /admonition >}} -Click **CloudWatch Logs Insights** to interactively view, search, and analyze your log data in the CloudWatch Logs Insights console. If you're not logged in to the CloudWatch console, the link forwards you to the login page. +Click **View in CloudWatch console** to interactively view, search, and analyze your log data in the CloudWatch Logs Insights console. If you're not logged in to the CloudWatch console, the link forwards you to the login page. ### Query Log groups with OpenSearch SQL diff --git a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts index 7f197b13d18..eb0dd667076 100644 --- a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts @@ -247,7 +247,7 @@ export interface CloudWatchLogsQuery extends common.DataQuery { */ logGroups?: Array; /** - * Whether a query is a Logs Insights or Logs Anomalies query + * Whether a query is a Logs Insights or Log Anomalies query */ logsMode?: LogsMode; /** @@ -275,7 +275,7 @@ export const defaultCloudWatchLogsQuery: Partial = { }; /** - * Shape of a Cloudwatch Logs Anomalies query + * Shape of a Cloudwatch Log Anomalies query */ export interface CloudWatchLogsAnomaliesQuery extends common.DataQuery { /** @@ -284,7 +284,7 @@ export interface CloudWatchLogsAnomaliesQuery extends common.DataQuery { anomalyDetectionARN?: string; id: string; /** - * Whether a query is a Logs Insights or Logs Anomalies query + * Whether a query is a Logs Insights or Log Anomalies query */ logsMode?: LogsMode; /** diff --git a/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go index ce8bc54c6c5..7d943a71130 100644 --- a/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/cloudwatch/kinds/dataquery/types_dataquery_gen.go @@ -304,7 +304,7 @@ const ( type CloudWatchLogsQuery struct { // Whether a query is a Metrics, Logs, or Annotations query QueryMode CloudWatchQueryMode `json:"queryMode"` - // Whether a query is a Logs Insights or Logs Anomalies query + // Whether a query is a Logs Insights or Log Anomalies query LogsMode *LogsMode `json:"logsMode,omitempty"` Id string `json:"id"` // AWS region to query for the logs @@ -356,14 +356,14 @@ func NewLogGroup() *LogGroup { return &LogGroup{} } -// Shape of a Cloudwatch Logs Anomalies query +// Shape of a Cloudwatch Log Anomalies query type CloudWatchLogsAnomaliesQuery struct { Id string `json:"id"` // AWS region to query for the logs Region string `json:"region"` // Whether a query is a Metrics, Logs or Annotations query QueryMode *CloudWatchQueryMode `json:"queryMode,omitempty"` - // Whether a query is a Logs Insights or Logs Anomalies query + // Whether a query is a Logs Insights or Log Anomalies query LogsMode *LogsMode `json:"logsMode,omitempty"` // Filter to return only anomalies that are 'SUPPRESSED', 'UNSUPPRESSED', or 'ALL' (default) SuppressionState *string `json:"suppressionState,omitempty"` diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx index bf59c959066..b0659d7abe7 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx @@ -25,7 +25,7 @@ const logsQueryLanguageOptions: Array> = [ const logsModeOptions: Array> = [ { label: 'Logs Insights', value: LogsMode.Insights }, - { label: 'Logs Anomalies', value: LogsMode.Anomalies }, + { label: 'Log Anomalies', value: LogsMode.Anomalies }, ]; export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor(props: Props) { diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.cue b/public/app/plugins/datasource/cloudwatch/dataquery.cue index 4144b423419..836d59c8f60 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.cue +++ b/public/app/plugins/datasource/cloudwatch/dataquery.cue @@ -155,7 +155,7 @@ composableKinds: DataQuery: { // Whether a query is a Metrics, Logs, or Annotations query queryMode: #CloudWatchQueryMode - // Whether a query is a Logs Insights or Logs Anomalies query + // Whether a query is a Logs Insights or Log Anomalies query logsMode?: #LogsMode id: string // AWS region to query for the logs @@ -173,7 +173,7 @@ composableKinds: DataQuery: { queryLanguage?: #LogsQueryLanguage } @cuetsy(kind="interface") - // Shape of a Cloudwatch Logs Anomalies query + // Shape of a Cloudwatch Log Anomalies query #CloudWatchLogsAnomaliesQuery: { common.DataQuery id: string @@ -181,7 +181,7 @@ composableKinds: DataQuery: { region: string // Whether a query is a Metrics, Logs or Annotations query queryMode?: #CloudWatchQueryMode - // Whether a query is a Logs Insights or Logs Anomalies query + // Whether a query is a Logs Insights or Log Anomalies query logsMode?: #LogsMode // Filter to return only anomalies that are 'SUPPRESSED', 'UNSUPPRESSED', or 'ALL' (default) suppressionState?: string diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts index ba0a1134d2d..28818f2f1ab 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts +++ b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts @@ -245,7 +245,7 @@ export interface CloudWatchLogsQuery extends common.DataQuery { */ logGroups?: Array; /** - * Whether a query is a Logs Insights or Logs Anomalies query + * Whether a query is a Logs Insights or Log Anomalies query */ logsMode?: LogsMode; /** @@ -273,7 +273,7 @@ export const defaultCloudWatchLogsQuery: Partial = { }; /** - * Shape of a Cloudwatch Logs Anomalies query + * Shape of a Cloudwatch Log Anomalies query */ export interface CloudWatchLogsAnomaliesQuery extends common.DataQuery { /** @@ -282,7 +282,7 @@ export interface CloudWatchLogsAnomaliesQuery extends common.DataQuery { anomalyDetectionARN?: string; id: string; /** - * Whether a query is a Logs Insights or Logs Anomalies query + * Whether a query is a Logs Insights or Log Anomalies query */ logsMode?: LogsMode; /** diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/language.ts b/public/app/plugins/datasource/cloudwatch/language/logs/language.ts index f70d844aaf7..f49af73e2f3 100644 --- a/public/app/plugins/datasource/cloudwatch/language/logs/language.ts +++ b/public/app/plugins/datasource/cloudwatch/language/logs/language.ts @@ -17,7 +17,8 @@ export const SORT = 'sort'; export const LIMIT = 'limit'; export const PARSE = 'parse'; export const DEDUP = 'dedup'; -export const LOGS_COMMANDS = [DISPLAY, FIELDS, FILTER, PATTERN, STATS, SORT, LIMIT, PARSE, DEDUP, DIFF]; +export const ANOMALY = 'anomaly'; +export const LOGS_COMMANDS = [DISPLAY, FIELDS, FILTER, PATTERN, STATS, SORT, LIMIT, PARSE, DEDUP, DIFF, ANOMALY]; export const LOGS_LOGIC_OPERATORS = ['and', 'or', 'not']; diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts index 55d411585a3..8eee779cdcf 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts @@ -823,7 +823,7 @@ const stopQueryResponseStub = { const anomaliesQueryResponse: DataQueryResponse = { data: [ { - name: 'Logs anomalies', + name: 'Log anomalies', refId: 'A', meta: { preferredVisualisationType: 'table', From 36e28963d38b91c7d2faeae77fc9165f4162b91b Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:56:16 +0100 Subject: [PATCH 083/209] Scopes: Script for setting up gdev scope resources (#113448) * Script for setting up gdev scope objects * Script for setting up gdev scope objects * Format * Update codeowners * Do a feature flag check * Formatting * Remove FF check, because creation is explicit anyways * Formatting --- .github/CODEOWNERS | 1 + devenv/scopes/README.md | 140 ++++++++++ devenv/scopes/scopes-config.yaml | 84 ++++++ devenv/scopes/scopes.go | 433 +++++++++++++++++++++++++++++++ devenv/setup.sh | 16 ++ 5 files changed, 674 insertions(+) create mode 100644 devenv/scopes/README.md create mode 100644 devenv/scopes/scopes-config.yaml create mode 100644 devenv/scopes/scopes.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d891231bb37..462e7a39458 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -227,6 +227,7 @@ /devenv/datasources.yaml @grafana/grafana-backend-group /devenv/datasources_docker.yaml @grafana/grafana-backend-group /devenv/dev-dashboards-without-uid/ @grafana/dashboards-squad +/devenv/scopes/ @grafana/grafana-operator-experience-squad /devenv/dev-dashboards/annotations @grafana/dataviz-squad /devenv/dev-dashboards/migrations @grafana/dataviz-squad diff --git a/devenv/scopes/README.md b/devenv/scopes/README.md new file mode 100644 index 00000000000..3607b70d3eb --- /dev/null +++ b/devenv/scopes/README.md @@ -0,0 +1,140 @@ +# Scopes Provisioning Script + +This script generates Scopes, ScopeNodes, and ScopeNavigations for Grafana development environments. + +## Usage + +### Create resources + +```bash +# From devenv directory +./setup.sh scopes + +# Or run directly +cd scopes +go run scopes.go +``` + +### Delete all gdev-prefixed resources + +```bash +# From devenv directory +./setup.sh undev + +# Or run directly +cd scopes +go run scopes.go -clean +``` + +**Note about caching**: The `/find/scope_navigations` endpoint used by the UI caches ScopeNavigation results for 15 minutes. After running cleanup, deleted resources may still appear in the UI until the cache expires. The resources are actually deleted (you can verify by checking the `/scopenavigations` list endpoint), but the UI will refresh after ~15 minutes or after restarting Grafana. + +Doing an `Empty Cache and Hard Reload` will also help. + +## Configuration + +The script reads from `scopes-config.yaml` by default. You can specify a different config file: + +```bash +go run scopes.go -config=my-config.yaml +``` + +### Configuration Format + +The configuration file uses YAML format with a natural tree structure. The indentation itself represents the hierarchy: + +- **scopes**: Map of scope definitions (key is the scope name) +- **tree**: Tree structure of scope nodes where the YAML structure defines parent-child relationships +- **navigations**: Map of scope navigations linking URLs to scopes (key is the navigation name) + +Example: + +```yaml +scopes: + app1: + title: Application 1 + filters: + - key: app + operator: equals + value: app1 + +tree: + environments: + title: Environments + nodeType: container + children: + production: + title: Production + nodeType: container + children: + app1-prod: + title: Application 1 + nodeType: leaf + linkId: app1 + linkType: scope + +navigations: + # Link to a dashboard + app1-nav: + url: /d/86Js1xRmk + scope: app1 + + # Link to another dashboard + app2-nav: + url: /d/GlAqcPgmz + scope: app2 + + # Custom URLs + explore-nav: + url: /explore + scope: app1 +``` + +### Tree Structure + +The tree structure uses YAML's natural indentation to represent hierarchy: + +- **Key**: Unique identifier for the node (will be prefixed with "gdev-") +- **title**: Display title +- **nodeType**: Either "container" (can have children) or "leaf" (selectable scope) +- **linkId**: References a scope name (if nodeType is "leaf") +- **linkType**: Usually "scope" +- **children**: Map of child nodes (nested structure follows YAML indentation) + +### Node Types + +- **container**: A category/grouping node that can contain other nodes +- **leaf**: A selectable node that links to a scope + +### Navigations + +Navigations link URLs to scopes. The `url` field should contain the full URL path (e.g., `/d/abc123` for dashboards or `/explore` for other pages). + +To find dashboard UIDs from gdev dashboards: + +```bash +# Find UIDs of all gdev dashboards +find devenv/dev-dashboards -name "*.json" -exec sh -c 'echo "{}:" && jq -r ".uid // .dashboard.uid // \"NO_UID\"" {}' \; + +# Or for a specific dashboard +jq -r ".uid // .dashboard.uid" devenv/dev-dashboards/all-panels.json +``` + +## Environment Variables + +- `GRAFANA_URL`: Grafana URL (default: http://localhost:3000) +- `GRAFANA_NAMESPACE`: Namespace (default: default) +- `GRAFANA_USER`: Grafana username (default: admin) +- `GRAFANA_PASSWORD`: Grafana password (default: admin) + +## Command Line Flags + +- `-url`: Grafana URL +- `-namespace`: Namespace +- `-config`: Config file path (default: scopes-config.yaml) +- `-user`: Grafana username +- `-password`: Grafana password +- `-clean`: Delete all gdev-prefixed resources + +## Prefix + +All resources are automatically prefixed with "gdev-" to avoid conflicts with production data. diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml new file mode 100644 index 00000000000..46592742bbd --- /dev/null +++ b/devenv/scopes/scopes-config.yaml @@ -0,0 +1,84 @@ +scopes: + app1: + title: Application 1 + filters: + - key: app + operator: equals + value: app1 + + app2: + title: Application 2 + filters: + - key: app + operator: equals + value: app2 + + cluster1: + title: Cluster 1 + filters: + - key: cluster + operator: equals + value: cluster1 + +tree: + gdev-scopes: + title: gdev-scopes + nodeType: container + children: + production: + title: Production + nodeType: container + children: + app1-prod: + title: Application 1 + nodeType: leaf + linkId: app1 + linkType: scope + app2-prod: + title: Application 2 + nodeType: leaf + linkId: app2 + linkType: scope + test-cases: + title: Test cases + nodeType: container + disableMultiSelect: true + children: + test-case-1: + title: Test case 1 + nodeType: leaf + linkId: test-case-1 + linkType: scope + test-case-2: + title: Test case 2 + nodeType: leaf + linkId: test-case-2 + linkType: scope + + clusters: + title: Clusters + nodeType: container + linkId: cluster1 + linkType: scope + children: + cluster1-node: + title: Cluster 1 + nodeType: leaf + linkId: cluster1 + linkType: scope + +navigations: + # Example: Link to a dashboard + app1-nav: + url: /d/86Js1xRmk + scope: app1 + + # Example: Link to a dashboard with full URL (already has /d/) + app2-nav: + url: /d/GlAqcPgmz + scope: app2 + + # Example: Custom URL path + custom-nav: + url: /explore + scope: app1 diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go new file mode 100644 index 00000000000..0480ee221e7 --- /dev/null +++ b/devenv/scopes/scopes.go @@ -0,0 +1,433 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" +) + +const ( + prefix = "gdev" + apiVersion = "scope.grafana.app/v0alpha1" + defaultURL = "http://localhost:3000" + defaultUser = "admin" +) + +var ( + grafanaURL = flag.String("url", getEnv("GRAFANA_URL", defaultURL), "Grafana URL") + namespace = flag.String("namespace", getEnv("GRAFANA_NAMESPACE", "default"), "Namespace") + configFile = flag.String("config", "scopes-config.yaml", "Config file path") + user = flag.String("user", getEnv("GRAFANA_USER", defaultUser), "Grafana username") + password = flag.String("password", getEnv("GRAFANA_PASSWORD", "admin"), "Grafana password") + cleanupFlag = flag.Bool("clean", false, "Delete all gdev-prefixed resources") +) + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +type Config struct { + Scopes map[string]ScopeConfig `yaml:"scopes"` + Tree map[string]TreeNode `yaml:"tree"` + Navigations map[string]NavigationConfig `yaml:"navigations"` +} + +// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec +type ScopeConfig struct { + Title string `yaml:"title"` + Filters []ScopeFilterConfig `yaml:"filters"` +} + +// ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter +type ScopeFilterConfig struct { + Key string `yaml:"key"` + Value string `yaml:"value"` + Values []string `yaml:"values,omitempty"` + Operator string `yaml:"operator"` +} + +// TreeNode is used for YAML parsing - converts to v0alpha1.ScopeNodeSpec +type TreeNode struct { + Title string `yaml:"title"` + NodeType string `yaml:"nodeType"` + LinkID string `yaml:"linkId,omitempty"` + LinkType string `yaml:"linkType,omitempty"` + Children map[string]TreeNode `yaml:"children,omitempty"` +} + +type NavigationConfig struct { + URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) + Scope string `yaml:"scope"` +} + +// Helper function to convert ScopeFilterConfig to v0alpha1.ScopeFilter +func convertFilter(cfg ScopeFilterConfig) v0alpha1.ScopeFilter { + filter := v0alpha1.ScopeFilter{ + Key: cfg.Key, + Value: cfg.Value, + Values: cfg.Values, + Operator: v0alpha1.FilterOperator(cfg.Operator), + } + return filter +} + +// Helper function to convert ScopeConfig to v0alpha1.ScopeSpec +func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec { + filters := make([]v0alpha1.ScopeFilter, len(cfg.Filters)) + for i, f := range cfg.Filters { + filters[i] = convertFilter(f) + } + return v0alpha1.ScopeSpec{ + Title: cfg.Title, + Filters: filters, + } +} + +type Client struct { + baseURL string + namespace string + httpClient *http.Client + auth string +} + +func NewClient(baseURL, namespace, user, password string) *Client { + return &Client{ + baseURL: baseURL, + namespace: namespace, + httpClient: &http.Client{}, + auth: basicAuth(user, password), + } +} + +func basicAuth(username, password string) string { + return fmt.Sprintf("%s:%s", username, password) +} + +func (c *Client) makeRequest(method, endpoint string, body []byte) error { + url := fmt.Sprintf("%s/apis/%s/namespaces/%s%s", c.baseURL, apiVersion, c.namespace, endpoint) + + var req *http.Request + var err error + + if body != nil { + req, err = http.NewRequest(method, url, bytes.NewBuffer(body)) + } else { + req, err = http.NewRequest(method, url, nil) + } + + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(strings.Split(c.auth, ":")[0], strings.Split(c.auth, ":")[1]) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + // For DELETE requests, 404 is acceptable (resource already deleted) + if resp.StatusCode == 404 { + return nil + } + return fmt.Errorf("API request failed: HTTP %d - %s", resp.StatusCode, string(bodyBytes)) + } + + return nil +} + +func (c *Client) createScope(name string, cfg ScopeConfig) error { + prefixedName := prefix + "-" + name + + spec := convertScopeSpec(cfg) + + resource := v0alpha1.Scope{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: "Scope", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: prefixedName, + }, + Spec: spec, + } + + body, err := json.Marshal(resource) + if err != nil { + return fmt.Errorf("failed to marshal scope: %w", err) + } + + fmt.Printf("✓ Creating scope: %s\n", prefixedName) + return c.makeRequest("POST", "/scopes", body) +} + +func (c *Client) createScopeNode(name string, node TreeNode, parentName string) error { + prefixedName := prefix + "-" + name + prefixedParent := "" + prefixedLinkID := "" + + if parentName != "" { + prefixedParent = prefix + "-" + parentName + } + + if node.LinkID != "" { + prefixedLinkID = prefix + "-" + node.LinkID + } + + nodeType := v0alpha1.NodeType(node.NodeType) + if nodeType == "" { + nodeType = v0alpha1.NodeTypeContainer + } + + linkType := v0alpha1.LinkType(node.LinkType) + if linkType == "" { + linkType = v0alpha1.LinkTypeScope + } + + spec := v0alpha1.ScopeNodeSpec{ + Title: node.Title, + NodeType: nodeType, + DisableMultiSelect: false, + } + + if prefixedParent != "" { + spec.ParentName = prefixedParent + } + + if prefixedLinkID != "" { + spec.LinkID = prefixedLinkID + spec.LinkType = linkType + } + + resource := v0alpha1.ScopeNode{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: "ScopeNode", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: prefixedName, + }, + Spec: spec, + } + + body, err := json.Marshal(resource) + if err != nil { + return fmt.Errorf("failed to marshal scope node: %w", err) + } + + fmt.Printf("✓ Creating scope node: %s\n", prefixedName) + return c.makeRequest("POST", "/scopenodes", body) +} + +func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error { + prefixedName := prefix + "-" + name + prefixedScope := prefix + "-" + nav.Scope + + if nav.URL == "" { + return fmt.Errorf("navigation %s must have 'url' specified", name) + } + + spec := v0alpha1.ScopeNavigationSpec{ + URL: nav.URL, + Scope: prefixedScope, + } + + resource := v0alpha1.ScopeNavigation{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: "ScopeNavigation", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: prefixedName, + }, + Spec: spec, + } + + body, err := json.Marshal(resource) + if err != nil { + return fmt.Errorf("failed to marshal scope navigation: %w", err) + } + + fmt.Printf("✓ Creating scope navigation: %s\n", prefixedName) + return c.makeRequest("POST", "/scopenavigations", body) +} + +func (c *Client) createTreeNodes(children map[string]TreeNode, parentName string) error { + for name, node := range children { + // Build full node name by appending to parent name + // This makes it easy to see the tree path from the node name + fullNodeName := name + if parentName != "" { + fullNodeName = parentName + "-" + name + } + + // parentName here is the full parent name (already includes full path) + err := c.createScopeNode(fullNodeName, node, parentName) + if err != nil { + return err + } + + if len(node.Children) > 0 { + // Pass fullNodeName as parent for children (will be prefixed with "gdev-" in createScopeNode) + if err := c.createTreeNodes(node.Children, fullNodeName); err != nil { + return err + } + } + } + + return nil +} + +func (c *Client) deleteResources() { + fmt.Println("Deleting all gdev-prefixed resources...") + + // Delete scopes (silently handle errors if endpoints aren't available) + c.deleteResourceType("/scopes", "scope") + + // Delete scope nodes + c.deleteResourceType("/scopenodes", "scope node") + + // Delete scope navigations + c.deleteResourceType("/scopenavigations", "scope navigation") + + fmt.Println("✓ Cleanup complete") +} + +func (c *Client) deleteResourceType(endpoint, resourceType string) { + url := fmt.Sprintf("%s/apis/%s/namespaces/%s%s", c.baseURL, apiVersion, c.namespace, endpoint) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + // Silently skip if we can't create request + return + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(strings.Split(c.auth, ":")[0], strings.Split(c.auth, ":")[1]) + + resp, err := c.httpClient.Do(req) + if err != nil { + // Silently skip if endpoint isn't available + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Silently skip if endpoint returns error (might not be available) + return + } + + var listResponse struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } `json:"items"` + } + + bodyBytes, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(bodyBytes, &listResponse); err != nil { + // Silently skip if we can't decode response + return + } + + if len(listResponse.Items) == 0 { + return + } + + deletedCount := 0 + for _, item := range listResponse.Items { + if strings.HasPrefix(item.Metadata.Name, prefix+"-") { + fmt.Printf(" Deleting %s: %s\n", resourceType, item.Metadata.Name) + deleteURL := fmt.Sprintf("%s/%s", endpoint, item.Metadata.Name) + if err := c.makeRequest("DELETE", deleteURL, nil); err != nil { + // Silently skip deletion errors + } else { + deletedCount++ + } + } + } +} + +func main() { + flag.Parse() + + client := NewClient(*grafanaURL, *namespace, *user, *password) + + if *cleanupFlag { + // Cleanup should be silent if endpoints aren't available + client.deleteResources() + return + } + + configData, err := os.ReadFile(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err) + os.Exit(1) + } + + var config Config + if err := yaml.Unmarshal(configData, &config); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing config file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Loading configuration from: %s\n", *configFile) + fmt.Printf("Grafana URL: %s\n", *grafanaURL) + fmt.Printf("Namespace: %s\n", *namespace) + fmt.Printf("Prefix: %s\n\n", prefix) + + // Create scopes + fmt.Println("Creating scopes...") + for name, scope := range config.Scopes { + if err := client.createScope(name, scope); err != nil { + fmt.Fprintf(os.Stderr, "Error creating scope %s: %v\n", name, err) + os.Exit(1) + } + } + fmt.Println() + + // Create scope nodes (tree structure) + if len(config.Tree) > 0 { + fmt.Println("Creating scope nodes...") + if err := client.createTreeNodes(config.Tree, ""); err != nil { + fmt.Fprintf(os.Stderr, "Error creating scope nodes: %v\n", err) + os.Exit(1) + } + fmt.Println() + } + + // Create scope navigations + if len(config.Navigations) > 0 { + fmt.Println("Creating scope navigations...") + for name, nav := range config.Navigations { + if err := client.createScopeNavigation(name, nav); err != nil { + fmt.Fprintf(os.Stderr, "Error creating scope navigation %s: %v\n", name, err) + os.Exit(1) + } + } + fmt.Println() + } + + fmt.Println("✓ All resources created successfully!") +} diff --git a/devenv/setup.sh b/devenv/setup.sh index 00eb50b5257..d3774ca19ec 100755 --- a/devenv/setup.sh +++ b/devenv/setup.sh @@ -19,6 +19,13 @@ bulkFolders() { ln -s -f ../../../devenv/bulk-folders/bulk-folders.yaml ../conf/provisioning/dashboards/bulk-folders.yaml } +scopes() { + echo -e "\xE2\x9C\x94 Setting up scopes, scope nodes, and scope navigations" + cd scopes + go run scopes.go + cd .. +} + requiresJsonnet() { if ! type "jsonnet" > /dev/null; then echo "you need you install jsonnet to run this script" @@ -49,6 +56,12 @@ undev() { rm -rf bulk-folders/Bulk\ Folder* echo -e " \xE2\x9C\x94 Reverting bulk-folders provisioning" + # Removing scopes, scope nodes, and scope navigations + cd scopes + go run scopes.go -clean + cd .. + echo -e " \xE2\x9C\x94 Deleting scopes, scope nodes, and scope navigations" + # Removing the symlinks rm -f ../conf/provisioning/dashboards/custom.yaml rm -f ../conf/provisioning/dashboards/bulk-folders.yaml @@ -63,6 +76,7 @@ usage() { echo " bulk-dashboards - provision 400 dashboards" echo " bulk-folders [folders] [dashboards] - provision many folders with dashboards" echo " bulk-folders - provision 200 folders with 3 dashboards in each" + echo " scopes - provision scopes, scope nodes, and scope navigations" echo " no args - provision core datasources and dev dashboards" echo " undev - removes any provisioning done by the setup.sh" } @@ -80,6 +94,8 @@ main() { bulkDashboard elif [[ $cmd == "bulk-folders" ]]; then bulkFolders "$arg1" + elif [[ $cmd == "scopes" ]]; then + scopes elif [[ $cmd == "undev" ]]; then undev else From b4d2d1eaf5ec9248adddb204951976daec40954f Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 7 Nov 2025 11:15:18 +0100 Subject: [PATCH 084/209] Alerting: Fix width of the code editor for Alertmanager configurations (#113541) fix width of the code editor for Alertmanager configurations --- .../unified/components/settings/AlertmanagerConfig.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx index c42566d7610..8282d16ed97 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx @@ -165,11 +165,11 @@ export default function AlertmanagerConfig({ alertmanagerName, onDismiss, onSave {isLoadingSuccessful && (
- - {({ height, width }) => ( + + {({ height }) => ( Date: Fri, 7 Nov 2025 11:51:32 +0100 Subject: [PATCH 085/209] fix(unified-storage): resource server tracing (#113582) --- pkg/storage/unified/resource/bulk.go | 2 +- pkg/storage/unified/resource/search.go | 19 +++++--------- pkg/storage/unified/resource/search_test.go | 11 ++++---- pkg/storage/unified/resource/server.go | 28 ++++++++------------- pkg/storage/unified/sql/server.go | 1 - 5 files changed, 22 insertions(+), 39 deletions(-) diff --git a/pkg/storage/unified/resource/bulk.go b/pkg/storage/unified/resource/bulk.go index 935204384df..1ac0ff2032f 100644 --- a/pkg/storage/unified/resource/bulk.go +++ b/pkg/storage/unified/resource/bulk.go @@ -110,7 +110,7 @@ func NewBulkSettings(md metadata.MD) (BulkSettings, error) { // All requests must be to the same NAMESPACE/GROUP/RESOURCE func (s *server) BulkProcess(stream resourcepb.BulkStore_BulkProcessServer) error { ctx := stream.Context() - ctx, span := s.tracer.Start(ctx, "resource.server.BulkProcess") + ctx, span := tracer.Start(ctx, "resource.server.BulkProcess") defer span.End() sendAndClose := func(rsp *resourcepb.BulkResponse) error { diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index edaaa0abba6..0f3e9f95912 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -127,11 +127,8 @@ type SearchBackend interface { GetOpenIndexes() []NamespacedResource } -const tracingPrexfixSearch = "unified_search." - // This supports indexing+search regardless of implementation type searchSupport struct { - tracer trace.Tracer log *slog.Logger storage StorageBackend search SearchBackend @@ -163,14 +160,11 @@ var ( _ resourcepb.ManagedObjectIndexServer = (*searchSupport)(nil) ) -func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.AccessClient, blob BlobSupport, tracer trace.Tracer, indexMetrics *BleveIndexMetrics, ownsIndexFn func(key NamespacedResource) (bool, error)) (support *searchSupport, err error) { +func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.AccessClient, blob BlobSupport, indexMetrics *BleveIndexMetrics, ownsIndexFn func(key NamespacedResource) (bool, error)) (support *searchSupport, err error) { // No backend search support if opts.Backend == nil { return nil, nil } - if tracer == nil { - return nil, fmt.Errorf("missing tracer") - } if opts.InitWorkerThreads < 1 { opts.InitWorkerThreads = 1 @@ -188,7 +182,6 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.A support = &searchSupport{ access: access, - tracer: tracer, storage: storage, search: opts.Backend, log: slog.Default().With("logger", "resource-search"), @@ -341,7 +334,7 @@ func (s *searchSupport) CountManagedObjects(ctx context.Context, req *resourcepb // Search implements ResourceIndexServer. func (s *searchSupport) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) { - ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Search") + ctx, span := tracer.Start(ctx, "resource.searchSupport.Search") defer span.End() if req.Options.Key.Namespace == "" || req.Options.Key.Group == "" || req.Options.Key.Resource == "" { @@ -499,7 +492,7 @@ func (s *searchSupport) buildIndexes(ctx context.Context) (int, error) { func (s *searchSupport) init(ctx context.Context) error { origCtx := ctx - ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init") + ctx, span := tracer.Start(ctx, "resource.searchSupport.init") defer span.End() start := time.Now().Unix() @@ -632,7 +625,7 @@ func (s *searchSupport) runIndexRebuilder(ctx context.Context) { } func (s *searchSupport) rebuildIndex(ctx context.Context, req rebuildRequest) { - ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"RebuildIndex") + ctx, span := tracer.Start(ctx, "resource.searchSupport.rebuildIndex") defer span.End() l := s.log.With("namespace", req.Namespace, "group", req.Group, "resource", req.Resource) @@ -731,7 +724,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)") } - ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"GetOrCreateIndex") + ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex") defer span.End() span.SetAttributes( attribute.String("namespace", key.Namespace), @@ -808,7 +801,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso } func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, indexBuildReason string, rebuild bool) (ResourceIndex, error) { - ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build") + ctx, span := tracer.Start(ctx, "resource.searchSupport.build") defer span.End() span.SetAttributes( diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index a35a8be7052..2c3cfe81097 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/authlib/types" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/trace/noop" dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -211,7 +210,7 @@ func TestSearchGetOrCreateIndex(t *testing.T) { InitMinCount: 1, // set min count to default for this test } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, support) @@ -267,7 +266,7 @@ func TestSearchGetOrCreateIndexWithIndexUpdate(t *testing.T) { } // Enable searchAfterWrite - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, support) @@ -316,7 +315,7 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { InitMinCount: 1, // set min count to default for this test } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, support) @@ -594,7 +593,7 @@ func TestFindIndexesForRebuild(t *testing.T) { MinBuildVersion: semver.MustParse("5.5.5"), } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, support) @@ -665,7 +664,7 @@ func TestRebuildIndexes(t *testing.T) { Resources: supplier, } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, support) diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 76c3e3c2a8c..b317bf20727 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -14,8 +14,7 @@ import ( "github.com/Masterminds/semver" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" - "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" + "go.opentelemetry.io/otel" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -30,6 +29,8 @@ import ( "github.com/grafana/grafana/pkg/util/scheduler" ) +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/resource") + // ResourceServer implements all gRPC services type ResourceServer interface { resourcepb.ResourceStoreServer @@ -210,9 +211,6 @@ type SearchOptions struct { } type ResourceServerOptions struct { - // OTel tracer - Tracer trace.Tracer - // Real storage backend Backend StorageBackend @@ -259,10 +257,6 @@ type ResourceServerOptions struct { } func NewResourceServer(opts ResourceServerOptions) (*server, error) { - if opts.Tracer == nil { - opts.Tracer = noop.NewTracerProvider().Tracer("resource-server") - } - if opts.Backend == nil { return nil, fmt.Errorf("missing Backend implementation") } @@ -314,8 +308,8 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { } blobstore, err = NewCDKBlobSupport(ctx, CDKBlobSupportOptions{ - Tracer: opts.Tracer, - Bucket: NewInstrumentedBucket(bucket, opts.Reg, opts.Tracer), + Tracer: tracer, + Bucket: NewInstrumentedBucket(bucket, opts.Reg, tracer), }) if err != nil { return nil, err @@ -331,7 +325,6 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { // Make this cancelable ctx, cancel := context.WithCancel(context.Background()) s := &server{ - tracer: opts.Tracer, log: logger, backend: opts.Backend, blob: blobstore, @@ -355,7 +348,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { if opts.Search.Resources != nil { var err error - s.search, err = newSearchSupport(opts.Search, s.backend, s.access, s.blob, opts.Tracer, opts.IndexMetrics, opts.OwnsIndexFn) + s.search, err = newSearchSupport(opts.Search, s.backend, s.access, s.blob, opts.IndexMetrics, opts.OwnsIndexFn) if err != nil { return nil, err } @@ -373,7 +366,6 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { var _ ResourceServer = &server{} type server struct { - tracer trace.Tracer log *slog.Logger backend StorageBackend blob BlobSupport @@ -651,7 +643,7 @@ func (s *server) checkFolderMovePermissions(ctx context.Context, user claims.Aut } func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*resourcepb.CreateResponse, error) { - ctx, span := s.tracer.Start(ctx, "storage_server.Create") + ctx, span := tracer.Start(ctx, "resource.server.Create") defer span.End() if r := verifyRequestKey(req.Key); r != nil { @@ -738,7 +730,7 @@ func (s *server) sleepAfterSuccessfulWriteOperation(res responseWithErrorResult, } func (s *server) Update(ctx context.Context, req *resourcepb.UpdateRequest) (*resourcepb.UpdateResponse, error) { - ctx, span := s.tracer.Start(ctx, "storage_server.Update") + ctx, span := tracer.Start(ctx, "resource.server.Update") defer span.End() rsp := &resourcepb.UpdateResponse{} @@ -812,7 +804,7 @@ func (s *server) update(ctx context.Context, user claims.AuthInfo, req *resource } func (s *server) Delete(ctx context.Context, req *resourcepb.DeleteRequest) (*resourcepb.DeleteResponse, error) { - ctx, span := s.tracer.Start(ctx, "storage_server.Delete") + ctx, span := tracer.Start(ctx, "resource.server.Delete") defer span.End() rsp := &resourcepb.DeleteResponse{} @@ -983,7 +975,7 @@ func (s *server) read(ctx context.Context, user claims.AuthInfo, req *resourcepb } func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resourcepb.ListResponse, error) { - ctx, span := s.tracer.Start(ctx, "storage_server.List") + ctx, span := tracer.Start(ctx, "resource.server.List") defer span.End() // The history + trash queries do not yet support additional filters diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index fbcb8b90b36..60fa9b8467a 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -62,7 +62,6 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { } serverOptions := resource.ResourceServerOptions{ - Tracer: opts.Tracer, Blob: resource.BlobConfig{ URL: apiserverCfg.Key("blob_url").MustString(""), }, From c784de6ef590172ae96df52b13bfec4e802d7224 Mon Sep 17 00:00:00 2001 From: Seunghun Shin <42256738+softho0n@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:51:48 +0900 Subject: [PATCH 086/209] Alerting: Add compressed periodic save for alert instances (#111803) What is this feature? This PR implements compressed periodic save for alert state storage, providing a more efficient alternative to regular periodic saves by grouping alert instances by rule UID and storing them using protobuf and snappy compression. When enabled via the state_compressed_periodic_save_enabled configuration option, the system groups alert instances by their alert rule, compresses each group using protobuf serialization and snappy compression, and processes all rules within a single database transaction at specified intervals instead of syncing after every alert evaluation cycle. Why do we need this feature? During discussions in PR #111357, we identified the need for a compressed approach to periodic alert state storage that could further reduce database load beyond the jitter mechanism. While the jitter feature distributes database operations over time, this compressed periodic save approach reduces the frequency of database operations by batching alert state updates at explicitly declared intervals rather than syncing after every alert evaluation cycle. This approach provides several key benefits: - Reduced Database Frequency: Instead of frequent sync operations tied to alert evaluation cycles, updates occur only at configured intervals - Storage Efficiency: Rule-based grouping with protobuf and snappy compression significantly reduces storage requirements The compressed periodic save complements the existing jitter mechanism by providing an alternative strategy focused on reducing overall database interaction frequency while maintaining data integrity through compression and batching. Who is this feature for? - Platform/Infrastructure teams managing large-scale Grafana deployments with high alert cardinality - Organizations looking to optimize storage costs and database performance for alerting workloads - Production environments with 1000+ alert rules where database write frequency is a concern --- .../set-up/performance-limitations/index.md | 18 +- pkg/services/ngalert/ngalert.go | 17 +- pkg/services/ngalert/ngalert_test.go | 4 +- pkg/services/ngalert/state/persist.go | 4 + pkg/services/ngalert/state/persister_async.go | 4 - .../ngalert/state/persister_sync_rule.go | 56 +++++- .../ngalert/store/proto_instance_database.go | 109 +++++++++--- .../store/proto_instance_database_test.go | 160 ++++++++++++++++++ 8 files changed, 330 insertions(+), 42 deletions(-) diff --git a/docs/sources/alerting/set-up/performance-limitations/index.md b/docs/sources/alerting/set-up/performance-limitations/index.md index 82e8ccd41f1..b18f3b6ec68 100644 --- a/docs/sources/alerting/set-up/performance-limitations/index.md +++ b/docs/sources/alerting/set-up/performance-limitations/index.md @@ -68,7 +68,21 @@ You can change this behavior by disabling the `alertingSaveStateCompressed` feat You can also reduce database load by writing states periodically instead of after every evaluation. -To save state periodically: +There are two approaches for periodic state saving: + +#### Compressed periodic saves + +You can combine compressed alert state storage with periodic saves by enabling both `alertingSaveStateCompressed` and `alertingSaveStatePeriodic` feature toggles together. + +This approach groups all alert instances by rule UID and compresses them together for efficient storage. + +When both feature toggles are enabled, Grafana will save compressed alert states at the interval specified by `state_periodic_save_interval`. Note that in compressed mode, the `state_periodic_save_batch_size` setting is ignored as the system groups instances by rule UID rather than by batch size. + +#### Batch-based periodic saves + +Alternatively, you can use batch-based periodic saves without compression: + +This approach processes individual alert instances in batches of a specified size. 1. Enable the `alertingSaveStatePeriodic` feature toggle. 1. Disable the `alertingSaveStateCompressed` feature toggle. @@ -77,7 +91,7 @@ By default, it saves the states every 5 minutes to the database and on each shut can also be configured using the `state_periodic_save_interval` configuration flag. During this process, Grafana deletes all existing alert instances from the database and then writes the entire current set of instances back in batches in a single transaction. Configure the size of each batch using the `state_periodic_save_batch_size` configuration option. -#### Jitter for periodic saves +##### Jitter for batch-based periodic saves To further distribute database load, you can enable jitter for periodic state saves by setting `state_periodic_save_jitter_enabled = true`. When jitter is enabled, instead of saving all batches simultaneously, Grafana spreads the batch writes across a calculated time window of 85% of the save interval. diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index ebb7e1061c2..6de3f7b1ba0 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -504,13 +504,6 @@ func initInstanceStore(sqlStore db.DB, logger log.Logger, featureToggles feature if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStateCompressed) { logger.Info("Using protobuf-based alert instance store") instanceStore = protoInstanceStore - // If FlagAlertingSaveStateCompressed is enabled, ProtoInstanceDBStore is used, - // which functions differently from InstanceDBStore. FlagAlertingSaveStatePeriodic is - // not applicable to ProtoInstanceDBStore, so a warning is logged if it is set. - //nolint:staticcheck // not yet migrated to OpenFeature - if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) { - logger.Warn("alertingSaveStatePeriodic is not used when alertingSaveStateCompressed feature flag enabled") - } } else { logger.Info("Using simple database alert instance store") instanceStore = simpleInstanceStore @@ -525,7 +518,15 @@ func initStatePersister(uaCfg setting.UnifiedAlertingSettings, cfg state.Manager //nolint:staticcheck // not yet migrated to OpenFeature if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStateCompressed) { logger.Info("Using rule state persister") - statePersister = state.NewSyncRuleStatePersisiter(logger, cfg) + + if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) { + logger.Info("Compressed storage with periodic save enabled") + ticker := clock.New().Ticker(cfg.StatePeriodicSaveInterval) + statePersister = state.NewSyncRuleStatePersisiter(logger, ticker, cfg) + } else { + logger.Info("Compressed storage FullSync disabled") + statePersister = state.NewSyncRuleStatePersisiter(logger, nil, cfg) + } } else if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) { logger.Info("Using periodic state persister") ticker := clock.New().Ticker(uaCfg.StatePeriodicSaveInterval) diff --git a/pkg/services/ngalert/ngalert_test.go b/pkg/services/ngalert/ngalert_test.go index c9adb2603cc..ef81499cd77 100644 --- a/pkg/services/ngalert/ngalert_test.go +++ b/pkg/services/ngalert/ngalert_test.go @@ -436,7 +436,9 @@ func TestInitStatePersister(t *testing.T) { ua := setting.UnifiedAlertingSettings{ StatePeriodicSaveInterval: 1 * time.Minute, } - cfg := state.ManagerCfg{} + cfg := state.ManagerCfg{ + StatePeriodicSaveInterval: 1 * time.Minute, + } tests := []struct { name string diff --git a/pkg/services/ngalert/state/persist.go b/pkg/services/ngalert/state/persist.go index 7c7442b986e..1ad5beed0ca 100644 --- a/pkg/services/ngalert/state/persist.go +++ b/pkg/services/ngalert/state/persist.go @@ -8,6 +8,10 @@ import ( history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" ) +type AlertInstancesProvider interface { + GetAlertInstances() []models.AlertInstance +} + // InstanceStore represents the ability to fetch and write alert instances. type InstanceStore interface { InstanceReader diff --git a/pkg/services/ngalert/state/persister_async.go b/pkg/services/ngalert/state/persister_async.go index 0de1dd5cdde..f76eed5f06e 100644 --- a/pkg/services/ngalert/state/persister_async.go +++ b/pkg/services/ngalert/state/persister_async.go @@ -12,10 +12,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" ) -type AlertInstancesProvider interface { - GetAlertInstances() []models.AlertInstance -} - type AsyncStatePersister struct { log log.Logger batchSize int diff --git a/pkg/services/ngalert/state/persister_sync_rule.go b/pkg/services/ngalert/state/persister_sync_rule.go index da2494d3647..a237d2983f2 100644 --- a/pkg/services/ngalert/state/persister_sync_rule.go +++ b/pkg/services/ngalert/state/persister_sync_rule.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/benbjohnson/clock" "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/infra/log" @@ -11,22 +12,63 @@ import ( ) type SyncRuleStatePersister struct { - log log.Logger - store InstanceStore + log log.Logger + store InstanceStore + ticker *clock.Ticker } -func NewSyncRuleStatePersisiter(log log.Logger, cfg ManagerCfg) StatePersister { +func NewSyncRuleStatePersisiter(log log.Logger, ticker *clock.Ticker, cfg ManagerCfg) StatePersister { return &SyncRuleStatePersister{ - log: log, - store: cfg.InstanceStore, + log: log, + store: cfg.InstanceStore, + ticker: ticker, } } -func (a *SyncRuleStatePersister) Async(_ context.Context, _ AlertInstancesProvider) { - a.log.Debug("Async: No-Op") +func (a *SyncRuleStatePersister) Async(ctx context.Context, instancesProvider AlertInstancesProvider) { + if a.ticker == nil { + return + } + + for { + select { + case <-a.ticker.C: + if err := a.fullSync(ctx, instancesProvider); err != nil { + a.log.Error("Failed to do a full compressed state sync to database", "err", err) + } + case <-ctx.Done(): + a.log.Info("Scheduler is shutting down, doing a final state sync.") + if err := a.fullSync(context.Background(), instancesProvider); err != nil { + a.log.Error("Failed to do a full compressed state sync to database", "err", err) + } + a.ticker.Stop() + a.log.Info("Compressed state async worker is shut down.") + return + } + } +} + +func (a *SyncRuleStatePersister) fullSync(ctx context.Context, instancesProvider AlertInstancesProvider) error { + startTime := time.Now() + a.log.Debug("Full compressed state sync start") + instances := instancesProvider.GetAlertInstances() + + // batchSize is set to 0 because compressed storage groups instances by ruleUID, not by batch size + err := a.store.FullSync(ctx, instances, 0, nil) + if err != nil { + a.log.Error("Full compressed state sync failed", "duration", time.Since(startTime), "instances", len(instances)) + return err + } + a.log.Debug("Full compressed state sync done", "duration", time.Since(startTime), "instances", len(instances)) + return nil } func (a *SyncRuleStatePersister) Sync(ctx context.Context, span trace.Span, ruleKey models.AlertRuleKeyWithGroup, states StateTransitions) { + if a.ticker != nil { + a.log.Debug("Skip immediate save, using periodic save instead") + return + } + if a.store == nil || len(states) == 0 { return } diff --git a/pkg/services/ngalert/store/proto_instance_database.go b/pkg/services/ngalert/store/proto_instance_database.go index eaa58253ac4..a9450d0851f 100644 --- a/pkg/services/ngalert/store/proto_instance_database.go +++ b/pkg/services/ngalert/store/proto_instance_database.go @@ -9,6 +9,7 @@ import ( "time" "github.com/golang/snappy" + "github.com/grafana/grafana/pkg/services/sqlstore" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" @@ -98,28 +99,13 @@ func (st ProtoInstanceDBStore) SaveAlertInstancesForRule(ctx context.Context, ke logger := st.Logger.FromContext(ctx) logger.Debug("SaveAlertInstancesForRule called", "rule_uid", key.UID, "org_id", key.OrgID, "instances", len(instances)) - alert_instances_proto := make([]*pb.AlertInstance, len(instances)) - - for i, instance := range instances { - alert_instances_proto[i] = alertInstanceModelToProto(instance) - } - - compressedAlertInstances, err := compressAlertInstances(alert_instances_proto) + compressedAlertInstances, err := convertAndCompressAlertInstances(instances) if err != nil { return fmt.Errorf("failed to compress alert instances: %w", err) } - return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - params := []any{key.OrgID, key.UID, compressedAlertInstances, time.Now()} - - upsertSQL := st.SQLStore.GetDialect().UpsertSQL( - "alert_rule_state", - []string{"org_id", "rule_uid"}, - []string{"org_id", "rule_uid", "data", "updated_at"}, - ) - _, err = sess.SQL(upsertSQL, params...).Query() - - return err + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + return st.upsertCompressedAlertInstances(sess, key.OrgID, key.UID, compressedAlertInstances, time.Now()) }) } @@ -134,9 +120,68 @@ func (st ProtoInstanceDBStore) DeleteAlertInstancesByRule(ctx context.Context, k } func (st ProtoInstanceDBStore) FullSync(ctx context.Context, instances []models.AlertInstance, batchSize int, jitterFunc func(int) time.Duration) error { + if len(instances) == 0 { + return nil + } + logger := st.Logger.FromContext(ctx) - logger.Error("FullSync called and not implemented") - return errors.New("fullsync is not implemented for proto instance database store") + logger.Debug("FullSync called", "total_instances", len(instances)) + + ruleGroups := make(map[models.AlertRuleKeyWithGroup][]models.AlertInstance) + for _, instance := range instances { + ruleKey := models.AlertRuleKeyWithGroup{ + AlertRuleKey: models.AlertRuleKey{ + OrgID: instance.RuleOrgID, + UID: instance.RuleUID, + }, + RuleGroup: "", + } + ruleGroups[ruleKey] = append(ruleGroups[ruleKey], instance) + } + + type preparedRule struct { + ruleKey models.AlertRuleKeyWithGroup + compressedData []byte + } + preparedRules := make([]preparedRule, 0, len(ruleGroups)) + + for ruleKey, ruleInstances := range ruleGroups { + // Convert and compress instances + compressedAlertInstances, err := convertAndCompressAlertInstances(ruleInstances) + if err != nil { + logger.Error("Failed to compress instances for rule", "rule_uid", ruleKey.UID, "error", err) + continue + } + + preparedRules = append(preparedRules, preparedRule{ + ruleKey: ruleKey, + compressedData: compressedAlertInstances, + }) + + logger.Debug("Prepared rule for sync", "rule_uid", ruleKey.UID, "org_id", ruleKey.OrgID, "instances", len(ruleInstances)) + } + + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + syncTimestamp := time.Now() + logger.Debug("Starting FullSync transaction", "rules_count", len(preparedRules), "timestamp", syncTimestamp) + + // First we delete all records from the table + if _, err := sess.Exec("DELETE FROM alert_rule_state"); err != nil { + return fmt.Errorf("failed to delete alert_rule_state: %w", err) + } + + for i, prepared := range preparedRules { + logger.Debug("Executing UPSERT for rule", "rule_uid", prepared.ruleKey.UID, "org_id", prepared.ruleKey.OrgID, "rule_index", i+1, "total_rules", len(preparedRules)) + + // Execute UPSERT with pre-compressed data using helper method + if err := st.upsertCompressedAlertInstances(sess, prepared.ruleKey.OrgID, prepared.ruleKey.UID, prepared.compressedData, syncTimestamp); err != nil { + return fmt.Errorf("failed to save instances for rule %s: %w", prepared.ruleKey.UID, err) + } + } + + logger.Debug("FullSync transaction completed successfully", "rules_synced", len(preparedRules)) + return nil + }) } func alertInstanceModelToProto(modelInstance models.AlertInstance) *pb.AlertInstance { @@ -155,6 +200,30 @@ func alertInstanceModelToProto(modelInstance models.AlertInstance) *pb.AlertInst } } +// convertAndCompressAlertInstances converts model instances to protobuf and compresses them +func convertAndCompressAlertInstances(instances []models.AlertInstance) ([]byte, error) { + alertInstancesProto := make([]*pb.AlertInstance, len(instances)) + for i, instance := range instances { + alertInstancesProto[i] = alertInstanceModelToProto(instance) + } + + return compressAlertInstances(alertInstancesProto) +} + +// upsertCompressedAlertInstances performs upsert operation for compressed alert instances +func (st ProtoInstanceDBStore) upsertCompressedAlertInstances(sess *sqlstore.DBSession, orgID int64, ruleUID string, compressedData []byte, timestamp time.Time) error { + upsertSQL := st.SQLStore.GetDialect().UpsertSQL( + "alert_rule_state", + []string{"org_id", "rule_uid"}, + []string{"org_id", "rule_uid", "data", "updated_at"}, + ) + + params := []any{orgID, ruleUID, compressedData, timestamp} + _, err := sess.SQL(upsertSQL, params...).Query() + + return err +} + func compressAlertInstances(instances []*pb.AlertInstance) ([]byte, error) { mProto, err := proto.Marshal(&pb.AlertInstances{Instances: instances}) if err != nil { diff --git a/pkg/services/ngalert/store/proto_instance_database_test.go b/pkg/services/ngalert/store/proto_instance_database_test.go index 04428a0efae..c98923995dd 100644 --- a/pkg/services/ngalert/store/proto_instance_database_test.go +++ b/pkg/services/ngalert/store/proto_instance_database_test.go @@ -174,6 +174,166 @@ func TestCompressAndDecompressAlertInstances(t *testing.T) { require.EqualExportedValues(t, alertInstances[1], decompressedInstances[1]) } +func TestConvertAndCompressAlertInstances(t *testing.T) { + now := time.Now() + + modelInstances := []models.AlertInstance{ + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleUID: "rule-uid-1", + RuleOrgID: 1, + LabelsHash: "hash-1", + }, + Labels: map[string]string{"label-1": "value-1"}, + CurrentState: models.InstanceStateFiring, + CurrentStateSince: now, + CurrentStateEnd: now.Add(time.Hour), + CurrentReason: "reason-1", + LastEvalTime: now.Add(-time.Minute), + LastSentAt: &now, + FiredAt: &now, + ResolvedAt: nil, + ResultFingerprint: "fingerprint-1", + }, + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleUID: "rule-uid-1", + RuleOrgID: 1, + LabelsHash: "hash-2", + }, + Labels: map[string]string{"label-2": "value-2"}, + CurrentState: models.InstanceStateNormal, + CurrentStateSince: now, + CurrentStateEnd: now.Add(time.Hour), + CurrentReason: "reason-2", + LastEvalTime: now.Add(-time.Minute), + LastSentAt: nil, + FiredAt: nil, + ResolvedAt: &now, + ResultFingerprint: "fingerprint-2", + }, + } + + compressedData, err := convertAndCompressAlertInstances(modelInstances) + require.NoError(t, err) + require.NotEmpty(t, compressedData) + + // Verify we can decompress and get back the same data + decompressedInstances, err := decompressAlertInstances(compressedData) + require.NoError(t, err) + require.Len(t, decompressedInstances, 2) + + // Convert back to model to compare + for i, protoInstance := range decompressedInstances { + modelInstance := alertInstanceProtoToModel("rule-uid-1", 1, protoInstance) + require.Equal(t, modelInstances[i].Labels, modelInstance.Labels) + require.Equal(t, modelInstances[i].CurrentState, modelInstance.CurrentState) + require.Equal(t, modelInstances[i].LabelsHash, modelInstance.LabelsHash) + require.Equal(t, modelInstances[i].ResultFingerprint, modelInstance.ResultFingerprint) + } +} + +func TestConvertAndCompressAlertInstances_EmptyInput(t *testing.T) { + emptyInstances := []models.AlertInstance{} + + compressedData, err := convertAndCompressAlertInstances(emptyInstances) + require.NoError(t, err) + + decompressedInstances, err := decompressAlertInstances(compressedData) + require.NoError(t, err) + require.Empty(t, decompressedInstances) +} + +func TestFullSyncGroupingLogic(t *testing.T) { + now := time.Now() + + // Test instances from multiple rules to verify grouping logic + instances := []models.AlertInstance{ + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleUID: "rule-1", + RuleOrgID: 1, + LabelsHash: "hash-1-1", + }, + Labels: models.InstanceLabels{"rule1": "instance1"}, + CurrentState: models.InstanceStateFiring, + CurrentStateSince: now, + CurrentStateEnd: now.Add(time.Hour), + CurrentReason: "test reason 1", + LastEvalTime: now.Add(-time.Minute), + ResultFingerprint: "fingerprint-1-1", + }, + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleUID: "rule-1", + RuleOrgID: 1, + LabelsHash: "hash-1-2", + }, + Labels: models.InstanceLabels{"rule1": "instance2"}, + CurrentState: models.InstanceStateNormal, + CurrentStateSince: now, + CurrentStateEnd: now.Add(time.Hour), + CurrentReason: "test reason 2", + LastEvalTime: now.Add(-time.Minute), + ResultFingerprint: "fingerprint-1-2", + }, + { + AlertInstanceKey: models.AlertInstanceKey{ + RuleUID: "rule-2", + RuleOrgID: 1, + LabelsHash: "hash-2-1", + }, + Labels: models.InstanceLabels{"rule2": "instance1"}, + CurrentState: models.InstanceStatePending, + CurrentStateSince: now, + CurrentStateEnd: now.Add(time.Hour), + CurrentReason: "test reason 3", + LastEvalTime: now.Add(-time.Minute), + ResultFingerprint: "fingerprint-2-1", + }, + } + + // Test the grouping logic that FullSync uses internally + ruleGroups := make(map[models.AlertRuleKeyWithGroup][]models.AlertInstance) + for _, instance := range instances { + ruleKey := models.AlertRuleKeyWithGroup{ + AlertRuleKey: models.AlertRuleKey{ + OrgID: instance.RuleOrgID, + UID: instance.RuleUID, + }, + RuleGroup: "", + } + ruleGroups[ruleKey] = append(ruleGroups[ruleKey], instance) + } + + // Verify grouping worked correctly + require.Len(t, ruleGroups, 2, "Should have 2 rule groups") + + rule1Key := models.AlertRuleKeyWithGroup{ + AlertRuleKey: models.AlertRuleKey{OrgID: 1, UID: "rule-1"}, + RuleGroup: "", + } + rule2Key := models.AlertRuleKeyWithGroup{ + AlertRuleKey: models.AlertRuleKey{OrgID: 1, UID: "rule-2"}, + RuleGroup: "", + } + + require.Len(t, ruleGroups[rule1Key], 2, "Rule 1 should have 2 instances") + require.Len(t, ruleGroups[rule2Key], 1, "Rule 2 should have 1 instance") + + // Test compression for each group + for ruleKey, ruleInstances := range ruleGroups { + compressedData, err := convertAndCompressAlertInstances(ruleInstances) + require.NoError(t, err, "Compression should succeed for rule %s", ruleKey.UID) + require.NotEmpty(t, compressedData, "Compressed data should not be empty for rule %s", ruleKey.UID) + + // Verify decompression works + decompressedInstances, err := decompressAlertInstances(compressedData) + require.NoError(t, err, "Decompression should succeed for rule %s", ruleKey.UID) + require.Len(t, decompressedInstances, len(ruleInstances), "Should have same number of instances after decompression for rule %s", ruleKey.UID) + } +} + func toProtoTimestampPtr(tm *time.Time) *timestamppb.Timestamp { if tm == nil { return nil From 8cb5f5646a112d0a8231877f3d8f88cc3a78861d Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 7 Nov 2025 13:27:25 +0200 Subject: [PATCH 087/209] Provisioning: Fix miscellaneous issues with setting and displaying sync status (#113529) * Provisioning: Preserve in progress job data * Refactor code and cover more situations * Fix linting * Fix issue with remove path operation for started time * Cleanup * prettier --------- Co-authored-by: Roberto Jimenez Sanchez --- .../provisioning/controller/repository.go | 57 ++++++++------ pkg/registry/apis/provisioning/jobs.go | 36 +++++---- .../apis/provisioning/jobs/sync/worker.go | 31 +++++--- .../provisioning/jobs/sync/worker_test.go | 66 +++++++++------- .../Repository/RepositoryPullStatusCard.tsx | 76 +++++++++++-------- 5 files changed, 157 insertions(+), 109 deletions(-) diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 681619711e1..b0ba13385b6 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -404,32 +404,47 @@ func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisionin return nil } -func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) *provisioning.SyncStatus { +func (rc *RepositoryController) determineSyncStatusOps(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) []map[string]interface{} { const unhealthyMessage = "Repository is unhealthy" hasUnhealthyMessage := len(obj.Status.Sync.Message) > 0 && obj.Status.Sync.Message[0] == unhealthyMessage + var patchOperations []map[string]interface{} + switch { case syncOptions != nil: - return &provisioning.SyncStatus{ - State: provisioning.JobStatePending, - LastRef: obj.Status.Sync.LastRef, - Started: time.Now().UnixMilli(), - } + // We will try to trigger a new sync job if we have sync options + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/sync/state", + "value": provisioning.JobStatePending, + }) + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/sync/started", + "value": int64(0), + }) case healthStatus.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it // FIXME: is this the clearest way to do this? Should we introduce another status or way of way of handling more // specific errors? - return &provisioning.SyncStatus{ - LastRef: obj.Status.Sync.LastRef, - } + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/sync/message", + "value": []string{}, + }) case !healthStatus.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it - return &provisioning.SyncStatus{ - State: provisioning.JobStateError, - Message: []string{unhealthyMessage}, - LastRef: obj.Status.Sync.LastRef, - } - default: - return nil + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/sync/state", + "value": provisioning.JobStateError, + }) + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/sync/message", + "value": []string{unhealthyMessage}, + }) } + + return patchOperations } //nolint:gocyclo @@ -509,13 +524,7 @@ func (rc *RepositoryController) process(item *queueItem) error { // determine the sync strategy and sync status to apply syncOptions := rc.determineSyncStrategy(ctx, obj, repo, shouldResync, healthStatus) - if syncStatus := rc.determineSyncStatus(obj, syncOptions, healthStatus); syncStatus != nil { - patchOperations = append(patchOperations, map[string]interface{}{ - "op": "replace", - "path": "/status/sync", - "value": syncStatus, - }) - } + patchOperations = append(patchOperations, rc.determineSyncStatusOps(obj, syncOptions, healthStatus)...) // Apply all patch operations if len(patchOperations) > 0 { @@ -525,6 +534,8 @@ func (rc *RepositoryController) process(item *queueItem) error { } } + // QUESTION: should we trigger the sync job after we have applied all patch operations or before? + // Is there are risk of race condition here? // Trigger sync job after we have applied all patch operations if syncOptions != nil { if err := rc.addSyncJob(ctx, obj, syncOptions); err != nil { diff --git a/pkg/registry/apis/provisioning/jobs.go b/pkg/registry/apis/provisioning/jobs.go index b1e174a04b7..94b705414f8 100644 --- a/pkg/registry/apis/provisioning/jobs.go +++ b/pkg/registry/apis/provisioning/jobs.go @@ -132,28 +132,36 @@ func (c *jobsConnector) Connect( } spec.Repository = name - // If a sync job is being created, we should update its status to pending. + job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec) + if err != nil { + responder.Error(err) + return + } + + // For pull jobs update the sync status + // patch the sync status 'state' to 'pending', and reset the 'started' field, leaving other fields unchanged. + // Intentionally maintain the previous job name until the jobs is picked up. if spec.Pull != nil { - err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg, map[string]interface{}{ - "op": "replace", - "path": "/status/sync", - "value": &provisioning.SyncStatus{ - State: provisioning.JobStatePending, - LastRef: cfg.Status.Sync.LastRef, - Started: time.Now().UnixMilli(), + err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg, + map[string]interface{}{ + "op": "replace", + "path": "/status/sync/state", + "value": provisioning.JobStatePending, }, - }) + map[string]interface{}{ + // Use "replace" instead of "remove" since "remove" fails if the path does not exist (RFC 6902). + // "started" field uses "omitempty", so it may be missing in the JSON. + "op": "replace", + "path": "/status/sync/started", + "value": int64(0), + }, + ) if err != nil { responder.Error(err) return } } - job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec) - if err != nil { - responder.Error(err) - return - } responder.Object(http.StatusAccepted, job) }), 30*time.Second), nil } diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker.go b/pkg/registry/apis/provisioning/jobs/sync/worker.go index dfb376ca470..05e6340b193 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/worker.go +++ b/pkg/registry/apis/provisioning/jobs/sync/worker.go @@ -110,25 +110,35 @@ func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, jo } syncStatus := job.Status.ToSyncStatus(job.Name) - // Preserve last ref as we use replace operation + // Preserve last ref lastRef := repo.Config().Status.Sync.LastRef syncStatus.LastRef = lastRef - if syncStatus.State == "" { - syncStatus.State = provisioning.JobStateWorking - } + // Ensure the sync state is set to 'working' if not already set or still pending. + // FIXME: This should not be needed as the progress recorder should have set it to 'working' by now. + syncStatus.State = provisioning.JobStateWorking - // Update sync status at start using JSON patch + // Update sync status at start using granular JSON patch operations + // Only patch fields that are actually being set to avoid overwriting with zero values patchOperations := []map[string]interface{}{ { "op": "replace", - "path": "/status/sync", - "value": syncStatus, + "path": "/status/sync/state", + "value": syncStatus.State, + }, + { + "op": "replace", + "path": "/status/sync/job", + "value": syncStatus.JobID, + }, + { + "op": "replace", + "path": "/status/sync/started", + "value": syncStatus.Started, }, } progress.SetMessage(ctx, "update sync status at start") - statusCtx, statusSpan := r.tracer.Start(ctx, "provisioning.sync.update_start_status") if err := r.patchStatus(statusCtx, cfg, patchOperations...); err != nil { statusSpan.End() @@ -174,14 +184,13 @@ func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, jo } syncSpan.End() - // Create sync status and set hash if successful - if syncStatus.State == provisioning.JobStateSuccess { + if syncStatus.State != provisioning.JobStateError { syncStatus.LastRef = currentRef } else { + // Preserve the original lastRef on error syncStatus.LastRef = lastRef } - // Update final status using JSON patch progress.SetMessage(ctx, "update status and stats") patchOperations = []map[string]interface{}{ { diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker_test.go b/pkg/registry/apis/provisioning/jobs/sync/worker_test.go index bf0b60bea01..a5013539957 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/worker_test.go @@ -115,17 +115,18 @@ func TestSyncWorker_Process(t *testing.T) { rw.MockRepository.On("Config").Return(repoConfig) pr.On("SetMessage", mock.Anything, "update sync status at start").Return() - rpf.On("Execute", mock.Anything, repoConfig, mock.MatchedBy(func(patch map[string]interface{}) bool { - if patch["op"] != "replace" || patch["path"] != "/status/sync" { - return false - } - - if patch["value"].(provisioning.SyncStatus).LastRef != "existing-ref" || patch["value"].(provisioning.SyncStatus).JobID != "test-job" { - return false - } - - return true - })).Return(errors.New("failed to patch status")) + // Expect granular patches for state, job, and started fields + rpf.On("Execute", mock.Anything, repoConfig, + mock.MatchedBy(func(patch map[string]interface{}) bool { + return patch["op"] == "replace" && patch["path"] == "/status/sync/state" + }), + mock.MatchedBy(func(patch map[string]interface{}) bool { + return patch["op"] == "replace" && patch["path"] == "/status/sync/job" + }), + mock.MatchedBy(func(patch map[string]interface{}) bool { + return patch["op"] == "replace" && patch["path"] == "/status/sync/started" + }), + ).Return(errors.New("failed to patch status")) }, expectedError: "update repo with job status at start: failed to patch status", }, @@ -151,9 +152,9 @@ func TestSyncWorker_Process(t *testing.T) { // Storage is migrated ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update succeeds + // Initial status update succeeds - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() - rpf.On("Execute", mock.Anything, repoConfig, mock.Anything).Return(nil).Once() + rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() // Repository resources creation fails rrf.On("Client", mock.Anything, mock.Anything).Return(nil, errors.New("failed to create repository resources client")) @@ -188,9 +189,9 @@ func TestSyncWorker_Process(t *testing.T) { // Storage is migrated ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update succeeds + // Initial status update succeeds - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() - rpf.On("Execute", mock.Anything, repoConfig, mock.Anything).Return(nil).Once() + rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() // Repository resources creation succeeds rrf.On("Client", mock.Anything, mock.Anything).Return(&resources.MockRepositoryResources{}, nil) @@ -224,9 +225,9 @@ func TestSyncWorker_Process(t *testing.T) { // Storage is migrated ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update + // Initial status update - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() - rpf.On("Execute", mock.Anything, repoConfig, mock.Anything).Return(nil) + rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() // Setup resources and clients mockRepoResources := resources.NewMockRepositoryResources(t) @@ -254,7 +255,7 @@ func TestSyncWorker_Process(t *testing.T) { } syncStatus := patch["value"].(provisioning.SyncStatus) return syncStatus.LastRef == "new-ref" && syncStatus.State == provisioning.JobStateSuccess - })).Return(nil) + })).Return(nil).Once() }, expectedError: "", }, @@ -277,9 +278,9 @@ func TestSyncWorker_Process(t *testing.T) { // Storage is migrated ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update + // Initial status update - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() - rpf.On("Execute", mock.Anything, repoConfig, mock.Anything).Return(nil) + rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() // Setup resources and clients mockRepoResources := resources.NewMockRepositoryResources(t) @@ -308,7 +309,7 @@ func TestSyncWorker_Process(t *testing.T) { patch["path"] == "/status/sync" && syncStatus.LastRef == "existing-ref" && // LastRef should not change on failure syncStatus.State == provisioning.JobStateError - })).Return(nil) + })).Return(nil).Once() }, expectedError: "sync operation failed", }, @@ -334,7 +335,9 @@ func TestSyncWorker_Process(t *testing.T) { pr.On("SetMessage", mock.Anything, mock.Anything).Return() pr.On("StrictMaxErrors", 20).Return() pr.On("Complete", mock.Anything, mock.Anything).Return(provisioning.JobStatus{State: provisioning.JobStateSuccess}) - rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything).Return(nil) + // Initial patch with granular updates, final patch with full sync status + rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() s.On("Sync", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("new-ref", nil) }, expectedError: "", @@ -355,10 +358,13 @@ func TestSyncWorker_Process(t *testing.T) { mockRepoResources.On("Stats", mock.Anything).Return(nil, nil) rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil) - // Verify only sync status is patched + // Initial patch with granular updates + rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + + // Verify only sync status is patched for final update rpf.On("Execute", mock.Anything, mock.Anything, mock.MatchedBy(func(patch map[string]interface{}) bool { return patch["path"] == "/status/sync" - })).Return(nil) + })).Return(nil).Once() // Simple mocks for other calls mockClients := resources.NewMockResourceClients(t) @@ -381,7 +387,8 @@ func TestSyncWorker_Process(t *testing.T) { } rw.MockRepository.On("Config").Return(repoConfig) ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + // Initial patch with granular updates + rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockRepoResources := resources.NewMockRepositoryResources(t) stats := &provisioning.ResourceStats{ @@ -468,10 +475,13 @@ func TestSyncWorker_Process(t *testing.T) { mockRepoResources.On("Stats", mock.Anything).Return(stats, nil) rrf.On("Client", mock.Anything, mock.Anything).Return(mockRepoResources, nil) + // Initial patch with granular updates + rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + // Verify only sync status is patched (multiple stats should be ignored) rpf.On("Execute", mock.Anything, mock.Anything, mock.MatchedBy(func(patch map[string]interface{}) bool { return patch["path"] == "/status/sync" - })).Return(nil) + })).Return(nil).Once() // Simple mocks for other calls mockClients := resources.NewMockResourceClients(t) @@ -495,8 +505,8 @@ func TestSyncWorker_Process(t *testing.T) { rw.MockRepository.On("Config").Return(repoConfig) ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status patch succeeds - rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + // Initial status patch succeeds - expect granular patches + rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() // Setup resources and clients mockRepoResources := resources.NewMockRepositoryResources(t) diff --git a/public/app/features/provisioning/Repository/RepositoryPullStatusCard.tsx b/public/app/features/provisioning/Repository/RepositoryPullStatusCard.tsx index 8758f8de911..7733d77db4f 100644 --- a/public/app/features/provisioning/Repository/RepositoryPullStatusCard.tsx +++ b/public/app/features/provisioning/Repository/RepositoryPullStatusCard.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { t, Trans } from '@grafana/i18n'; import { Badge, Card, Grid, Text, TextLink, useStyles2 } from '@grafana/ui'; @@ -17,6 +17,8 @@ export function RepositoryPullStatusCard({ repo }: { repo: Repository }) { const statusColor = getStatusColor(status?.sync.state); const statusIcon = getStatusIcon(status?.sync.state); + const isWorking = status?.sync.state === 'working' || status?.sync.state === 'pending'; + const { url: lastCommitUrl, hasUrl } = getRepoCommitUrl(repo.spec, status?.sync.lastRef); return ( @@ -42,45 +44,45 @@ export function RepositoryPullStatusCard({ repo }: { repo: Repository }) { {status?.sync.job ?? 'N/A'}
- {/* Last Ref */} - - Last Ref: - -
- {hasUrl && lastCommitUrl ? ( - +
+ {/* Last Ref */} + + Last Ref: + +
+ {hasUrl && lastCommitUrl ? ( + + {status?.sync.lastRef + ? status.sync.lastRef.substring(0, 7) + : t('provisioning.repository-overview.not-available', 'N/A')} + + ) : ( {status?.sync.lastRef ? status.sync.lastRef.substring(0, 7) : t('provisioning.repository-overview.not-available', 'N/A')} - - ) : ( - - {status?.sync.lastRef - ? status.sync.lastRef.substring(0, 7) - : t('provisioning.repository-overview.not-available', 'N/A')} - + )} +
+ + + Last successful pull: + +
+ {formatTimestamp(status?.sync.finished)} +
+ + {!!status?.sync?.message?.length && ( + <> + + Messages: + +
+ +
+ )}
- - - Last successful pull: - -
- {formatTimestamp(status?.sync.finished)} -
- - {!!status?.sync?.message?.length && ( - <> - - Messages: - -
- -
- - )} @@ -95,5 +97,13 @@ const getStyles = () => { spanTwo: css({ gridColumn: 'span 2', }), + historicalData: css({ + gridColumn: '1 / -1', + display: 'grid', + gridTemplateColumns: 'subgrid', + }), + historicalDataOverlay: css({ + opacity: 0.6, + }), }; }; From e90759e5af7daeb015bbec74ea940ca8fa87f65d Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Fri, 7 Nov 2025 13:50:40 +0100 Subject: [PATCH 088/209] `grafana-iam`: enable dual writing for resource permissions (#112793) * `grafana-iam`: enable dual writing for resource permissions Co-authored-by: jguer * copy paste mistake * Reduce complexity * nits to make the code easy to review * Forgot to check the error --------- Co-authored-by: jguer --- pkg/registry/apis/iam/register.go | 54 +++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index b679c64f918..e6b58f3c536 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -350,23 +350,59 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } //nolint:staticcheck // not yet migrated to OpenFeature if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { - resourcePermissionStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage) - if err != nil { + if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, b.enableDualWriter, enableZanzanaSync); err != nil { return err } - if enableZanzanaSync { - b.logger.Info("Enabling AfterCreate, BeginUpdate, and AfterDelete hooks for ResourcePermission to sync to Zanzana") - resourcePermissionStore.AfterCreate = b.AfterResourcePermissionCreate - resourcePermissionStore.BeginUpdate = b.BeginResourcePermissionUpdate - resourcePermissionStore.AfterDelete = b.AfterResourcePermissionDelete - } - storage[iamv0.ResourcePermissionInfo.StoragePath()] = resourcePermissionStore } apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage return nil } +func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableDualWriter bool, + enableZanzanaSync bool, +) error { + var store rest.Storage + // Create the legacy store first + legacyStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage) + if err != nil { + return err + } + + // Register the hooks for Zanzana sync + // FIXME: The hooks are registered on the legacy store + // Once we fully migrate to unified storage, we can move these hooks to the unified store + if enableZanzanaSync { + b.logger.Info("Enabling AfterCreate, BeginUpdate, and AfterDelete hooks for ResourcePermission to sync to Zanzana") + legacyStore.AfterCreate = b.AfterResourcePermissionCreate + legacyStore.BeginUpdate = b.BeginResourcePermissionUpdate + legacyStore.AfterDelete = b.AfterResourcePermissionDelete + } + + // Set the default store to the legacy store + store = legacyStore + + if enableDualWriter { + // Create the dual write store (UniStore + LegacyStore) + uniStore, err := grafanaregistry.NewRegistryStore(apiGroupInfo.Scheme, iamv0.ResourcePermissionInfo, opts.OptsGetter) + if err != nil { + return err + } + + store, err = opts.DualWriteBuilder(iamv0.ResourcePermissionInfo.GroupResource(), legacyStore, uniStore) + if err != nil { + return err + } + } + + storage[iamv0.ResourcePermissionInfo.StoragePath()] = store + return nil +} + func (b *IdentityAccessManagementAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { return func(rc common.ReferenceCallback) map[string]common.OpenAPIDefinition { dst := legacyiamv0.GetOpenAPIDefinitions(rc) From 33390a14830fe9cab7424d1b8856455a71f522c9 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Fri, 7 Nov 2025 10:16:41 -0300 Subject: [PATCH 089/209] LibraryPanels: Improve `getAllLibraryElements` filter performance (#113544) --- pkg/services/libraryelements/database.go | 39 +++-- .../libraryelements_get_all_test.go | 157 ++++++++++++++++++ 2 files changed, 181 insertions(+), 15 deletions(-) diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 3a47b3095ce..a5b43fd54cb 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -473,17 +473,22 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI if err != nil { return err } + // Every signed in user can see the general folder. The general folder might have "general" or the empty string as its UID. - var folderUIDS = []string{"general", ""} - folderMap := map[string]string{} + // Using a map for O(1) lookup instead of O(n) slice iteration + folderUIDSet := make(map[string]bool, len(fs)+2) + folderUIDSet["general"] = true + folderUIDSet[""] = true + + folderMap := make(map[string]string, len(fs)) for _, f := range fs { - folderUIDS = append(folderUIDS, f.UID) + folderUIDSet[f.UID] = true folderMap[f.UID] = f.Title } // if the user is not an admin, we need to filter out elements that are not in folders the user can see for _, element := range elements { if !signedInUser.HasRole(org.RoleAdmin) { - if !contains(folderUIDS, element.FolderUID) { + if !folderUIDSet[element.FolderUID] { continue } } @@ -522,10 +527,11 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI }) } - var libraryElements []model.LibraryElement + var libraryElements []model.LibraryElementWithMeta countBuilder := db.SQLBuilder{} if folderFilter.includeGeneralFolder { countBuilder.Write(selectLibraryElementDTOWithMeta) + countBuilder.Write(", '' as folder_uid ") countBuilder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect())) countBuilder.Write(` WHERE le.org_id=? AND le.folder_id=0`, signedInUser.GetOrgID()) writeKindSQL(query, &countBuilder) @@ -537,6 +543,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI countBuilder.Write(" ") } countBuilder.Write(selectLibraryElementDTOWithMeta) + countBuilder.Write(", le.folder_uid as folder_uid ") countBuilder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect())) countBuilder.Write(` WHERE le.org_id=? AND le.folder_id<>0`, signedInUser.GetOrgID()) writeKindSQL(query, &countBuilder) @@ -550,8 +557,19 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI return err } + // Apply the same folder permission filtering to the count for non-admin users + totalCount := int64(len(libraryElements)) + if !signedInUser.HasRole(org.RoleAdmin) { + totalCount = 0 + for _, element := range libraryElements { + if folderUIDSet[element.FolderUID] { + totalCount++ + } + } + } + result = model.LibraryElementSearchResult{ - TotalCount: int64(len(libraryElements)), + TotalCount: totalCount, Elements: retDTOs, Page: query.Page, PerPage: query.PerPage, @@ -878,15 +896,6 @@ func (l *LibraryElementService) deleteLibraryElementsInFolderUID(c context.Conte }) } -func contains(slice []string, element string) bool { - for _, item := range slice { - if item == element { - return true - } - } - return false -} - func getFoldersWithMatchingTitles(c context.Context, l *LibraryElementService, signedInUser identity.Requester, query model.SearchLibraryElementsQuery) ([]string, error) { if len(strings.TrimSpace(query.SearchString)) <= 0 { return nil, nil diff --git a/pkg/services/libraryelements/libraryelements_get_all_test.go b/pkg/services/libraryelements/libraryelements_get_all_test.go index 612f5a9c279..7ff376cdecf 100644 --- a/pkg/services/libraryelements/libraryelements_get_all_test.go +++ b/pkg/services/libraryelements/libraryelements_get_all_test.go @@ -1370,4 +1370,161 @@ func TestIntegration_GetAllLibraryElements(t *testing.T) { require.NotEmpty(t, element.UID, "Should have a UID") require.Equal(t, int64(0), element.Meta.ConnectedDashboards, "Should have no connected dashboards") }) + + // Non-admin user permission tests + scenarioWithPanel(t, "When a non-admin user has folders but none of the library elements are in those folders, it should return empty result", + func(t *testing.T, sc scenarioContext) { + // Create library panels in the scenario folder + // nolint:staticcheck + command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Text - Library Panel2") + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.createHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + // Create a different folder that the non-admin user has access to (but has no panels) + differentFolder := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_DifferentFolder", + Title: "DifferentFolder", + } + + // Change user to non-admin and set their accessible folders to only the different folder + // This simulates a user who can see a folder but that folder doesn't contain any of the library elements + sc.reqContext.OrgRole = org.RoleViewer + sc.folderSvc.ExpectedFolders = []*folder.Folder{differentFolder} + sc.folderSvc.AddFolder(differentFolder) + + resp = sc.service.getAllHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + var result libraryElementsSearch + err := json.Unmarshal(resp.Body(), &result) + require.NoError(t, err) + + // TotalCount should be 0 for non-admin users since they can't access the folders with panels + require.Equal(t, int64(0), result.Result.TotalCount, "TotalCount should be 0 since user has no access to folders with panels") + require.Equal(t, 0, len(result.Result.Elements), "Elements should be empty since user has no access to folders with panels") + require.Equal(t, 1, result.Result.Page, "Should be on page 1") + require.Equal(t, 100, result.Result.PerPage, "Should have perPage 100") + }) + + scenarioWithPanel(t, "When a non-admin user has folders and some library elements are in those folders, it should return only accessible elements", + func(t *testing.T, sc scenarioContext) { + // Create a second folder that the non-admin user will have access to + accessibleFolder := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_AccessibleFolder", + Title: "AccessibleFolder", + } + + // Create a library panel in the accessible folder (need to add it to fake service first) + sc.folderSvc.ExpectedFolder = accessibleFolder + sc.folderSvc.AddFolder(accessibleFolder) + // nolint:staticcheck + command := getCreatePanelCommand(accessibleFolder.ID, accessibleFolder.UID, "Accessible Panel") + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.createHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + // Create another panel in a folder the user won't have access to + inaccessibleFolder := &folder.Folder{ + ID: 3, + OrgID: 1, + UID: "uid_for_InaccessibleFolder", + Title: "InaccessibleFolder", + } + sc.folderSvc.ExpectedFolder = inaccessibleFolder + sc.folderSvc.AddFolder(inaccessibleFolder) + // nolint:staticcheck + command = getCreatePanelCommand(inaccessibleFolder.ID, inaccessibleFolder.UID, "Inaccessible Panel") + sc.reqContext.Req.Body = mockRequestBody(command) + resp = sc.service.createHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + // Change user to non-admin and set their accessible folders to only the accessible folder and scenario folder + // This will filter out the inaccessible folder + sc.reqContext.OrgRole = org.RoleViewer + sc.folderSvc.ExpectedFolders = []*folder.Folder{sc.folder, accessibleFolder} + + resp = sc.service.getAllHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + var result libraryElementsSearch + err := json.Unmarshal(resp.Body(), &result) + require.NoError(t, err) + + // TotalCount should match the number of accessible elements (2) for non-admin users + require.Equal(t, int64(2), result.Result.TotalCount, "TotalCount should be 2 (only accessible panels)") + require.Equal(t, 2, len(result.Result.Elements), "Elements should contain only 2 accessible panels") + require.Equal(t, 1, result.Result.Page, "Should be on page 1") + require.Equal(t, 100, result.Result.PerPage, "Should have perPage 100") + + // Verify the returned panels are from accessible folders only + folderUIDs := make(map[string]bool) + for _, element := range result.Result.Elements { + folderUIDs[element.FolderUID] = true + require.Contains(t, []string{sc.folder.UID, accessibleFolder.UID}, element.FolderUID, "Element should be in accessible folder") + require.NotEqual(t, inaccessibleFolder.UID, element.FolderUID, "Element should not be from inaccessible folder") + } + require.True(t, folderUIDs[sc.folder.UID], "Should include panel from scenario folder") + require.True(t, folderUIDs[accessibleFolder.UID], "Should include panel from accessible folder") + }) + + scenarioWithPanel(t, "When a non-admin user has access to all folders containing library elements, it should return all elements", + func(t *testing.T, sc scenarioContext) { + // Create a second folder that the non-admin user will have access to + folder2 := &folder.Folder{ + ID: 2, + OrgID: 1, + UID: "uid_for_Folder2", + Title: "Folder2", + } + sc.folderSvc.ExpectedFolder = folder2 + sc.folderSvc.AddFolder(folder2) + + // Create a library panel in folder2 + // nolint:staticcheck + command := getCreatePanelCommand(folder2.ID, folder2.UID, "Panel in Folder2") + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.createHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + // Create another panel in the original scenario folder + sc.folderSvc.ExpectedFolder = sc.folder + // nolint:staticcheck + command = getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Panel in ScenarioFolder") + sc.reqContext.Req.Body = mockRequestBody(command) + resp = sc.service.createHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + // Change user to non-admin and set their accessible folders to include all folders with panels + sc.reqContext.OrgRole = org.RoleViewer + sc.folderSvc.ExpectedFolders = []*folder.Folder{sc.folder, folder2} + + resp = sc.service.getAllHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + var result libraryElementsSearch + err := json.Unmarshal(resp.Body(), &result) + require.NoError(t, err) + + // Should return all 3 panels (1 from initial setup + 2 created in this test) + require.Equal(t, int64(3), result.Result.TotalCount, "Should return all 3 panels") + require.Equal(t, 3, len(result.Result.Elements), "Should have 3 elements") + require.Equal(t, 1, result.Result.Page, "Should be on page 1") + require.Equal(t, 100, result.Result.PerPage, "Should have perPage 100") + + // Verify all panels are from the accessible folders + folderUIDs := make(map[string]int) + for _, element := range result.Result.Elements { + folderUIDs[element.FolderUID]++ + require.Contains(t, []string{sc.folder.UID, folder2.UID}, element.FolderUID, "All elements should be in accessible folders") + require.Equal(t, int64(model.PanelElement), element.Kind, "Should be a panel element") + require.Equal(t, "text", element.Type, "Should be text panel") + } + require.Equal(t, 2, folderUIDs[sc.folder.UID], "Should have 2 panels in scenario folder") + require.Equal(t, 1, folderUIDs[folder2.UID], "Should have 1 panel in folder2") + }) } From 176b0f8b48af78167e803e52a0669c30a4ec568c Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 7 Nov 2025 14:36:53 +0100 Subject: [PATCH 090/209] IAM: Refactor user org hooks to use MutateRequest API (#113392) * update with mutation hooks * add missing delete mutation --- .../iam/resource_permission_hooks_test.go | 13 +- pkg/registry/apis/iam/user_org_hooks.go | 201 +++++---------- pkg/registry/apis/iam/user_org_hooks_test.go | 232 ++++++++---------- 3 files changed, 175 insertions(+), 271 deletions(-) diff --git a/pkg/registry/apis/iam/resource_permission_hooks_test.go b/pkg/registry/apis/iam/resource_permission_hooks_test.go index ff696020fc1..ea79b11545b 100644 --- a/pkg/registry/apis/iam/resource_permission_hooks_test.go +++ b/pkg/registry/apis/iam/resource_permission_hooks_test.go @@ -16,8 +16,9 @@ import ( type FakeZanzanaClient struct { zanzana.Client - writeCallback func(context.Context, *v1.WriteRequest) error - readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error) + writeCallback func(context.Context, *v1.WriteRequest) error + readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error) + mutateCallback func(context.Context, *v1.MutateRequest) error } // Read implements zanzana.Client. @@ -33,6 +34,14 @@ func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) err return f.writeCallback(ctx, req) } +// Mutate implements zanzana.Client. +func (f *FakeZanzanaClient) Mutate(ctx context.Context, req *v1.MutateRequest) error { + if f.mutateCallback != nil { + return f.mutateCallback(ctx, req) + } + return nil +} + func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) { t.Helper() for _, exp := range expected { diff --git a/pkg/registry/apis/iam/user_org_hooks.go b/pkg/registry/apis/iam/user_org_hooks.go index 380d98ceac2..8f75ed5c970 100644 --- a/pkg/registry/apis/iam/user_org_hooks.go +++ b/pkg/registry/apis/iam/user_org_hooks.go @@ -10,27 +10,8 @@ import ( iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/grafana/grafana/pkg/services/authz/zanzana" ) -// createUserBasicRoleTuple creates a tuple for a user's basic role assignment -func createUserBasicRoleTuple(userUID, orgRole string) *v1.TupleKey { - if orgRole == "" { - return nil - } - - basicRole := zanzana.TranslateBasicRole(orgRole) - if basicRole == "" { - return nil - } - - return &v1.TupleKey{ - User: zanzana.NewTupleEntry(zanzana.TypeUser, userUID, ""), - Relation: zanzana.RelationAssignee, - Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), - } -} - // AfterUserCreate is a post-create hook that writes the user's basic role assignment to Zanzana (openFGA) func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, _ *metav1.CreateOptions) { if b.zClient == nil { @@ -43,24 +24,24 @@ func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, return } - resourceType := "user" - operation := "create" - // Skip if user has no role assigned if user.Spec.Role == "" { b.logger.Debug("user has no role assigned, skipping basic role sync", "namespace", user.Namespace, - "userUID", user.Name, + "name", user.Name, ) return } + resourceType := "user" + operation := "create" + // Grab a ticket to write to Zanzana wait := time.Now() b.zTickets <- true hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) - go func(u *iamv0.User) { + go func(namespace, subjectName, role, resourceType, operation string) { start := time.Now() status := "success" @@ -70,44 +51,38 @@ func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() - tuple := createUserBasicRoleTuple(u.Name, u.Spec.Role) - if tuple == nil { - b.logger.Warn("failed to create user basic role tuple", - "namespace", u.Namespace, - "userUID", u.Name, - "role", u.Spec.Role, - ) - status = "failure" - return - } - b.logger.Debug("writing user basic role to zanzana", - "namespace", u.Namespace, - "userUID", u.Name, - "role", u.Spec.Role, + "namespace", namespace, + "name", subjectName, + "role", role, ) ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err := b.zClient.Write(ctx, &v1.WriteRequest{ - Namespace: u.Namespace, - Writes: &v1.WriteRequestWrites{ - TupleKeys: []*v1.TupleKey{tuple}, + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: namespace, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_UpdateUserOrgRole{ + UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{User: subjectName, Role: role}, + }, + }, }, }) + if err != nil { status = "failure" b.logger.Error("failed to write user basic role to zanzana", "err", err, - "namespace", u.Namespace, - "userUID", u.Name, - "role", u.Spec.Role, + "namespace", namespace, + "name", subjectName, + "role", role, ) } else { hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc() } - }(user.DeepCopy()) + }(user.Namespace, user.Name, user.Spec.Role, resourceType, operation) } // BeginUserUpdate is a pre-update hook that gets called on user updates @@ -142,7 +117,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginUserUpdate(ctx context.Context b.zTickets <- true hooksWaitHistogram.WithLabelValues("user", "update").Observe(time.Since(wait).Seconds()) - go func(old, new *iamv0.User) { + go func(namespace, subjectName, oldRole, newRole string) { start := time.Now() status := "success" @@ -153,72 +128,40 @@ func (b *IdentityAccessManagementAPIBuilder) BeginUserUpdate(ctx context.Context }() b.logger.Debug("updating user basic role in zanzana", - "namespace", new.Namespace, - "userUID", new.Name, - "oldRole", old.Spec.Role, - "newRole", new.Spec.Role, + "namespace", namespace, + "name", subjectName, + "oldRole", oldRole, + "newRole", newRole, ) ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - req := &v1.WriteRequest{ - Namespace: new.Namespace, + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: namespace, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_UpdateUserOrgRole{ + UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{User: subjectName, Role: newRole}, + }, + }, { + Operation: &v1.MutateOperation_DeleteUserOrgRole{ + DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{User: subjectName, Role: oldRole}, + }, + }, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to update user basic role in zanzana", + "err", err, + "namespace", namespace, + "name", subjectName, + "role", newRole, + "oldRole", oldRole, + ) } - - // Delete old role tuple if it existed - if old.Spec.Role != "" { - oldTuple := createUserBasicRoleTuple(old.Name, old.Spec.Role) - if oldTuple != nil { - deleteTuple := tupleToTupleKeyWithoutCondition(oldTuple) - req.Deletes = &v1.WriteRequestDeletes{ - TupleKeys: []*v1.TupleKeyWithoutCondition{deleteTuple}, - } - b.logger.Debug("deleting old user basic role from zanzana", - "namespace", new.Namespace, - "userUID", new.Name, - "role", old.Spec.Role, - ) - } - } - - // Write new role tuple if it exists - if new.Spec.Role != "" { - newTuple := createUserBasicRoleTuple(new.Name, new.Spec.Role) - if newTuple != nil { - req.Writes = &v1.WriteRequestWrites{ - TupleKeys: []*v1.TupleKey{newTuple}, - } - b.logger.Debug("writing new user basic role to zanzana", - "namespace", new.Namespace, - "userUID", new.Name, - "role", new.Spec.Role, - ) - } - } - - // Only make the request if there are deletes or writes - if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { - err := b.zClient.Write(ctx, req) - if err != nil { - status = "failure" - b.logger.Error("failed to update user basic role in zanzana", - "err", err, - "namespace", new.Namespace, - "userUID", new.Name, - ) - } else { - if req.Deletes != nil && len(req.Deletes.TupleKeys) > 0 { - hooksTuplesCounter.WithLabelValues("user", "update", "delete").Inc() - } - if req.Writes != nil && len(req.Writes.TupleKeys) > 0 { - hooksTuplesCounter.WithLabelValues("user", "update", "write").Inc() - } - } - } else { - b.logger.Debug("no tuples to update in zanzana", "namespace", new.Namespace) - } - }(oldUser.DeepCopy(), newUser.DeepCopy()) + }(oldUser.Namespace, oldUser.Name, oldUser.Spec.Role, newUser.Spec.Role) }, nil } @@ -241,7 +184,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, if user.Spec.Role == "" { b.logger.Debug("user had no role assigned, skipping basic role sync", "namespace", user.Namespace, - "userUID", user.Name, + "name", user.Name, ) return } @@ -250,7 +193,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, b.zTickets <- true hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) - go func(u *iamv0.User) { + go func(namespace, subjectName, role string) { start := time.Now() status := "success" @@ -260,44 +203,36 @@ func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() - tuple := createUserBasicRoleTuple(u.Name, u.Spec.Role) - if tuple == nil { - b.logger.Warn("failed to create user basic role tuple for deletion", - "namespace", u.Namespace, - "userUID", u.Name, - "role", u.Spec.Role, - ) - status = "failure" - return - } - - deleteTuple := tupleToTupleKeyWithoutCondition(tuple) - b.logger.Debug("deleting user basic role from zanzana", - "namespace", u.Namespace, - "userUID", u.Name, - "role", u.Spec.Role, + "namespace", namespace, + "name", subjectName, + "role", role, ) ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err := b.zClient.Write(ctx, &v1.WriteRequest{ - Namespace: u.Namespace, - Deletes: &v1.WriteRequestDeletes{ - TupleKeys: []*v1.TupleKeyWithoutCondition{deleteTuple}, + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: namespace, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_DeleteUserOrgRole{ + DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{User: subjectName, Role: role}, + }, + }, }, }) + if err != nil { status = "failure" b.logger.Error("failed to delete user basic role from zanzana", "err", err, - "namespace", u.Namespace, - "userUID", u.Name, - "role", u.Spec.Role, + "namespace", namespace, + "name", subjectName, + "role", role, ) } else { hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc() } - }(user.DeepCopy()) + }(user.Namespace, user.Name, user.Spec.Role) } diff --git a/pkg/registry/apis/iam/user_org_hooks_test.go b/pkg/registry/apis/iam/user_org_hooks_test.go index 516b66e380f..021b067c33c 100644 --- a/pkg/registry/apis/iam/user_org_hooks_test.go +++ b/pkg/registry/apis/iam/user_org_hooks_test.go @@ -33,21 +33,22 @@ func TestAfterUserCreate(t *testing.T) { }, } - testAdminRole := func(ctx context.Context, req *v1.WriteRequest) error { + testAdminRole := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) require.Equal(t, "org-1", req.Namespace) + require.Len(t, req.Operations, 1) - tuple := req.Writes.TupleKeys[0] - require.Equal(t, "user:df2p421det1q8c", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_admin", tuple.Object) + op := req.Operations[0] + require.NotNil(t, op) + updateOp := op.GetUpdateUserOrgRole() + require.NotNil(t, updateOp) + require.Equal(t, "df2p421det1q8c", updateOp.User) + require.Equal(t, "Admin", updateOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAdminRole} + b.zClient = &FakeZanzanaClient{mutateCallback: testAdminRole} b.AfterUserCreate(&user, nil) wg.Wait() }) @@ -64,21 +65,22 @@ func TestAfterUserCreate(t *testing.T) { }, } - testEditorRole := func(ctx context.Context, req *v1.WriteRequest) error { + testEditorRole := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) require.Equal(t, "org-2", req.Namespace) + require.Len(t, req.Operations, 1) - tuple := req.Writes.TupleKeys[0] - require.Equal(t, "user:user123", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_editor", tuple.Object) + op := req.Operations[0] + require.NotNil(t, op) + updateOp := op.GetUpdateUserOrgRole() + require.NotNil(t, updateOp) + require.Equal(t, "user123", updateOp.User) + require.Equal(t, "Editor", updateOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testEditorRole} + b.zClient = &FakeZanzanaClient{mutateCallback: testEditorRole} b.AfterUserCreate(&user, nil) wg.Wait() }) @@ -95,21 +97,22 @@ func TestAfterUserCreate(t *testing.T) { }, } - testViewerRole := func(ctx context.Context, req *v1.WriteRequest) error { + testViewerRole := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) require.Equal(t, "org-3", req.Namespace) + require.Len(t, req.Operations, 1) - tuple := req.Writes.TupleKeys[0] - require.Equal(t, "user:viewer456", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_viewer", tuple.Object) + op := req.Operations[0] + require.NotNil(t, op) + updateOp := op.GetUpdateUserOrgRole() + require.NotNil(t, updateOp) + require.Equal(t, "viewer456", updateOp.User) + require.Equal(t, "Viewer", updateOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testViewerRole} + b.zClient = &FakeZanzanaClient{mutateCallback: testViewerRole} b.AfterUserCreate(&user, nil) wg.Wait() }) @@ -184,31 +187,28 @@ func TestBeginUserUpdate(t *testing.T) { }, } - testRoleChange := func(ctx context.Context, req *v1.WriteRequest) error { + testRoleChange := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-1", req.Namespace) + require.Len(t, req.Operations, 2) - // Should delete old role - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - deleteTuple := req.Deletes.TupleKeys[0] - require.Equal(t, "user:testuser", deleteTuple.User) - require.Equal(t, "assignee", deleteTuple.Relation) - require.Equal(t, "role:basic_viewer", deleteTuple.Object) + // First operation should be UpdateUserOrgRole with new role + updateOp := req.Operations[0].GetUpdateUserOrgRole() + require.NotNil(t, updateOp) + require.Equal(t, "testuser", updateOp.User) + require.Equal(t, "Admin", updateOp.Role) - // Should write new role - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - writeTuple := req.Writes.TupleKeys[0] - require.Equal(t, "user:testuser", writeTuple.User) - require.Equal(t, "assignee", writeTuple.Relation) - require.Equal(t, "role:basic_admin", writeTuple.Object) + // Second operation should be DeleteUserOrgRole with old role + deleteOp := req.Operations[1].GetDeleteUserOrgRole() + require.NotNil(t, deleteOp) + require.Equal(t, "testuser", deleteOp.User) + require.Equal(t, "Viewer", deleteOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testRoleChange} + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleChange} finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) require.NoError(t, err) @@ -218,7 +218,7 @@ func TestBeginUserUpdate(t *testing.T) { wg.Wait() }) - t.Run("should delete old role when new role is empty", func(t *testing.T) { + t.Run("should update role when new role is empty", func(t *testing.T) { wg.Add(1) oldUser := iamv0.User{ ObjectMeta: metav1.ObjectMeta{ @@ -240,26 +240,28 @@ func TestBeginUserUpdate(t *testing.T) { }, } - testRemoveRole := func(ctx context.Context, req *v1.WriteRequest) error { + testRemoveRole := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) + require.Len(t, req.Operations, 2) - // Should delete old role - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - deleteTuple := req.Deletes.TupleKeys[0] - require.Equal(t, "user:testuser2", deleteTuple.User) - require.Equal(t, "assignee", deleteTuple.Relation) - require.Equal(t, "role:basic_editor", deleteTuple.Object) + // First operation should be UpdateUserOrgRole with empty role + updateOp := req.Operations[0].GetUpdateUserOrgRole() + require.NotNil(t, updateOp) + require.Equal(t, "testuser2", updateOp.User) + require.Equal(t, "", updateOp.Role) - // Should not write new role - require.Nil(t, req.Writes) + // Second operation should be DeleteUserOrgRole with old role + deleteOp := req.Operations[1].GetDeleteUserOrgRole() + require.NotNil(t, deleteOp) + require.Equal(t, "testuser2", deleteOp.User) + require.Equal(t, "Editor", deleteOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testRemoveRole} + b.zClient = &FakeZanzanaClient{mutateCallback: testRemoveRole} finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) require.NoError(t, err) @@ -291,26 +293,28 @@ func TestBeginUserUpdate(t *testing.T) { }, } - testAddRole := func(ctx context.Context, req *v1.WriteRequest) error { + testAddRole := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-3", req.Namespace) + require.Len(t, req.Operations, 2) - // Should not delete old role (was empty) - require.Nil(t, req.Deletes) + // First operation should be UpdateUserOrgRole with new role + updateOp := req.Operations[0].GetUpdateUserOrgRole() + require.NotNil(t, updateOp) + require.Equal(t, "testuser3", updateOp.User) + require.Equal(t, "Admin", updateOp.Role) - // Should write new role - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - writeTuple := req.Writes.TupleKeys[0] - require.Equal(t, "user:testuser3", writeTuple.User) - require.Equal(t, "assignee", writeTuple.Relation) - require.Equal(t, "role:basic_admin", writeTuple.Object) + // Second operation should be DeleteUserOrgRole with empty old role + deleteOp := req.Operations[1].GetDeleteUserOrgRole() + require.NotNil(t, deleteOp) + require.Equal(t, "testuser3", deleteOp.User) + require.Equal(t, "", deleteOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAddRole} + b.zClient = &FakeZanzanaClient{mutateCallback: testAddRole} finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) require.NoError(t, err) @@ -368,12 +372,12 @@ func TestBeginUserUpdate(t *testing.T) { } callCount := 0 - testNoCall := func(ctx context.Context, req *v1.WriteRequest) error { + testNoCall := func(ctx context.Context, req *v1.MutateRequest) error { callCount++ return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoCall} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoCall} finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) require.NoError(t, err) @@ -437,25 +441,23 @@ func TestAfterUserDelete(t *testing.T) { }, } - testDeleteAdmin := func(ctx context.Context, req *v1.WriteRequest) error { + testDeleteAdmin := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-1", req.Namespace) + require.Len(t, req.Operations, 1) - // Should have deletes but no writes - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Nil(t, req.Writes) - - deleteTuple := req.Deletes.TupleKeys[0] - require.Equal(t, "user:df2p421det1q8c", deleteTuple.User) - require.Equal(t, "assignee", deleteTuple.Relation) - require.Equal(t, "role:basic_admin", deleteTuple.Object) + op := req.Operations[0] + require.NotNil(t, op) + deleteOp := op.GetDeleteUserOrgRole() + require.NotNil(t, deleteOp) + require.Equal(t, "df2p421det1q8c", deleteOp.User) + require.Equal(t, "Admin", deleteOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDeleteAdmin} + b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteAdmin} b.AfterUserDelete(&user, nil) wg.Wait() }) @@ -472,22 +474,23 @@ func TestAfterUserDelete(t *testing.T) { }, } - testDeleteEditor := func(ctx context.Context, req *v1.WriteRequest) error { + testDeleteEditor := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) + require.Len(t, req.Operations, 1) - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - deleteTuple := req.Deletes.TupleKeys[0] - require.Equal(t, "user:editor123", deleteTuple.User) - require.Equal(t, "assignee", deleteTuple.Relation) - require.Equal(t, "role:basic_editor", deleteTuple.Object) + op := req.Operations[0] + require.NotNil(t, op) + deleteOp := op.GetDeleteUserOrgRole() + require.NotNil(t, deleteOp) + require.Equal(t, "editor123", deleteOp.User) + require.Equal(t, "Editor", deleteOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDeleteEditor} + b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteEditor} b.AfterUserDelete(&user, nil) wg.Wait() }) @@ -504,22 +507,23 @@ func TestAfterUserDelete(t *testing.T) { }, } - testDeleteViewer := func(ctx context.Context, req *v1.WriteRequest) error { + testDeleteViewer := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-3", req.Namespace) + require.Len(t, req.Operations, 1) - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - deleteTuple := req.Deletes.TupleKeys[0] - require.Equal(t, "user:viewer456", deleteTuple.User) - require.Equal(t, "assignee", deleteTuple.Relation) - require.Equal(t, "role:basic_viewer", deleteTuple.Object) + op := req.Operations[0] + require.NotNil(t, op) + deleteOp := op.GetDeleteUserOrgRole() + require.NotNil(t, deleteOp) + require.Equal(t, "viewer456", deleteOp.User) + require.Equal(t, "Viewer", deleteOp.Role) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDeleteViewer} + b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteViewer} b.AfterUserDelete(&user, nil) wg.Wait() }) @@ -563,47 +567,3 @@ func TestAfterUserDelete(t *testing.T) { // If we get here without panic, the test passes }) } - -func TestCreateUserBasicRoleTuple(t *testing.T) { - t.Run("should create tuple for Admin role", func(t *testing.T) { - tuple := createUserBasicRoleTuple("user123", "Admin") - require.NotNil(t, tuple) - require.Equal(t, "user:user123", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_admin", tuple.Object) - }) - - t.Run("should create tuple for Editor role", func(t *testing.T) { - tuple := createUserBasicRoleTuple("user456", "Editor") - require.NotNil(t, tuple) - require.Equal(t, "user:user456", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_editor", tuple.Object) - }) - - t.Run("should create tuple for Viewer role", func(t *testing.T) { - tuple := createUserBasicRoleTuple("user789", "Viewer") - require.NotNil(t, tuple) - require.Equal(t, "user:user789", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_viewer", tuple.Object) - }) - - t.Run("should create tuple for None role", func(t *testing.T) { - tuple := createUserBasicRoleTuple("user000", "None") - require.NotNil(t, tuple) - require.Equal(t, "user:user000", tuple.User) - require.Equal(t, "assignee", tuple.Relation) - require.Equal(t, "role:basic_none", tuple.Object) - }) - - t.Run("should return nil for empty role", func(t *testing.T) { - tuple := createUserBasicRoleTuple("user123", "") - require.Nil(t, tuple) - }) - - t.Run("should return nil for invalid role", func(t *testing.T) { - tuple := createUserBasicRoleTuple("user123", "InvalidRole") - require.Nil(t, tuple) - }) -} From e5ed003fb219d4b8ffa2c2a44abda4e6ff54d91a Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:38:59 +0100 Subject: [PATCH 091/209] Dashboard Library: Add new "suggestedDashboards" feature toggle (#113591) --- .../src/types/featureToggles.gen.ts | 6 +++++- pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 6 +++++- pkg/services/featuremgmt/toggles_gen.json | 18 +++++++++++++++--- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5c44991aba8..8ac393b412c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -565,10 +565,14 @@ export interface FeatureToggles { */ queryLibrary?: boolean; /** - * Enable suggested dashboards when creating new dashboards + * Enable dashboard library experiments that are production ready */ dashboardLibrary?: boolean; /** + * Enable suggested dashboards when creating new dashboards + */ + suggestedDashboards?: boolean; + /** * Sets the logs table as default visualisation in logs explore */ logsExploreTableDefaultVisualization?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 60d477ad62c..008ed41d566 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -971,6 +971,13 @@ var ( }, { Name: "dashboardLibrary", + Description: "Enable dashboard library experiments that are production ready", + Stage: FeatureStageExperimental, + Owner: grafanaSharingSquad, + FrontendOnly: false, + }, + { + Name: "suggestedDashboards", Description: "Enable suggested dashboards when creating new dashboards", Stage: FeatureStageExperimental, Owner: grafanaSharingSquad, diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 98099178166..d2108887cb9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -127,6 +127,7 @@ disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,fal grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false queryLibrary,preview,@grafana/sharing-squad,false,false,false dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false +suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 60ad9a69431..f4701c6fb19 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -516,9 +516,13 @@ const ( FlagQueryLibrary = "queryLibrary" // FlagDashboardLibrary - // Enable suggested dashboards when creating new dashboards + // Enable dashboard library experiments that are production ready FlagDashboardLibrary = "dashboardLibrary" + // FlagSuggestedDashboards + // Enable suggested dashboards when creating new dashboards + FlagSuggestedDashboards = "suggestedDashboards" + // FlagLogsExploreTableDefaultVisualization // Sets the logs table as default visualisation in logs explore FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index c656f948c18..e8c5715fe73 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1082,14 +1082,14 @@ { "metadata": { "name": "dashboardLibrary", - "resourceVersion": "1760051989635", + "resourceVersion": "1762521182817", "creationTimestamp": "2025-09-26T16:02:12Z", "annotations": { - "grafana.app/updatedTimestamp": "2025-10-09 23:19:49.635811 +0000 UTC" + "grafana.app/updatedTimestamp": "2025-11-07 13:13:02.817210943 +0000 UTC" } }, "spec": { - "description": "Enable suggested dashboards when creating new dashboards", + "description": "Enable dashboard library experiments that are production ready", "stage": "experimental", "codeowner": "@grafana/sharing-squad" } @@ -3779,6 +3779,18 @@ "codeowner": "@grafana/search-and-storage" } }, + { + "metadata": { + "name": "suggestedDashboards", + "resourceVersion": "1762521182817", + "creationTimestamp": "2025-11-07T13:13:02Z" + }, + "spec": { + "description": "Enable suggested dashboards when creating new dashboards", + "stage": "experimental", + "codeowner": "@grafana/sharing-squad" + } + }, { "metadata": { "name": "tableNextGen", From 1ca95cda4a6dd04007e51ddd50be24686e38d777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Fri, 7 Nov 2025 15:19:55 +0100 Subject: [PATCH 092/209] fix(folders): prevent circular dependencies (#113595) --- pkg/registry/apis/folders/validate.go | 8 +++ pkg/registry/apis/folders/validate_test.go | 65 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index db8c3b7e0e8..4f8ccd2250d 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -120,6 +120,14 @@ func validateOnUpdate(ctx context.Context, return err } + // Check that the folder being moved is not an ancestor of the target parent. + // This prevents circular references (e.g., moving A under B when B is already under A). + for _, ancestor := range info.Items { + if ancestor.Name == obj.Name { + return fmt.Errorf("cannot move folder under its own descendant, this would create a circular reference") + } + } + // if by moving a folder we exceed the max depth, return an error if len(info.Items) > maxDepth+1 { return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached") diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index 33fda7ea30f..1f67c0da9d8 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -264,6 +264,71 @@ func TestValidateUpdate(t *testing.T) { maxDepth: folder.MaxNestedFolderDepth, expectedErr: "[folder.maximum-depth-reached]", }, + { + name: "error when moving folder under its own descendant (direct child)", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "child", + }, + }, + Spec: folders.FolderSpec{ + Title: "parent folder", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent", + }, + Spec: folders.FolderSpec{ + Title: "parent folder", + }, + }, + // When querying parents of "child", we get the chain: child -> parent -> root + // This means "parent" is an ancestor of "child", so we can't move "parent" under "child" + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "child", Parent: "parent"}, + {Name: "parent", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + expectedErr: "cannot move folder under its own descendant", + }, + { + name: "error when moving folder under its grandchild", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grandparent", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "grandchild", + }, + }, + Spec: folders.FolderSpec{ + Title: "grandparent folder", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grandparent", + }, + Spec: folders.FolderSpec{ + Title: "grandparent folder", + }, + }, + // When querying parents of "grandchild", we get: grandchild -> child -> grandparent -> root + // This means "grandparent" is in the ancestry, so we can't move it under "grandchild" + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "grandchild", Parent: "child"}, + {Name: "child", Parent: "grandparent"}, + {Name: "grandparent", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + expectedErr: "cannot move folder under its own descendant", + }, } for _, tt := range tests { From 8b6cc211e9a1692f847f3498f01aa5bec8bd765d Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Fri, 7 Nov 2025 09:24:34 -0500 Subject: [PATCH 093/209] Git Sync: Allow user disable push to configured branch (#113564) * Git Sync: Allow user disable push to configured branch --- .../provisioning/Config/ConfigForm.tsx | 9 ++++++ .../EnablePushToConfiguredBranchOption.tsx | 28 +++++++++++++++++++ .../features/provisioning/Config/defaults.ts | 1 + .../provisioning/Wizard/FinishStep.tsx | 10 +++++++ public/app/features/provisioning/types.ts | 1 + .../app/features/provisioning/utils/data.ts | 3 +- public/locales/en-US/grafana.json | 2 ++ 7 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 public/app/features/provisioning/Config/EnablePushToConfiguredBranchOption.tsx diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 211422b0c2a..a29bc68329e 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -38,6 +38,7 @@ import { getHasTokenInstructions } from '../utils/git'; import { getRepositoryTypeConfig, isGitProvider } from '../utils/repositoryTypes'; import { ConfigFormGithubCollapse } from './ConfigFormGithubCollapse'; +import { EnablePushToConfiguredBranchOption } from './EnablePushToConfiguredBranchOption'; import { getDefaultValues } from './defaults'; // This needs to be a function for translations to work @@ -303,6 +304,7 @@ export function ConfigForm({ data }: ConfigFormProps) { onChange: (e) => { if (e.target.checked) { setValue('prWorkflow', false); + setValue('enablePushToConfiguredBranch', false); } }, })} @@ -324,6 +326,13 @@ export function ConfigForm({ data }: ConfigFormProps) { /> )} + {isGitBased && ( + + register={register} + registerName="enablePushToConfiguredBranch" + readOnly={readOnly} + /> + )} {type === 'github' && } {isGitBased && ( diff --git a/public/app/features/provisioning/Config/EnablePushToConfiguredBranchOption.tsx b/public/app/features/provisioning/Config/EnablePushToConfiguredBranchOption.tsx new file mode 100644 index 00000000000..a164bebb7c7 --- /dev/null +++ b/public/app/features/provisioning/Config/EnablePushToConfiguredBranchOption.tsx @@ -0,0 +1,28 @@ +import { FieldValues, UseFormRegister, Path } from 'react-hook-form'; + +import { t } from '@grafana/i18n'; +import { Checkbox, Field } from '@grafana/ui'; + +export function EnablePushToConfiguredBranchOption({ + register, + registerName, + readOnly, +}: { + register: UseFormRegister; + registerName: Path; + readOnly: boolean; +}) { + return ( + + + + ); +} diff --git a/public/app/features/provisioning/Config/defaults.ts b/public/app/features/provisioning/Config/defaults.ts index 65ab1bd84fa..7a8f27ae456 100644 --- a/public/app/features/provisioning/Config/defaults.ts +++ b/public/app/features/provisioning/Config/defaults.ts @@ -31,6 +31,7 @@ export function getDefaultValues({ target: defaultTarget, intervalSeconds: 60, }, + enablePushToConfiguredBranch: true, }; } return specToData(repository); diff --git a/public/app/features/provisioning/Wizard/FinishStep.tsx b/public/app/features/provisioning/Wizard/FinishStep.tsx index 30a4749faaf..63f0f6b2504 100644 --- a/public/app/features/provisioning/Wizard/FinishStep.tsx +++ b/public/app/features/provisioning/Wizard/FinishStep.tsx @@ -5,6 +5,7 @@ import { Trans, t } from '@grafana/i18n'; import { Checkbox, Field, Input, Stack, Text, TextLink } from '@grafana/ui'; import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { EnablePushToConfiguredBranchOption } from '../Config/EnablePushToConfiguredBranchOption'; import { checkImageRenderer, checkImageRenderingAllowed, checkPublicAccess } from '../GettingStarted/features'; import { isGitProvider } from '../utils/repositoryTypes'; @@ -68,6 +69,7 @@ export const FinishStep = memo(function FinishStep() { onChange: (e) => { if (e.target.checked) { setValue('repository.prWorkflow', false); + setValue('repository.enablePushToConfiguredBranch', false); } }, })} @@ -90,6 +92,14 @@ export const FinishStep = memo(function FinishStep() { )} + {isGitBased && ( + + register={register} + readOnly={readOnly} + registerName="repository.enablePushToConfiguredBranch" + /> + )} + {isGithub && imageRenderingAllowed && ( { generateDashboardPreviews: spec.github?.generateDashboardPreviews || false, readOnly: !spec.workflows.length, prWorkflow: spec.workflows.includes('branch'), + enablePushToConfiguredBranch: spec.workflows.includes('write'), }); }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 554150ef77d..79ae2cc323e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "No jobs..." }, + "enable-push-to-configured-branch-description": "Allow direct commits to the configured branch.", + "enable-push-to-configured-branch-label": "Enable push to configured branch", "enhanced-features": { "description": "Get the most out of your GitHub integration with these optional add-ons", "description-instant-updates": "Get instant updates in Grafana as soon as changes are committed. Review and approve changes using pull requests before they go live.", From 305ed25896bcc4530aa23138e407a786d9e8f602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Fri, 7 Nov 2025 15:32:17 +0100 Subject: [PATCH 094/209] fix(folders): add a circuit breaker to prevent infinite loops (#113596) --- pkg/services/folder/folderimpl/unifiedstore.go | 13 +++++++++++-- pkg/services/folder/folderimpl/unifiedstore_test.go | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 28ced5f2321..8c30024eefb 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -388,7 +388,9 @@ func (ss *FolderUnifiedStoreImpl) GetFolders(ctx context.Context, q folder.GetFo } if (q.WithFullpath || q.WithFullpathUIDs) && f.Fullpath == "" { - buildFolderFullPaths(f, relations, folderMap) + if err := buildFolderFullPaths(f, relations, folderMap); err != nil { + return nil, err + } } hits = append(hits, f) @@ -559,15 +561,21 @@ func computeFullPath(parents []*folder.Folder) (string, string) { return strings.Join(fullpath, "/"), strings.Join(fullpathUIDs, "/") } -func buildFolderFullPaths(f *folder.Folder, relations map[string]string, folderMap map[string]*folder.Folder) { +func buildFolderFullPaths(f *folder.Folder, relations map[string]string, folderMap map[string]*folder.Folder) error { titles := make([]string, 0) uids := make([]string, 0) titles = append(titles, f.Title) uids = append(uids, f.UID) + i := 0 currentUID := f.UID for currentUID != "" { + // This is just a circuit breaker to prevent infinite loops. We should never reach this limit. + if i > 1000 { + return fmt.Errorf("folder depth exceeds the maximum allowed depth, You might have a circular reference") + } + i++ parentUID, exists := relations[currentUID] if !exists { break @@ -588,6 +596,7 @@ func buildFolderFullPaths(f *folder.Folder, relations map[string]string, folderM f.Fullpath = strings.Join(util.Reverse(titles), "/") f.FullpathUIDs = strings.Join(util.Reverse(uids), "/") + return nil } func shouldSkipFolder(f *folder.Folder, filterUIDs map[string]struct{}) bool { diff --git a/pkg/services/folder/folderimpl/unifiedstore_test.go b/pkg/services/folder/folderimpl/unifiedstore_test.go index a2bb1db024d..5ffc1d12947 100644 --- a/pkg/services/folder/folderimpl/unifiedstore_test.go +++ b/pkg/services/folder/folderimpl/unifiedstore_test.go @@ -881,7 +881,7 @@ func TestBuildFolderFullPaths(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - buildFolderFullPaths(tt.args.f, tt.args.relations, tt.args.folderMap) + require.NoError(t, buildFolderFullPaths(tt.args.f, tt.args.relations, tt.args.folderMap)) require.Equal(t, tt.want.Fullpath, tt.args.f.Fullpath, "BuildFolderFullPaths() = %v, want %v", tt.args.f.Fullpath, tt.want.Fullpath) require.Equal(t, tt.want.FullpathUIDs, tt.args.f.FullpathUIDs, "BuildFolderFullPaths() = %v, want %v", tt.args.f.FullpathUIDs, tt.want.FullpathUIDs) require.Equal(t, tt.want.Title, tt.args.f.Title, "BuildFolderFullPaths() = %v, want %v", tt.args.f.Title, tt.want.Title) From d7d296df8e46aa10eed364e46351d75308785e60 Mon Sep 17 00:00:00 2001 From: Misi Date: Fri, 7 Nov 2025 16:51:41 +0100 Subject: [PATCH 095/209] Fix: Return auth labels from `/api/users/lookup` (#113584) * wip * Return auth labels from /api/users/lookup * Rename * Address feedback * Add more tests, fix tests * Cleanup --- pkg/api/common_test.go | 2 +- pkg/api/org_users.go | 2 +- pkg/api/user.go | 6 + pkg/api/user_test.go | 38 + pkg/services/login/authinfo.go | 7 +- pkg/services/login/authinfoimpl/service.go | 21 +- .../login/authinfoimpl/service_test.go | 31 + pkg/services/login/authinfoimpl/store.go | 25 +- pkg/services/login/authinfoimpl/store_test.go | 2 +- .../authinfotest/auth_info_service_mock.go | 896 +++--------------- .../authinfotest/auth_info_store_mock.go | 173 ++++ pkg/services/login/authinfotest/fake.go | 19 +- 12 files changed, 463 insertions(+), 759 deletions(-) create mode 100644 pkg/services/login/authinfoimpl/service_test.go create mode 100644 pkg/services/login/authinfotest/auth_info_store_mock.go diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index d324831b075..0578cefd724 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -273,7 +273,7 @@ func setupSimpleHTTPServer(features featuremgmt.FeatureToggles) *HTTPServer { AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), annotationsRepo: annotationstest.NewFakeAnnotationsRepo(), authInfoService: &authinfotest.FakeService{ - ExpectedLabels: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)}, + ExpectedRecentlyUsedLabel: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)}, }, tracer: tracing.InitializeTracerForTest(), } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 0367f58b2f5..8a10cc24944 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -314,7 +314,7 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or filteredUsers = append(filteredUsers, user) } - modules, err := hs.authInfoService.GetUserLabels(c.Req.Context(), login.GetUserLabelsQuery{ + modules, err := hs.authInfoService.GetUsersRecentlyUsedLabel(c.Req.Context(), login.GetUserLabelsQuery{ UserIDs: authLabelsUserIDs, }) diff --git a/pkg/api/user.go b/pkg/api/user.go index 311f80e49ae..e8c02ed1cf0 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -115,6 +115,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response } return response.Error(http.StatusInternalServerError, "Failed to get user", err) } + result := user.UserProfileDTO{ ID: usr.ID, UID: usr.UID, @@ -128,6 +129,11 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response UpdatedAt: usr.Updated, CreatedAt: usr.Created, } + // Populate AuthLabels using all historically used auth modules ordered by most recent. + if modules, err := hs.authInfoService.GetUserAuthModuleLabels(c.Req.Context(), usr.ID); err == nil { + result.AuthLabels = modules + } + return response.JSON(http.StatusOK, &result) } diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 41b11e76487..8e4b3503015 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -185,6 +185,44 @@ func TestIntegrationUserAPIEndpoint_userLoggedIn(t *testing.T) { require.NoError(t, err) }, mock) + // Multiple historical auth labels should appear ordered by recency + loggedInUserScenario(t, "When calling GET returns with multiple auth labels", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) { + createUserCmd := user.CreateUserCommand{ + Email: fmt.Sprint("multi", "@test.com"), + Name: "multi", + Login: "multi", + IsAdmin: true, + } + orgSvc, err := orgimpl.ProvideService(sqlStore, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, sc.cfg, nil, nil, tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) + require.NoError(t, err) + usr, err := userSvc.Create(context.Background(), &createUserCmd) + require.Nil(t, err) + + sc.handlerFunc = hs.GetUserByLoginOrEmail + + userMock := usertest.NewUserServiceFake() + userMock.ExpectedUser = &user.User{ID: usr.ID, Email: usr.Email, Login: usr.Login, Name: usr.Name} + sc.userService = userMock + hs.userService = userMock + + fakeAuth := &authinfotest.FakeService{ExpectedAuthModuleLabels: []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)}} + hs.authInfoService = fakeAuth + + sc.fakeReqWithParams("GET", sc.url, map[string]string{"loginOrEmail": usr.Email}).exec() + + var resp user.UserProfileDTO + require.Equal(t, http.StatusOK, sc.resp.Code) + err = json.Unmarshal(sc.resp.Body.Bytes(), &resp) + require.NoError(t, err) + expected := []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)} + require.Equal(t, expected, resp.AuthLabels) + }, mock) + loggedInUserScenario(t, "When calling GET on", "/api/users", "/api/users", func(sc *scenarioContext) { userMock.ExpectedSearchUsers = mockResult diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 095e3390ce9..e8922772240 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -8,15 +8,18 @@ import ( //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) + GetUsersRecentlyUsedLabel(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error) + GetUserAuthModuleLabels(ctx context.Context, userID int64) ([]string, error) SetAuthInfo(ctx context.Context, cmd *SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *UpdateAuthInfoCommand) error DeleteUserAuthInfo(ctx context.Context, userID int64) error } +//go:generate mockery --name Store --structname MockAuthInfoStore --outpkg authinfotest --filename auth_info_store_mock.go --output ./authinfotest/ type Store interface { GetAuthInfo(ctx context.Context, query *GetAuthInfoQuery) (*UserAuth, error) - GetUserLabels(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error) + GetUsersRecentlyUsedLabel(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error) + GetUserAuthModules(ctx context.Context, userID int64) ([]string, error) SetAuthInfo(ctx context.Context, cmd *SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *UpdateAuthInfoCommand) error DeleteUserAuthInfo(ctx context.Context, userID int64) error diff --git a/pkg/services/login/authinfoimpl/service.go b/pkg/services/login/authinfoimpl/service.go index 1d7736c2c54..3c92e0c795f 100644 --- a/pkg/services/login/authinfoimpl/service.go +++ b/pkg/services/login/authinfoimpl/service.go @@ -67,11 +67,28 @@ func (s *Service) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery return authInfo, nil } -func (s *Service) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { +// GetUserAuthModuleLabels returns all auth modules for a user ordered by most recent first. +func (s *Service) GetUserAuthModuleLabels(ctx context.Context, userID int64) ([]string, error) { + modules, err := s.authInfoStore.GetUserAuthModules(ctx, userID) + if err != nil { + return nil, err + } + + result := make([]string, 0, len(modules)) + // modules should be unique and should not contain empty strings + for _, m := range modules { + label := login.GetAuthProviderLabel(m) + result = append(result, label) + } + + return result, nil +} + +func (s *Service) GetUsersRecentlyUsedLabel(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { if len(query.UserIDs) == 0 { return map[int64]string{}, nil } - return s.authInfoStore.GetUserLabels(ctx, query) + return s.authInfoStore.GetUsersRecentlyUsedLabel(ctx, query) } func (s *Service) setAuthInfoInCache(ctx context.Context, query *login.GetAuthInfoQuery, info *login.UserAuth) error { diff --git a/pkg/services/login/authinfoimpl/service_test.go b/pkg/services/login/authinfoimpl/service_test.go new file mode 100644 index 00000000000..210ac463625 --- /dev/null +++ b/pkg/services/login/authinfoimpl/service_test.go @@ -0,0 +1,31 @@ +package authinfoimpl + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/login/authinfotest" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestAuthInfoService_GetUserAuthModuleLabels(t *testing.T) { + store := authinfotest.NewMockAuthInfoStore(t) + + userID := int64(42) + // Input modules from store (order matters, uniqueness assumed) + modules := []string{login.OktaAuthModule, login.LDAPAuthModule, login.SAMLAuthModule} + + store.On("GetUserAuthModules", mock.Anything, userID).Return(modules, nil) + + svc := ProvideService(store, nil, nil) + + actual, err := svc.GetUserAuthModuleLabels(context.Background(), userID) + require.NoError(t, err) + + expected := []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)} + + // Verify labels mapped and order preserved + require.Equal(t, expected, actual) +} diff --git a/pkg/services/login/authinfoimpl/store.go b/pkg/services/login/authinfoimpl/store.go index 3d8dcd6c39f..49b1c565754 100644 --- a/pkg/services/login/authinfoimpl/store.go +++ b/pkg/services/login/authinfoimpl/store.go @@ -82,7 +82,7 @@ func (s *Store) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) return userAuth, nil } -func (s *Store) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { +func (s *Store) GetUsersRecentlyUsedLabel(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { userAuths := []login.UserAuth{} params := make([]interface{}, 0, len(query.UserIDs)) for _, id := range query.UserIDs { @@ -105,6 +105,29 @@ func (s *Store) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuer return labelMap, nil } +// GetUserAuthModules returns all auth modules a user has used ordered by most recently used first. +func (s *Store) GetUserAuthModules(ctx context.Context, userID int64) ([]string, error) { + rows := make([]struct { + AuthModule string `xorm:"auth_module"` + }, 0) + err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + return sess.Table("user_auth").Where("user_id = ?", userID).Desc("created").Cols("auth_module").Find(&rows) + }) + if err != nil { + return nil, err + } + modules := make([]string, 0, len(rows)) + seen := make(map[string]struct{}, len(rows)) + for _, r := range rows { + if _, ok := seen[r.AuthModule]; ok { + continue + } + seen[r.AuthModule] = struct{}{} + modules = append(modules, r.AuthModule) + } + return modules, nil +} + func (s *Store) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { authUser := &login.UserAuth{ UserId: cmd.UserId, diff --git a/pkg/services/login/authinfoimpl/store_test.go b/pkg/services/login/authinfoimpl/store_test.go index aa6f2b85083..a930580c1d7 100644 --- a/pkg/services/login/authinfoimpl/store_test.go +++ b/pkg/services/login/authinfoimpl/store_test.go @@ -45,7 +45,7 @@ func TestIntegrationAuthInfoStore(t *testing.T) { UserId: 2, })) - labels, err := store.GetUserLabels(ctx, login.GetUserLabelsQuery{UserIDs: []int64{1, 2}}) + labels, err := store.GetUsersRecentlyUsedLabel(ctx, login.GetUserLabelsQuery{UserIDs: []int64{1, 2}}) require.NoError(t, err) require.Len(t, labels, 2) diff --git a/pkg/services/login/authinfotest/auth_info_service_mock.go b/pkg/services/login/authinfotest/auth_info_service_mock.go index 42f9bd60b7f..a7004c24c54 100644 --- a/pkg/services/login/authinfotest/auth_info_service_mock.go +++ b/pkg/services/login/authinfotest/auth_info_service_mock.go @@ -1,17 +1,163 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify +// Code generated by mockery v2.53.5. DO NOT EDIT. package authinfotest import ( - "context" + context "context" - "github.com/grafana/grafana/pkg/services/login" - "github.com/grafana/grafana/pkg/services/user" + login "github.com/grafana/grafana/pkg/services/login" mock "github.com/stretchr/testify/mock" ) +// MockAuthInfoService is an autogenerated mock type for the AuthInfoService type +type MockAuthInfoService struct { + mock.Mock +} + +// DeleteUserAuthInfo provides a mock function with given fields: ctx, userID +func (_m *MockAuthInfoService) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + ret := _m.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for DeleteUserAuthInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAuthInfo provides a mock function with given fields: ctx, query +func (_m *MockAuthInfoService) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetAuthInfo") + } + + var r0 *login.UserAuth + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) (*login.UserAuth, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) *login.UserAuth); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*login.UserAuth) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *login.GetAuthInfoQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetUserAuthModuleLabels provides a mock function with given fields: ctx, userID +func (_m *MockAuthInfoService) GetUserAuthModuleLabels(ctx context.Context, userID int64) ([]string, error) { + ret := _m.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for GetUserAuthModuleLabels") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) ([]string, error)); ok { + return rf(ctx, userID) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) []string); ok { + r0 = rf(ctx, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetUsersRecentlyUsedLabel provides a mock function with given fields: ctx, query +func (_m *MockAuthInfoService) GetUsersRecentlyUsedLabel(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetUsersRecentlyUsedLabel") + } + + var r0 map[int64]string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) (map[int64]string, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) map[int64]string); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[int64]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, login.GetUserLabelsQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetAuthInfo provides a mock function with given fields: ctx, cmd +func (_m *MockAuthInfoService) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { + ret := _m.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for SetAuthInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *login.SetAuthInfoCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateAuthInfo provides a mock function with given fields: ctx, cmd +func (_m *MockAuthInfoService) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { + ret := _m.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for UpdateAuthInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *login.UpdateAuthInfoCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // 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 { @@ -25,741 +171,3 @@ func NewMockAuthInfoService(t interface { 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/login/authinfotest/auth_info_store_mock.go b/pkg/services/login/authinfotest/auth_info_store_mock.go new file mode 100644 index 00000000000..676985404c8 --- /dev/null +++ b/pkg/services/login/authinfotest/auth_info_store_mock.go @@ -0,0 +1,173 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package authinfotest + +import ( + context "context" + + login "github.com/grafana/grafana/pkg/services/login" + mock "github.com/stretchr/testify/mock" +) + +// MockAuthInfoStore is an autogenerated mock type for the Store type +type MockAuthInfoStore struct { + mock.Mock +} + +// DeleteUserAuthInfo provides a mock function with given fields: ctx, userID +func (_m *MockAuthInfoStore) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + ret := _m.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for DeleteUserAuthInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAuthInfo provides a mock function with given fields: ctx, query +func (_m *MockAuthInfoStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetAuthInfo") + } + + var r0 *login.UserAuth + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) (*login.UserAuth, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) *login.UserAuth); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*login.UserAuth) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *login.GetAuthInfoQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetUserAuthModules provides a mock function with given fields: ctx, userID +func (_m *MockAuthInfoStore) GetUserAuthModules(ctx context.Context, userID int64) ([]string, error) { + ret := _m.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for GetUserAuthModules") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) ([]string, error)); ok { + return rf(ctx, userID) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) []string); ok { + r0 = rf(ctx, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetUsersRecentlyUsedLabel provides a mock function with given fields: ctx, query +func (_m *MockAuthInfoStore) GetUsersRecentlyUsedLabel(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { + ret := _m.Called(ctx, query) + + if len(ret) == 0 { + panic("no return value specified for GetUsersRecentlyUsedLabel") + } + + var r0 map[int64]string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) (map[int64]string, error)); ok { + return rf(ctx, query) + } + if rf, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) map[int64]string); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[int64]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, login.GetUserLabelsQuery) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetAuthInfo provides a mock function with given fields: ctx, cmd +func (_m *MockAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { + ret := _m.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for SetAuthInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *login.SetAuthInfoCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateAuthInfo provides a mock function with given fields: ctx, cmd +func (_m *MockAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { + ret := _m.Called(ctx, cmd) + + if len(ret) == 0 { + panic("no return value specified for UpdateAuthInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *login.UpdateAuthInfoCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockAuthInfoStore creates a new instance of MockAuthInfoStore. 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 NewMockAuthInfoStore(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAuthInfoStore { + mock := &MockAuthInfoStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/login/authinfotest/fake.go b/pkg/services/login/authinfotest/fake.go index 9a4d7843a3c..63a9796bbfc 100644 --- a/pkg/services/login/authinfotest/fake.go +++ b/pkg/services/login/authinfotest/fake.go @@ -8,11 +8,12 @@ import ( type FakeService struct { login.AuthInfoService - LatestUserID int64 - ExpectedUserAuth *login.UserAuth - ExpectedExternalUser *login.ExternalUserInfo - ExpectedError error - ExpectedLabels map[int64]string + LatestUserID int64 + ExpectedUserAuth *login.UserAuth + ExpectedExternalUser *login.ExternalUserInfo + ExpectedError error + ExpectedRecentlyUsedLabel map[int64]string + ExpectedAuthModuleLabels []string SetAuthInfoFn func(ctx context.Context, cmd *login.SetAuthInfoCommand) error UpdateAuthInfoFn func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error @@ -24,8 +25,12 @@ func (a *FakeService) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQ return a.ExpectedUserAuth, a.ExpectedError } -func (a *FakeService) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { - return a.ExpectedLabels, a.ExpectedError +func (a *FakeService) GetUsersRecentlyUsedLabel(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) { + return a.ExpectedRecentlyUsedLabel, a.ExpectedError +} + +func (a *FakeService) GetUserAuthModuleLabels(ctx context.Context, userID int64) ([]string, error) { + return a.ExpectedAuthModuleLabels, a.ExpectedError } func (a *FakeService) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { From 3d8da615691cf3971688da702143250710bcba4f Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 7 Nov 2025 11:06:33 -0500 Subject: [PATCH 096/209] E2E: Improve ad-hoc filtering test (#113558) * E2E: Improve ad-hoc filtering test * remove unused import * fix some table e2es after making getCell sync --- .../adhoc-filter-from-panel.spec.ts | 121 +++++++++--------- .../panels-suite/table-kitchenSink.spec.ts | 22 ++-- e2e-playwright/panels-suite/table-utils.ts | 6 +- 3 files changed, 76 insertions(+), 73 deletions(-) diff --git a/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts b/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts index 8ad6b634a87..e47fb1b26f9 100644 --- a/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts +++ b/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @@ -1,16 +1,9 @@ -import { Page, Locator } from '@playwright/test'; - import { test, expect } from '@grafana/plugin-e2e'; import testDashboard from '../dashboards/AdHocFilterTest.json'; +import { getCell } from '../panels-suite/table-utils'; -// Helper function to get a specific cell in a table -const getCell = async (loc: Page | Locator, rowIdx: number, colIdx: number) => - loc - .getByRole('row') - .nth(rowIdx) - .getByRole(rowIdx === 0 ? 'columnheader' : 'gridcell') - .nth(colIdx); +const fixture = require('../fixtures/prometheus-response.json'); test.describe( 'Dashboard with Table powered by Prometheus data source', @@ -46,80 +39,90 @@ test.describe( gotoDashboardPage, selectors, }) => { - // Handle query and query_range API calls + // Handle query and query_range API calls. Ideally, this would instead be directly tested against gdev-prometheus. await page.route(/\/api\/ds\/query/, async (route) => { - const fixture = require('../fixtures/prometheus-response.json'); - // during the test, we select the "inner_eval" slice to filter; this simulates the behavior - // of prometheus applying that filter and removing dataframes from the response. - if (route.request().postData()?.includes('{slice=\\\"inner_eval\\\"}')) { - fixture.results.A.frames.splice(1, 1); + const response = JSON.parse(JSON.stringify(fixture)); + + // This simulates the behavior of prometheus applying a filter and removing dataframes from the response where + // the label matches the selected filter. We check for either the slice being applied inline into the prometheus + // query or the adhoc filter being present in the request body of prometheus applying that filter and removing + // dataframes from the response. + const postData = route.request().postData(); + const match = + postData?.match(/{slice=\\\"([\w_]+)\\\"}/) ?? + postData?.match(/"adhocFilters":\[{"key":"slice","operator":"equals","value":"([\w_]+)"}\]/); + if (match) { + response.results.A.frames = response.results.A.frames.filter((frame) => + frame.schema.fields.every((field) => !field.labels || field.labels.slice === match[1]) + ); } await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(fixture), + body: JSON.stringify(response), }); }); const dashboardPage = await gotoDashboardPage({ uid: dashboardUID }); - const panel = dashboardPage.getByGrafanaSelector( + let panel = dashboardPage.getByGrafanaSelector( selectors.components.Panels.Panel.title('Table powered by Prometheus') ); - await expect(panel).toBeVisible(); + await expect(panel, 'panel is rendered').toBeVisible(); // Wait for the table to load completely - await expect(panel.locator('.rdg')).toBeVisible(); + const table = panel.locator('.rdg'); + await expect(table, 'table is rendered').toBeVisible(); - // Get the first data cell in the third column (row 1, column 2) - const labelValueCell = await getCell(panel, 1, 1); - await expect(labelValueCell).toBeVisible(); + const firstValue = (await getCell(table, 1, 1).textContent())!; + const secondValue = (await getCell(table, 2, 1).textContent())!; + expect(firstValue, `first cell is "${firstValue}"`).toBeTruthy(); + expect(secondValue, `second cell is "${secondValue}"`).toBeTruthy(); + expect(firstValue, 'first and second cell values are different').not.toBe(secondValue); - // Get the cell value before clicking the filter button - const labelValue = await labelValueCell.textContent(); - expect(labelValue).toBeTruthy(); + async function performTest(labelValue: string) { + // Confirm both cells are rendered before we proceed + const otherValue = labelValue === firstValue ? secondValue : firstValue; + await expect(table.getByText(labelValue), `"${labelValue}" is rendered`).toContainText(labelValue); + await expect(table.getByText(otherValue), `"${otherValue}" is rendered`).toContainText(otherValue); - const otherValueCell = await getCell(panel, 2, 1); - const otherValueLabel = await otherValueCell.textContent(); - expect(otherValueLabel).toBeTruthy(); - expect(otherValueLabel).not.toBe(labelValue); + // click the "Filter for value" button on the cell with the specified labelValue + await table.getByText(labelValue).hover(); + table.getByText(labelValue).getByRole('button', { name: 'Filter for value' }).click(); - // Hover over the first cell to trigger the appearance of filter actions - await labelValueCell.hover(); + // Look for submenu items that contain the filtered value + // The adhoc filter should appear as a filter chip or within the variable controls + const submenuItems = dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItem); + await expect(submenuItems.filter({ hasText: labelValue }), `submenu contains "${labelValue}"`).toBeVisible(); + await expect( + submenuItems.filter({ hasText: otherValue }), + `submenu does not contain "${otherValue}"` + ).toBeHidden(); - // Check if the "Filter for value" button appears on hover - const filterForValueButton = labelValueCell.getByRole('button', { name: 'Filter for value' }); - await expect(filterForValueButton).toBeVisible(); + // The URL parameter should contain the filter in format like: var-PromAdHoc=["columnName","=","value"] + const currentUrl = page.url(); + const urlParams = new URLSearchParams(new URL(currentUrl).search); + const promAdHocParam = urlParams.get('var-PromAdHoc'); + expect(promAdHocParam, `url contains "${labelValue}"`).toContain(labelValue); + expect(promAdHocParam, `url does not contain "${otherValue}"`).not.toContain(otherValue); - // Click on the "Filter for value" button - await filterForValueButton.click(); + // finally, let's check that the table was updated and that the value was filtered out when the query was re-run + await expect(table.getByText(labelValue), `"${labelValue}" is still visible`).toHaveText(labelValue); + await expect(table.getByText(otherValue), `"${otherValue}" is filtered out`).toBeHidden(); - // Check if the adhoc filter appears in the dashboard submenu - const submenuItems = dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItem); - await expect(submenuItems.first()).toBeVisible(); + // Remove the adhoc filter by clicking the submenu item again + const filterChip = submenuItems.filter({ hasText: labelValue }); + await filterChip.getByLabel(/Remove filter with key/).click(); + await page.click('body', { position: { x: 0, y: 0 } }); // click outside to close the open menu from ad-hoc filters - // Look for submenu items that contain the filtered value - // The adhoc filter should appear as a filter chip or within the variable controls - const hasFilterValue = await submenuItems.filter({ hasText: labelValue! }).count(); - expect(hasFilterValue).toBeGreaterThan(0); + // the "first" and "second" cells locators don't work here for some reason. + await expect(table.getByText(labelValue), `"${labelValue}" is still rendered`).toContainText(labelValue); + await expect(table.getByText(otherValue), `"${otherValue}" is rendered again`).toContainText(otherValue); + } - const hasOtherValue = await submenuItems.filter({ hasText: otherValueLabel! }).count(); - expect(hasOtherValue).toBe(0); - - // Check if the URL contains the var-PromAdHoc parameter with the filtered value - const currentUrl = page.url(); - expect(currentUrl).toContain('var-PromAdHoc'); - - // The URL parameter should contain the filter in format like: var-PromAdHoc=["columnName","=","value"] - const urlParams = new URLSearchParams(new URL(currentUrl).search); - const promAdHocParam = urlParams.get('var-PromAdHoc'); - expect(promAdHocParam).toBeTruthy(); - expect(promAdHocParam).toContain(labelValue!); - expect(promAdHocParam).not.toContain(otherValueLabel!); - - // finally, let's check that the table was updated and that the value was filtered out when the query was re-run - await expect(otherValueCell).toBeHidden(); + await performTest(firstValue); + await performTest(secondValue); }); } ); diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 7312027d3b6..a14085aa753 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -65,11 +65,11 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // test that hover overflow works. - const loremIpsumCell = await getCell(page, 1, longTextColIdx); + const loremIpsumCell = getCell(page, 1, longTextColIdx); await loremIpsumCell.scrollIntoViewIfNeeded(); await loremIpsumCell.hover(); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); - await (await getCell(page, 1, longTextColIdx + 1)).hover(); + await getCell(page, 1, longTextColIdx + 1).hover(); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // enable cell inspect, confirm that hover no longer triggers. @@ -140,15 +140,15 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] ).toBeVisible(); // click the "State" column header to sort it. - const stateColumnHeader = await getCell(page, 0, 1); + const stateColumnHeader = getCell(page, 0, 1); await stateColumnHeader.getByText('Info').click(); await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'ascending'); - expect(getCell(page, 1, 1)).resolves.toContainText('down'); // down or down fast + await expect(getCell(page, 1, 1)).toContainText('down'); // down or down fast await stateColumnHeader.getByText('Info').click(); await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'descending'); - expect(getCell(page, 1, 1)).resolves.toContainText('up'); // up or up fast + await expect(getCell(page, 1, 1)).toContainText('up'); // up or up fast await stateColumnHeader.getByText('Info').click(); await expect(stateColumnHeader).not.toHaveAttribute('aria-sort'); @@ -171,7 +171,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] const stateColumnHeader = page.getByRole('columnheader').nth(infoColumnIdx); // get the first value in the "State" column, filter it out, then check that it went away. - const firstStateValue = (await (await getCell(page, 1, infoColumnIdx)).textContent())!; + const firstStateValue = (await getCell(page, 1, infoColumnIdx).textContent())!; await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click(); const filterContainer = dashboardPage.getByGrafanaSelector( selectors.components.Panels.Visualization.TableNG.Filters.Container @@ -188,7 +188,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await expect(filterContainer).not.toBeVisible(); // did it actually filter out our value? - await expect(getCell(page, 1, infoColumnIdx)).resolves.not.toHaveText(firstStateValue); + await expect(getCell(page, 1, infoColumnIdx)).not.toHaveText(firstStateValue); }); test('Tests pagination, row height adjustment', async ({ gotoDashboardPage, selectors, page }) => { @@ -289,7 +289,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] const dataLinkColIdx = await getColumnIdx(page, 'Data Link'); // Info column has a single DataLink by default. - const infoCell = await getCell(page, 1, infoColumnIdx); + const infoCell = getCell(page, 1, infoColumnIdx); await expect(infoCell.locator('a')).toBeVisible(); expect(infoCell.locator('a')).toHaveAttribute('href'); expect(infoCell.locator('a')).not.toHaveAttribute('aria-haspopup'); @@ -306,7 +306,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] continue; } - const cell = await getCell(page, 1, colIdx); + const cell = getCell(page, 1, colIdx); await expect(cell.locator('a')).toBeVisible(); expect(cell.locator('a')).toHaveAttribute('href'); expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); @@ -319,7 +319,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] // loop thru the columns, click the links, observe that the tooltip appears, and close the tooltip. for (let colIdx = 0; colIdx < colCount; colIdx++) { - const cell = await getCell(page, 1, colIdx); + const cell = getCell(page, 1, colIdx); if (colIdx === infoColumnIdx) { // the Info column should still have its single link. expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); @@ -433,7 +433,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await filterContainer.getByTitle('up', { exact: true }).locator('label').click(); await filterContainer.getByRole('button', { name: 'Ok' }).click(); - const cell = await getCell(page, 1, dataLinkColumnIdx); + const cell = getCell(page, 1, dataLinkColumnIdx); await expect(cell).toBeVisible(); await expect(cell).toHaveCSS('text-decoration', /line-through/); diff --git a/e2e-playwright/panels-suite/table-utils.ts b/e2e-playwright/panels-suite/table-utils.ts index 6a225665542..a289c0b211a 100644 --- a/e2e-playwright/panels-suite/table-utils.ts +++ b/e2e-playwright/panels-suite/table-utils.ts @@ -1,6 +1,6 @@ import { Page, Locator } from '@playwright/test'; -export const getCell = async (loc: Page | Locator, rowIdx: number, colIdx: number) => +export const getCell = (loc: Page | Locator, rowIdx: number, colIdx: number) => loc .getByRole('row') .nth(rowIdx) @@ -8,7 +8,7 @@ export const getCell = async (loc: Page | Locator, rowIdx: number, colIdx: numbe .nth(colIdx); export const getCellHeight = async (loc: Page | Locator, rowIdx: number, colIdx: number) => { - const cell = await getCell(loc, rowIdx, colIdx); + const cell = getCell(loc, rowIdx, colIdx); return (await cell.boundingBox())?.height ?? 0; }; @@ -18,7 +18,7 @@ export const getColumnIdx = async (loc: Page | Locator, columnName: string) => { let result = -1; const colCount = await loc.getByRole('columnheader').count(); for (let colIdx = 0; colIdx < colCount; colIdx++) { - const cell = await getCell(loc, 0, colIdx); + const cell = getCell(loc, 0, colIdx); if ((await cell.textContent()) === columnName) { result = colIdx; break; From 62129bb91f9bc0883cf9591a9892763239101315 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Fri, 7 Nov 2025 17:27:19 +0100 Subject: [PATCH 097/209] Search: Change copy to `Search with Grafana Assistant` (#113609) --- public/app/features/commandPalette/CommandPalette.test.tsx | 4 ++-- public/app/features/commandPalette/CommandPalette.tsx | 2 +- public/locales/en-US/grafana.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/features/commandPalette/CommandPalette.test.tsx b/public/app/features/commandPalette/CommandPalette.test.tsx index 02cdd308229..eeedeacc5a5 100644 --- a/public/app/features/commandPalette/CommandPalette.test.tsx +++ b/public/app/features/commandPalette/CommandPalette.test.tsx @@ -44,7 +44,7 @@ describe('CommandPalette', () => { // Check if empty state message is rendered expect(await screen.findByText('No results found')).toBeInTheDocument(); // Check if AI Assistant button is rendered with correct props - expect(screen.getByRole('button', { name: 'Try searching with Grafana Assistant' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Search with Grafana Assistant' })).toBeInTheDocument(); }); it('should render empty state without AI Assistant button when assistant is not available', async () => { @@ -55,6 +55,6 @@ describe('CommandPalette', () => { // Check if empty state message is rendered expect(await screen.findByText('No results found')).toBeInTheDocument(); // Check that AI Assistant button is not rendered - expect(screen.queryByRole('button', { name: 'Try searching with Grafana Assistant' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Search with Grafana Assistant' })).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index d51b238a569..2c223f4f1a6 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -189,7 +189,7 @@ const RenderResults = ({ isFetchingSearchResults, searchResults, searchQuery }: )} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 79ae2cc323e..6c9cb73d689 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4130,7 +4130,7 @@ "scopes": "Scopes" }, "empty-state": { - "button-title": "Try searching with Grafana Assistant", + "button-title": "Search with Grafana Assistant", "message": "No results found" }, "scopes": { From 02464c19b858dc01b9f453ee476af60f7f9250f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Fri, 7 Nov 2025 17:31:50 +0100 Subject: [PATCH 098/209] Provisioning: Add validation for Job specifications (#113590) * Validate Job Specs * Add comprehensive unit test coverage for job validator - Added 8 new test cases to improve coverage from 88.9% to ~100% - Tests for migrate action without options - Tests for delete/move actions with resources (missing kind) - Tests for move action with valid resources - Tests for move/delete with both paths and resources - Tests for move action with invalid source paths - Tests for push action with valid paths Now covers all validation paths including resource validation and edge cases for all job action types. * Add integration tests for job validation Added comprehensive integration tests that verify the job validator properly rejects invalid job specifications via the API: - Test job without action (required field) - Test job with invalid action - Test pull job without pull options - Test push job without push options - Test push job with invalid branch name (consecutive dots) - Test push job with path traversal attempt - Test delete job without paths or resources - Test delete job with invalid path (path traversal) - Test move job without target path - Test move job without paths or resources - Test move job with invalid target path (path traversal) - Test migrate job without migrate options - Test valid pull job to ensure validation doesn't block legitimate requests These tests verify that the admission controller properly validates job specs before they are persisted, ensuring security (path traversal prevention) and data integrity (required fields/options). * Remove valid job test case from integration tests Removed the positive test case as it's not necessary for validation testing. The integration tests now focus solely on verifying that invalid job specs are properly rejected by the admission controller. * Fix movejob_test to expect validation error at creation time Updated the 'move without target path' test to expect the job creation to fail with a validation error, rather than expecting the job to be created and then fail during execution. This aligns with the new job validation logic which rejects invalid job specs at the API admission control level (422 Unprocessable Entity) before they can be persisted. This is better behavior as it prevents invalid jobs from being created in the first place, rather than allowing them to be created and then failing during execution. * Simplify action validation using slices.Contains Replaced manual loop with slices.Contains for cleaner, more idiomatic Go code. This reduces code complexity while maintaining the same validation logic. - Added import for 'slices' package - Replaced 8-line loop with 1-line slices.Contains call - All unit tests pass * Refactor job action validation in ValidateJob function Removed the hardcoded valid actions array and simplified the validation logic. The function now directly appends an error for invalid actions, improving code clarity and maintainability. This change aligns with the recent updates to job validation, ensuring that invalid job specifications are properly handled. --- apps/provisioning/pkg/jobs/validator.go | 172 +++++ apps/provisioning/pkg/jobs/validator_test.go | 593 ++++++++++++++++++ pkg/registry/apis/provisioning/register.go | 7 +- .../apis/provisioning/job_validation_test.go | 182 ++++++ pkg/tests/apis/provisioning/movejob_test.go | 19 +- 5 files changed, 966 insertions(+), 7 deletions(-) create mode 100644 apps/provisioning/pkg/jobs/validator.go create mode 100644 apps/provisioning/pkg/jobs/validator_test.go create mode 100644 pkg/tests/apis/provisioning/job_validation_test.go diff --git a/apps/provisioning/pkg/jobs/validator.go b/apps/provisioning/pkg/jobs/validator.go new file mode 100644 index 00000000000..77232490b66 --- /dev/null +++ b/apps/provisioning/pkg/jobs/validator.go @@ -0,0 +1,172 @@ +package jobs + +import ( + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/repository/git" + "github.com/grafana/grafana/apps/provisioning/pkg/safepath" +) + +// ValidateJob performs validation on the Job specification and returns an error if validation fails +func ValidateJob(job *provisioning.Job) error { + list := field.ErrorList{} + + // Validate action is specified + if job.Spec.Action == "" { + list = append(list, field.Required(field.NewPath("spec", "action"), "action must be specified")) + return toError(job.Name, list) // Early return since we can't validate further without knowing the action + } + + // Validate repository is specified + if job.Spec.Repository == "" { + list = append(list, field.Required(field.NewPath("spec", "repository"), "repository must be specified")) + } + + // Validate action-specific options + switch job.Spec.Action { + case provisioning.JobActionPull: + if job.Spec.Pull == nil { + list = append(list, field.Required(field.NewPath("spec", "pull"), "pull options required for pull action")) + } + // Pull options are simple, just incremental bool - no further validation needed + + case provisioning.JobActionPush: + if job.Spec.Push == nil { + list = append(list, field.Required(field.NewPath("spec", "push"), "push options required for push action")) + } else { + list = append(list, validateExportJobOptions(job.Spec.Push)...) + } + + case provisioning.JobActionPullRequest: + if job.Spec.PullRequest == nil { + list = append(list, field.Required(field.NewPath("spec", "pr"), "pull request options required for pr action")) + } + // PullRequest options are mostly informational - no strict validation needed + + case provisioning.JobActionMigrate: + if job.Spec.Migrate == nil { + list = append(list, field.Required(field.NewPath("spec", "migrate"), "migrate options required for migrate action")) + } + // Migrate options are simple - no further validation needed + + case provisioning.JobActionDelete: + if job.Spec.Delete == nil { + list = append(list, field.Required(field.NewPath("spec", "delete"), "delete options required for delete action")) + } else { + list = append(list, validateDeleteJobOptions(job.Spec.Delete)...) + } + + case provisioning.JobActionMove: + if job.Spec.Move == nil { + list = append(list, field.Required(field.NewPath("spec", "move"), "move options required for move action")) + } else { + list = append(list, validateMoveJobOptions(job.Spec.Move)...) + } + default: + list = append(list, field.Invalid(field.NewPath("spec", "action"), job.Spec.Action, "invalid action")) + } + + return toError(job.Name, list) +} + +// toError converts a field.ErrorList to an error, returning nil if the list is empty +func toError(name string, list field.ErrorList) error { + if len(list) == 0 { + return nil + } + return apierrors.NewInvalid( + provisioning.JobResourceInfo.GroupVersionKind().GroupKind(), + name, list) +} + +// validateExportJobOptions validates export (push) job options +func validateExportJobOptions(opts *provisioning.ExportJobOptions) field.ErrorList { + list := field.ErrorList{} + + // Validate branch name if specified + if opts.Branch != "" { + if !git.IsValidGitBranchName(opts.Branch) { + list = append(list, field.Invalid(field.NewPath("spec", "push", "branch"), opts.Branch, "invalid git branch name")) + } + } + + // Validate path if specified + if opts.Path != "" { + if err := safepath.IsSafe(opts.Path); err != nil { + list = append(list, field.Invalid(field.NewPath("spec", "push", "path"), opts.Path, err.Error())) + } + } + + return list +} + +// validateDeleteJobOptions validates delete job options +func validateDeleteJobOptions(opts *provisioning.DeleteJobOptions) field.ErrorList { + list := field.ErrorList{} + + // At least one of paths or resources must be specified + if len(opts.Paths) == 0 && len(opts.Resources) == 0 { + list = append(list, field.Required(field.NewPath("spec", "delete"), "at least one path or resource must be specified")) + return list + } + + // Validate paths + for i, p := range opts.Paths { + if err := safepath.IsSafe(p); err != nil { + list = append(list, field.Invalid(field.NewPath("spec", "delete", "paths").Index(i), p, err.Error())) + } + } + + // Validate resources + for i, r := range opts.Resources { + if r.Name == "" { + list = append(list, field.Required(field.NewPath("spec", "delete", "resources").Index(i).Child("name"), "resource name is required")) + } + if r.Kind == "" { + list = append(list, field.Required(field.NewPath("spec", "delete", "resources").Index(i).Child("kind"), "resource kind is required")) + } + } + + return list +} + +// validateMoveJobOptions validates move job options +func validateMoveJobOptions(opts *provisioning.MoveJobOptions) field.ErrorList { + list := field.ErrorList{} + + // At least one of paths or resources must be specified + if len(opts.Paths) == 0 && len(opts.Resources) == 0 { + list = append(list, field.Required(field.NewPath("spec", "move"), "at least one path or resource must be specified")) + return list + } + + // Target path is required + if opts.TargetPath == "" { + list = append(list, field.Required(field.NewPath("spec", "move", "targetPath"), "target path is required")) + } else { + if err := safepath.IsSafe(opts.TargetPath); err != nil { + list = append(list, field.Invalid(field.NewPath("spec", "move", "targetPath"), opts.TargetPath, err.Error())) + } + } + + // Validate source paths + for i, p := range opts.Paths { + if err := safepath.IsSafe(p); err != nil { + list = append(list, field.Invalid(field.NewPath("spec", "move", "paths").Index(i), p, err.Error())) + } + } + + // Validate resources + for i, r := range opts.Resources { + if r.Name == "" { + list = append(list, field.Required(field.NewPath("spec", "move", "resources").Index(i).Child("name"), "resource name is required")) + } + if r.Kind == "" { + list = append(list, field.Required(field.NewPath("spec", "move", "resources").Index(i).Child("kind"), "resource kind is required")) + } + } + + return list +} diff --git a/apps/provisioning/pkg/jobs/validator_test.go b/apps/provisioning/pkg/jobs/validator_test.go new file mode 100644 index 00000000000..fdd29598ebc --- /dev/null +++ b/apps/provisioning/pkg/jobs/validator_test.go @@ -0,0 +1,593 @@ +package jobs + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestValidateJob(t *testing.T) { + tests := []struct { + name string + job *provisioning.Job + wantErr bool + validateError func(t *testing.T, err error) + }{ + { + name: "valid pull job", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Repository: "test-repo", + Pull: &provisioning.SyncJobOptions{ + Incremental: true, + }, + }, + }, + wantErr: false, + }, + { + name: "missing action", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.action: Required value") + }, + }, + { + name: "invalid action", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobAction("invalid"), + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.action: Invalid value") + }, + }, + { + name: "missing repository", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Pull: &provisioning.SyncJobOptions{ + Incremental: true, + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.repository: Required value") + }, + }, + { + name: "pull action without pull options", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.pull: Required value") + }, + }, + { + name: "push action without push options", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.push: Required value") + }, + }, + { + name: "valid push job with valid branch", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Repository: "test-repo", + Push: &provisioning.ExportJobOptions{ + Branch: "main", + Message: "Test commit", + }, + }, + }, + wantErr: false, + }, + { + name: "push job with invalid branch name", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Repository: "test-repo", + Push: &provisioning.ExportJobOptions{ + Branch: "feature..branch", // Invalid: contains consecutive dots + Message: "Test commit", + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.push.branch") + require.Contains(t, err.Error(), "invalid git branch name") + }, + }, + { + name: "push job with invalid path", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Repository: "test-repo", + Push: &provisioning.ExportJobOptions{ + Path: "../../../etc/passwd", // Invalid: path traversal + Message: "Test commit", + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.push.path") + }, + }, + { + name: "delete action without options", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.delete: Required value") + }, + }, + { + name: "delete action without paths or resources", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{}, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "at least one path or resource must be specified") + }, + }, + { + name: "valid delete action with paths", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"dashboard.json", "folder/other.json"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid delete action with resources", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "my-dashboard", + Kind: "Dashboard", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "delete action with invalid path", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"../../etc/passwd"}, // Invalid: path traversal + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.delete.paths[0]") + }, + }, + { + name: "delete action with resource missing name", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Kind: "Dashboard", + }, + }, + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.delete.resources[0].name") + }, + }, + { + name: "move action without options", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.move: Required value") + }, + }, + { + name: "move action without paths or resources", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + TargetPath: "new-location/", + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "at least one path or resource must be specified") + }, + }, + { + name: "move action without target path", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard.json"}, + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.move.targetPath: Required value") + }, + }, + { + name: "valid move action", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Paths: []string{"old-location/dashboard.json"}, + TargetPath: "new-location/", + }, + }, + }, + wantErr: false, + }, + { + name: "move action with invalid target path", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard.json"}, + TargetPath: "../../../etc/", // Invalid: path traversal + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.move.targetPath") + }, + }, + { + name: "valid migrate job", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMigrate, + Repository: "test-repo", + Migrate: &provisioning.MigrateJobOptions{ + History: true, + Message: "Migrate from legacy", + }, + }, + }, + wantErr: false, + }, + { + name: "migrate action without migrate options", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMigrate, + Repository: "test-repo", + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.migrate: Required value") + }, + }, + { + name: "valid pr job", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPullRequest, + Repository: "test-repo", + PullRequest: &provisioning.PullRequestJobOptions{ + PR: 123, + Ref: "refs/pull/123/head", + }, + }, + }, + wantErr: false, + }, + { + name: "delete action with resource missing kind", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "my-dashboard", + }, + }, + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.delete.resources[0].kind") + }, + }, + { + name: "move action with valid resources", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "my-dashboard", + Kind: "Dashboard", + }, + }, + TargetPath: "new-location/", + }, + }, + }, + wantErr: false, + }, + { + name: "move action with resource missing kind", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "my-dashboard", + }, + }, + TargetPath: "new-location/", + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.move.resources[0].kind") + }, + }, + { + name: "move action with both paths and resources", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard.json"}, + Resources: []provisioning.ResourceRef{ + { + Name: "my-dashboard", + Kind: "Dashboard", + }, + }, + TargetPath: "new-location/", + }, + }, + }, + wantErr: false, + }, + { + name: "move action with invalid source path", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Repository: "test-repo", + Move: &provisioning.MoveJobOptions{ + Paths: []string{"../invalid/path"}, + TargetPath: "valid/target/", + }, + }, + }, + wantErr: true, + validateError: func(t *testing.T, err error) { + require.Contains(t, err.Error(), "spec.move.paths[0]") + }, + }, + { + name: "delete action with both paths and resources", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Repository: "test-repo", + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"dashboard.json"}, + Resources: []provisioning.ResourceRef{ + { + Name: "my-dashboard", + Kind: "Dashboard", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "push action with valid path", + job: &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + }, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Repository: "test-repo", + Push: &provisioning.ExportJobOptions{ + Path: "some/valid/path", + Message: "Test commit", + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateJob(tt.job) + if tt.wantErr { + require.Error(t, err) + if tt.validateError != nil { + tt.validateError(t, err) + } + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 9899ac2a0da..13104e3e2f5 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -35,6 +35,7 @@ import ( clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" informers "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" + jobsvalidation "github.com/grafana/grafana/apps/provisioning/pkg/jobs" "github.com/grafana/grafana/apps/provisioning/pkg/loki" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -576,10 +577,10 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm return nil } - // FIXME: Do nothing for Jobs for now - _, ok = obj.(*provisioning.Job) + // Validate Jobs + job, ok := obj.(*provisioning.Job) if ok { - return nil + return jobsvalidation.ValidateJob(job) } repo, err := b.asRepository(ctx, obj, a.GetOldObject()) diff --git a/pkg/tests/apis/provisioning/job_validation_test.go b/pkg/tests/apis/provisioning/job_validation_test.go new file mode 100644 index 00000000000..c54ad58500c --- /dev/null +++ b/pkg/tests/apis/provisioning/job_validation_test.go @@ -0,0 +1,182 @@ +package provisioning + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_JobValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create a test repository first + const repo = "job-validation-test-repo" + testRepo := TestRepo{ + Name: repo, + Target: "instance", + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + tests := []struct { + name string + jobSpec map[string]interface{} + expectedErr string + }{ + { + name: "job without action", + jobSpec: map[string]interface{}{ + "repository": repo, + }, + expectedErr: "spec.action: Required value: action must be specified", + }, + { + name: "job with invalid action", + jobSpec: map[string]interface{}{ + "action": "invalid-action", + "repository": repo, + }, + expectedErr: "spec.action: Invalid value: \"invalid-action\": invalid action", + }, + { + name: "pull job without pull options", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionPull), + "repository": repo, + }, + expectedErr: "spec.pull: Required value: pull options required for pull action", + }, + { + name: "push job without push options", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionPush), + "repository": repo, + }, + expectedErr: "spec.push: Required value: push options required for push action", + }, + { + name: "push job with invalid branch name", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionPush), + "repository": repo, + "push": map[string]interface{}{ + "branch": "feature..branch", // Invalid: consecutive dots + "message": "Test commit", + }, + }, + expectedErr: "spec.push.branch: Invalid value: \"feature..branch\": invalid git branch name", + }, + { + name: "push job with path traversal", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionPush), + "repository": repo, + "push": map[string]interface{}{ + "path": "../../etc/passwd", // Invalid: path traversal + "message": "Test commit", + }, + }, + expectedErr: "spec.push.path: Invalid value: \"../../etc/passwd\"", + }, + { + name: "delete job without paths or resources", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionDelete), + "repository": repo, + "delete": map[string]interface{}{}, + }, + expectedErr: "spec.delete: Required value: at least one path or resource must be specified", + }, + { + name: "delete job with invalid path", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionDelete), + "repository": repo, + "delete": map[string]interface{}{ + "paths": []string{"../invalid/path"}, + }, + }, + expectedErr: "spec.delete.paths[0]: Invalid value: \"../invalid/path\"", + }, + { + name: "move job without target path", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionMove), + "repository": repo, + "move": map[string]interface{}{ + "paths": []string{"dashboard.json"}, + }, + }, + expectedErr: "spec.move.targetPath: Required value: target path is required", + }, + { + name: "move job without paths or resources", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionMove), + "repository": repo, + "move": map[string]interface{}{ + "targetPath": "new-location/", + }, + }, + expectedErr: "spec.move: Required value: at least one path or resource must be specified", + }, + { + name: "move job with invalid target path", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionMove), + "repository": repo, + "move": map[string]interface{}{ + "paths": []string{"dashboard.json"}, + "targetPath": "../../../etc/", // Invalid: path traversal + }, + }, + expectedErr: "spec.move.targetPath: Invalid value: \"../../../etc/\"", + }, + { + name: "migrate job without migrate options", + jobSpec: map[string]interface{}{ + "action": string(provisioning.JobActionMigrate), + "repository": repo, + }, + expectedErr: "spec.migrate: Required value: migrate options required for migrate action", + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the job object directly + jobObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("test-job-validation-%d", i), + "namespace": "default", + }, + "spec": tt.jobSpec, + }, + } + + // Try to create the job - should fail with validation error + _, err := helper.Jobs.Resource.Create(ctx, jobObj, metav1.CreateOptions{}) + require.Error(t, err, "expected validation error for invalid job spec") + + // Verify it's a validation error with correct status code + statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity) + require.Contains(t, statusError.Message, tt.expectedErr, "error message should contain expected validation message") + }) + } +} diff --git a/pkg/tests/apis/provisioning/movejob_test.go b/pkg/tests/apis/provisioning/movejob_test.go index d7a6c4e7f44..85d227996fd 100644 --- a/pkg/tests/apis/provisioning/movejob_test.go +++ b/pkg/tests/apis/provisioning/movejob_test.go @@ -171,7 +171,7 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) { }) t.Run("move without target path", func(t *testing.T) { - // Create move job without target path (should fail) + // Create move job without target path (should fail validation at creation time) spec := provisioning.JobSpec{ Action: provisioning.JobActionMove, Move: &provisioning.MoveJobOptions{ @@ -180,9 +180,20 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) { }, } - job := helper.TriggerJobAndWaitForComplete(t, repo, spec) - state := mustNestedString(job.Object, "status", "state") - assert.Equal(t, "error", state, "move job should have failed due to missing target path") + // The job should be rejected by the admission controller with validation error + body := asJSON(&spec) + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx) + + require.Error(t, result.Error(), "move job without target path should fail validation") + statusError := helper.RequireApiErrorStatus(result.Error(), metav1.StatusReasonInvalid, 422) + require.Contains(t, statusError.Message, "spec.move.targetPath", "error should mention missing target path") }) t.Run("move by resource reference", func(t *testing.T) { From 4fee8b34ad06d69805a1ee76a8a289d8f94e69a7 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 7 Nov 2025 11:33:13 -0500 Subject: [PATCH 099/209] Suggestions: Refactor getPanelDataSummary into its own method (#113251) * Suggestions: Refactor getPanelDataSummary into its own method * restore order * update some imports * update codeowners --- .github/CODEOWNERS | 2 +- packages/grafana-data/src/index.ts | 2 +- .../suggestions/getPanelDataSummary.test.ts | 94 +++++++++++++++++++ .../panel/suggestions/getPanelDataSummary.ts | 82 ++++++++++++++++ packages/grafana-data/src/types/panel.ts | 81 +--------------- .../panel/components/PanelDataErrorView.tsx | 11 +-- 6 files changed, 184 insertions(+), 88 deletions(-) create mode 100644 packages/grafana-data/src/panel/suggestions/getPanelDataSummary.test.ts create mode 100644 packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 462e7a39458..234789dfa4c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -254,7 +254,6 @@ /devenv/dev-dashboards/all-panels.json @grafana/dataviz-squad /devenv/dev-dashboards/dashboards.go @grafana/dataviz-squad /devenv/dev-dashboards/home.json @grafana/dataviz-squad - /devenv/dev-dashboards/datasource-elasticsearch/ @grafana/partner-datasources /devenv/dev-dashboards/datasource-opentsdb/ @grafana/partner-datasources /devenv/dev-dashboards/datasource-influxdb/ @grafana/partner-datasources @@ -550,6 +549,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-data/src/geo/ @grafana/dataviz-squad /packages/grafana-data/src/monaco/ @grafana/partner-datasources /packages/grafana-data/src/panel/ @grafana/dashboards-squad +/packages/grafana-data/src/panel/suggestions/ @grafana/dataviz-squad /packages/grafana-data/src/query/ @grafana/grafana-datasources-core-services /packages/grafana-data/src/rbac/ @grafana/access-squad /packages/grafana-data/src/table/ @grafana/dataviz-squad diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index e058612a3c7..a3ef43c91e8 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -435,6 +435,7 @@ export { isStandardFieldProp, type OptionDefaults, } from './panel/getPanelOptionsWithDefaults'; +export { type PanelDataSummary, getPanelDataSummary } from './panel/suggestions/getPanelDataSummary'; export { createFieldConfigRegistry } from './panel/registryFactories'; export { type QueryRunner, type QueryRunnerOptions } from './types/queryRunner'; export { type GroupingToMatrixTransformerOptions } from './transformations/transformers/groupingToMatrix'; @@ -651,7 +652,6 @@ export { type AngularPanelMenuItem, type PanelPluginDataSupport, type VisualizationSuggestion, - type PanelDataSummary, type VisualizationSuggestionsSupplier, VizOrientation, VisualizationSuggestionScore, diff --git a/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.test.ts b/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.test.ts new file mode 100644 index 00000000000..bef7a302757 --- /dev/null +++ b/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.test.ts @@ -0,0 +1,94 @@ +import { createDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; + +import { getPanelDataSummary } from './getPanelDataSummary'; + +describe('getPanelDataSummary', () => { + describe('when called with no dataframes', () => { + it('should return summary with zero counts', () => { + const summary = getPanelDataSummary(); + + expect(summary.rowCountTotal).toBe(0); + expect(summary.rowCountMax).toBe(0); + expect(summary.fieldCount).toBe(0); + expect(summary.frameCount).toBe(0); + expect(summary.hasData).toBe(false); + + expect(summary.fieldCountByType(FieldType.time)).toBe(0); + expect(summary.fieldCountByType(FieldType.number)).toBe(0); + expect(summary.fieldCountByType(FieldType.string)).toBe(0); + expect(summary.fieldCountByType(FieldType.boolean)).toBe(0); + + expect(summary.hasFieldType(FieldType.time)).toBe(false); + expect(summary.hasFieldType(FieldType.number)).toBe(false); + expect(summary.hasFieldType(FieldType.string)).toBe(false); + expect(summary.hasFieldType(FieldType.boolean)).toBe(false); + }); + }); + + describe('when called with a single dataframes', () => { + it('should return correct summary', () => { + const frames = [ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [10, 20, 30] }, + ], + }), + ]; + const summary = getPanelDataSummary(frames); + + expect(summary.rowCountTotal).toBe(3); + expect(summary.rowCountMax).toBe(3); + expect(summary.fieldCount).toBe(2); + expect(summary.frameCount).toBe(1); + expect(summary.hasData).toBe(true); + + expect(summary.fieldCountByType(FieldType.time)).toBe(1); + expect(summary.fieldCountByType(FieldType.number)).toBe(1); + expect(summary.fieldCountByType(FieldType.string)).toBe(0); + expect(summary.fieldCountByType(FieldType.boolean)).toBe(0); + + expect(summary.hasFieldType(FieldType.time)).toBe(true); + expect(summary.hasFieldType(FieldType.number)).toBe(true); + expect(summary.hasFieldType(FieldType.string)).toBe(false); + expect(summary.hasFieldType(FieldType.boolean)).toBe(false); + }); + }); + + describe('when called with multiple dataframes', () => { + it('should return correct summary', () => { + const frames = [ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [10, 20, 30] }, + ], + }), + createDataFrame({ + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'B'] }, + { name: 'amount', type: FieldType.number, values: [100, 200] }, + ], + }), + ]; + const summary = getPanelDataSummary(frames); + + expect(summary.rowCountTotal).toBe(5); + expect(summary.rowCountMax).toBe(3); + expect(summary.fieldCount).toBe(4); + expect(summary.frameCount).toBe(2); + expect(summary.hasData).toBe(true); + + expect(summary.fieldCountByType(FieldType.time)).toBe(1); + expect(summary.fieldCountByType(FieldType.number)).toBe(2); + expect(summary.fieldCountByType(FieldType.string)).toBe(1); + expect(summary.fieldCountByType(FieldType.boolean)).toBe(0); + + expect(summary.hasFieldType(FieldType.time)).toBe(true); + expect(summary.hasFieldType(FieldType.number)).toBe(true); + expect(summary.hasFieldType(FieldType.string)).toBe(true); + expect(summary.hasFieldType(FieldType.boolean)).toBe(false); + }); + }); +}); diff --git a/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts b/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts new file mode 100644 index 00000000000..ea97e1705d7 --- /dev/null +++ b/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts @@ -0,0 +1,82 @@ +import { PreferredVisualisationType } from '../../types/data'; +import { DataFrame, FieldType } from '../../types/dataFrame'; + +/** + * @alpha + */ +export interface PanelDataSummary { + hasData?: boolean; + rowCountTotal: number; + rowCountMax: number; + frameCount: number; + fieldCount: number; + fieldCountByType: (type: FieldType) => number; + hasFieldType: (type: FieldType) => boolean; + /** The first frame that set's this value */ + preferredVisualisationType?: PreferredVisualisationType; + + /* --- DEPRECATED FIELDS BELOW --- */ + /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */ + numberFieldCount: number; + /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.time) */ + timeFieldCount: number; + /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */ + stringFieldCount: number; + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */ + hasNumberField?: boolean; + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */ + hasTimeField?: boolean; + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */ + hasStringField?: boolean; +} + +/** + * @alpha + * given a list of dataframes, summarize attributes of those frames for features like suggestions. + * @param frames - dataframes to summarize + * @returns summary of the dataframes + */ +export function getPanelDataSummary(frames: DataFrame[] = []): PanelDataSummary { + let rowCountTotal = 0; + let rowCountMax = 0; + let fieldCount = 0; + const countByType: Partial> = {}; + let preferredVisualisationType: PreferredVisualisationType | undefined; + + for (const frame of frames) { + rowCountTotal += frame.length; + + if (frame.meta?.preferredVisualisationType) { + preferredVisualisationType = frame.meta.preferredVisualisationType; + } + + for (const field of frame.fields) { + fieldCount++; + countByType[field.type] = (countByType[field.type] || 0) + 1; + } + + if (frame.length > rowCountMax) { + rowCountMax = frame.length; + } + } + + const fieldCountByType = (f: FieldType) => countByType[f] ?? 0; + + return { + rowCountTotal, + rowCountMax, + fieldCount, + preferredVisualisationType, + frameCount: frames.length, + hasData: rowCountTotal > 0, + hasFieldType: (f: FieldType) => fieldCountByType(f) > 0, + fieldCountByType, + // deprecated + numberFieldCount: fieldCountByType(FieldType.number), + timeFieldCount: fieldCountByType(FieldType.time), + stringFieldCount: fieldCountByType(FieldType.string), + hasTimeField: fieldCountByType(FieldType.time) > 0, + hasNumberField: fieldCountByType(FieldType.number) > 0, + hasStringField: fieldCountByType(FieldType.string) > 0, + }; +} diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 91083493c83..426c52abadf 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -2,14 +2,15 @@ import { defaultsDeep } from 'lodash'; import { EventBus } from '../events/types'; import { StandardEditorProps } from '../field/standardFieldConfigEditorRegistry'; +import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary'; import { Registry } from '../utils/Registry'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; import { ScopedVars } from './ScopedVars'; import { AlertStateInfo } from './alerts'; import { PanelModel } from './dashboard'; -import { LoadingState, PreferredVisualisationType } from './data'; -import { DataFrame, FieldType } from './dataFrame'; +import { LoadingState } from './data'; +import { DataFrame } from './dataFrame'; import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; import { FieldConfigSource } from './fieldOverrides'; import { IconName } from './icon'; @@ -258,25 +259,6 @@ export enum VisualizationSuggestionScore { OK = 50, } -/** - * @alpha - */ -export interface PanelDataSummary { - hasData?: boolean; - rowCountTotal: number; - rowCountMax: number; - frameCount: number; - fieldCount: number; - numberFieldCount: number; - timeFieldCount: number; - stringFieldCount: number; - hasNumberField?: boolean; - hasTimeField?: boolean; - hasStringField?: boolean; - /** The first frame that set's this value */ - preferredVisualisationType?: PreferredVisualisationType; -} - /** * @alpha */ @@ -293,68 +275,13 @@ export class VisualizationSuggestionsBuilder { constructor(data?: PanelData, panel?: PanelModel) { this.data = data; this.panel = panel; - this.dataSummary = this.computeDataSummary(); + this.dataSummary = getPanelDataSummary(this.data?.series); } getListAppender(defaults: VisualizationSuggestion) { return new VisualizationSuggestionsListAppender(this.list, defaults); } - private computeDataSummary() { - const frames = this.data?.series || []; - - let numberFieldCount = 0; - let timeFieldCount = 0; - let stringFieldCount = 0; - let rowCountTotal = 0; - let rowCountMax = 0; - let fieldCount = 0; - let preferredVisualisationType: PreferredVisualisationType | undefined; - - for (const frame of frames) { - rowCountTotal += frame.length; - - if (frame.meta?.preferredVisualisationType) { - preferredVisualisationType = frame.meta.preferredVisualisationType; - } - - for (const field of frame.fields) { - fieldCount++; - - switch (field.type) { - case FieldType.number: - numberFieldCount += 1; - break; - case FieldType.time: - timeFieldCount += 1; - break; - case FieldType.string: - stringFieldCount += 1; - break; - } - } - - if (frame.length > rowCountMax) { - rowCountMax = frame.length; - } - } - - return { - numberFieldCount, - timeFieldCount, - stringFieldCount, - rowCountTotal, - rowCountMax, - fieldCount, - preferredVisualisationType, - frameCount: frames.length, - hasData: rowCountTotal > 0, - hasTimeField: timeFieldCount > 0, - hasNumberField: numberFieldCount > 0, - hasStringField: stringFieldCount > 0, - }; - } - getList() { return this.list; } diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index ba2ddcf7f8d..96b9299b3d8 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -1,12 +1,6 @@ import { css } from '@emotion/css'; -import { - CoreApp, - GrafanaTheme2, - PanelDataSummary, - VisualizationSuggestionsBuilder, - VisualizationSuggestion, -} from '@grafana/data'; +import { CoreApp, getPanelDataSummary, GrafanaTheme2, PanelDataSummary, VisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; @@ -27,8 +21,7 @@ import { changePanelPlugin } from '../state/actions'; export function PanelDataErrorView(props: PanelDataErrorViewProps) { const styles = useStyles2(getStyles); const context = usePanelContext(); - const builder = new VisualizationSuggestionsBuilder(props.data); - const { dataSummary } = builder; + const dataSummary = getPanelDataSummary(props.data.series); const message = getMessageFor(props, dataSummary); const dispatch = useDispatch(); From ecc9e9257e95f72ef8786b6a65c1754c41d87176 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 7 Nov 2025 11:34:11 -0500 Subject: [PATCH 100/209] E2E: Prevent issue where certain times can cause test failures (#110196) * E2E: Prevent issue where certain times can cause test failures * re-enable first test --- .../dashboard-time-zone.spec.ts | 101 +++++------------- 1 file changed, 24 insertions(+), 77 deletions(-) diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 271e2f42b1f..6d4b589b79c 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -17,7 +17,7 @@ test.describe( tag: ['@dashboards'], }, () => { - test.fixme('Tests dashboard time zone scenarios', async ({ page, gotoDashboardPage, selectors }) => { + test('Tests dashboard time zone scenarios', async ({ page, gotoDashboardPage, selectors }) => { const dashboardPage = await gotoDashboardPage({ uid: TIMEZONE_DASHBOARD_UID }); const fromTimeZone = 'UTC'; @@ -106,12 +106,18 @@ test.describe( zone: 'Browser', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + const relativeTimeRow = dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) + .locator('[role="row"]') + .filter({ hasText: '00:00:00' }) + .first(); + const timezoneRow = dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel in timezone')) + .locator('[role="row"]') + .filter({ hasText: '00:00:00' }) + .first(); + + await expect(relativeTimeRow).toBeVisible(); // Today so far, still in Browser timezone await setTimeRange(page, dashboardPage, selectors, { @@ -119,19 +125,8 @@ test.describe( to: 'now', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); - - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel in timezone')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); + await expect(timezoneRow).toBeVisible(); // Test UTC timezone await setTimeRange(page, dashboardPage, selectors, { @@ -140,12 +135,7 @@ test.describe( zone: 'Coordinated Universal Time', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); // Today so far, still in UTC timezone await setTimeRange(page, dashboardPage, selectors, { @@ -153,19 +143,8 @@ test.describe( to: 'now', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); - - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel in timezone')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); + await expect(timezoneRow).toBeVisible(); // Test Tokyo timezone await setTimeRange(page, dashboardPage, selectors, { @@ -174,12 +153,7 @@ test.describe( zone: 'Asia/Tokyo', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); // Today so far, still in Tokyo timezone await setTimeRange(page, dashboardPage, selectors, { @@ -187,19 +161,8 @@ test.describe( to: 'now', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); - - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel in timezone')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); + await expect(timezoneRow).toBeVisible(); // Test LA timezone await setTimeRange(page, dashboardPage, selectors, { @@ -208,12 +171,7 @@ test.describe( zone: 'America/Los Angeles', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); // Today so far, still in LA timezone await setTimeRange(page, dashboardPage, selectors, { @@ -221,19 +179,8 @@ test.describe( to: 'now', }); - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel with relative time override')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); - - await expect( - dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel in timezone')) - .locator('[role="row"]') - .filter({ hasText: '00:00:00' }) - ).toBeVisible(); + await expect(relativeTimeRow).toBeVisible(); + await expect(timezoneRow).toBeVisible(); }); } ); From 1e1adafeecdb54847c1310fee0b825eda1276d71 Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Fri, 7 Nov 2025 12:01:16 -0500 Subject: [PATCH 101/209] Alerting: Add admission hooks for rules app (#113429) This adds validating admission hooks to enforce the requirements on AlertRules and RecordingRules that are currently enforced through the provisioning service and storage mechanisms in preparation of a consistent validation in both legacy storage and unified storage. It also adds a mutating admission hook to the app to ensure that folder annotations and folder labels are kept in sync so we can perform label-selector lists. --- .../rules/definitions/alerting-manifest.yaml | 22 ++- apps/alerting/rules/go.mod | 2 +- apps/alerting/rules/kinds/alertRule.cue | 12 ++ apps/alerting/rules/kinds/recordingRule.cue | 12 ++ .../apis/alerting/v0alpha1/alertrule_ext.go | 77 +++++++- .../rules/pkg/apis/alerting/v0alpha1/ext.go | 29 +++ .../alerting/v0alpha1/recordingrule_ext.go | 46 ++++- .../rules/pkg/apis/alerting_manifest.go | 32 +++- .../rules/pkg/app/alertrule/mutator.go | 45 +++++ .../rules/pkg/app/alertrule/validator.go | 123 ++++++++++++ apps/alerting/rules/pkg/app/app.go | 35 +++- apps/alerting/rules/pkg/app/app_test.go | 175 ++++++++++++++++++ apps/alerting/rules/pkg/app/config/runtime.go | 22 +++ .../rules/pkg/app/recordingrule/mutator.go | 37 ++++ .../rules/pkg/app/recordingrule/validator.go | 95 ++++++++++ apps/alerting/rules/pkg/app/util/validator.go | 27 +++ .../apps/alerting/rules/alertrule/compat.go | 4 + .../alerting/rules/recordingrule/compat.go | 4 + .../rules/recordingrule/legacy_storage.go | 7 +- pkg/registry/apps/alerting/rules/register.go | 69 ++++++- .../rules/alertrule/alertrule_test.go | 147 ++++++++++++++- .../rules/recordingrule/recordingrule_test.go | 138 +++++++++++++- 22 files changed, 1144 insertions(+), 16 deletions(-) create mode 100644 apps/alerting/rules/pkg/app/alertrule/mutator.go create mode 100644 apps/alerting/rules/pkg/app/alertrule/validator.go create mode 100644 apps/alerting/rules/pkg/app/app_test.go create mode 100644 apps/alerting/rules/pkg/app/config/runtime.go create mode 100644 apps/alerting/rules/pkg/app/recordingrule/mutator.go create mode 100644 apps/alerting/rules/pkg/app/recordingrule/validator.go create mode 100644 apps/alerting/rules/pkg/app/util/validator.go diff --git a/apps/alerting/rules/definitions/alerting-manifest.yaml b/apps/alerting/rules/definitions/alerting-manifest.yaml index 836bd2c9a94..a8927dd1ea4 100644 --- a/apps/alerting/rules/definitions/alerting-manifest.yaml +++ b/apps/alerting/rules/definitions/alerting-manifest.yaml @@ -8,7 +8,16 @@ spec: preferredVersion: v0alpha1 versions: - kinds: - - conversion: false + - admission: + mutation: + operations: + - CREATE + - UPDATE + validation: + operations: + - CREATE + - UPDATE + conversion: false kind: AlertRule plural: AlertRules schemas: @@ -214,7 +223,16 @@ spec: - spec.panelRef.dashboardUID - spec.panelRef.panelID - spec.notificationSettings.receiver - - conversion: false + - admission: + mutation: + operations: + - CREATE + - UPDATE + validation: + operations: + - CREATE + - UPDATE + conversion: false kind: RecordingRule plural: RecordingRules schemas: diff --git a/apps/alerting/rules/go.mod b/apps/alerting/rules/go.mod index f10d66be03f..f1ccc575706 100644 --- a/apps/alerting/rules/go.mod +++ b/apps/alerting/rules/go.mod @@ -5,6 +5,7 @@ go 1.25.3 require ( github.com/grafana/grafana-app-sdk v0.48.1 github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/prometheus/common v0.67.1 k8s.io/apimachinery v0.34.1 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -49,7 +50,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/alerting/rules/kinds/alertRule.cue b/apps/alerting/rules/kinds/alertRule.cue index bbc374c244c..f3c7293cf76 100644 --- a/apps/alerting/rules/kinds/alertRule.cue +++ b/apps/alerting/rules/kinds/alertRule.cue @@ -13,6 +13,18 @@ alertRulev0alpha1: alertRuleKind & { schema: { spec: v0alpha1.AlertRuleSpec } + validation: { + operations: [ + "CREATE", + "UPDATE", + ] + } + mutation: { + operations: [ + "CREATE", + "UPDATE", + ] + } selectableFields: [ "spec.title", "spec.paused", diff --git a/apps/alerting/rules/kinds/recordingRule.cue b/apps/alerting/rules/kinds/recordingRule.cue index 3a6e14907ba..3a126afc0f2 100644 --- a/apps/alerting/rules/kinds/recordingRule.cue +++ b/apps/alerting/rules/kinds/recordingRule.cue @@ -13,6 +13,18 @@ recordingRulev0alpha1: recordingRuleKind & { schema: { spec: v0alpha1.RecordingRuleSpec } + validation: { + operations: [ + "CREATE", + "UPDATE", + ] + } + mutation: { + operations: [ + "CREATE", + "UPDATE", + ] + } selectableFields: [ "spec.title", "spec.paused", diff --git a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/alertrule_ext.go b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/alertrule_ext.go index da4b07b3df8..a705925fd5f 100644 --- a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/alertrule_ext.go +++ b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/alertrule_ext.go @@ -3,6 +3,7 @@ package v0alpha1 import ( "fmt" "slices" + "time" ) func (o *AlertRule) GetProvenanceStatus() string { @@ -48,4 +49,78 @@ func (s *AlertRuleSpec) ExecErrStateOrDefault() string { return s.ExecErrState } -// TODO: add duration clamping for the field types AlertRulePromDuration, AlertRulePromDurationWMillis, and the For and KeepFiringFor string pointers +func (d *AlertRulePromDuration) ToDuration() (time.Duration, error) { + return ToDuration(string(*d)) +} + +func (d *AlertRulePromDurationWMillis) ToDuration() (time.Duration, error) { + return ToDuration(string(*d)) +} + +func (d *AlertRulePromDuration) Clamp() error { + clampedDuration, err := ClampDuration(string(*d)) + if err != nil { + return err + } + *d = AlertRulePromDuration(clampedDuration) + return nil +} + +func (d *AlertRulePromDurationWMillis) Clamp() error { + clampedDuration, err := ClampDuration(string(*d)) + if err != nil { + return err + } + *d = AlertRulePromDurationWMillis(clampedDuration) + return nil +} + +func (spec *AlertRuleSpec) ClampDurations() error { + // clamp all duration fields + if err := spec.Trigger.Interval.Clamp(); err != nil { + return err + } + if spec.For != nil { + clamped, err := ClampDuration(*spec.For) + if err != nil { + return err + } + spec.For = &clamped + } + if spec.KeepFiringFor != nil { + clamped, err := ClampDuration(*spec.KeepFiringFor) + if err != nil { + return err + } + spec.KeepFiringFor = &clamped + } + if spec.NotificationSettings != nil { + if spec.NotificationSettings.GroupWait != nil { + if err := spec.NotificationSettings.GroupWait.Clamp(); err != nil { + return err + } + } + if spec.NotificationSettings.GroupInterval != nil { + if err := spec.NotificationSettings.GroupInterval.Clamp(); err != nil { + return err + } + } + if spec.NotificationSettings.RepeatInterval != nil { + if err := spec.NotificationSettings.RepeatInterval.Clamp(); err != nil { + return err + } + } + } + for k, expr := range spec.Expressions { + if expr.RelativeTimeRange != nil { + if err := expr.RelativeTimeRange.From.Clamp(); err != nil { + return err + } + if err := expr.RelativeTimeRange.To.Clamp(); err != nil { + return err + } + spec.Expressions[k] = expr + } + } + return nil +} diff --git a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go index 7b87f8ee8e9..73606736a3f 100644 --- a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go +++ b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go @@ -1,10 +1,22 @@ package v0alpha1 +import ( + "fmt" + "time" + + prom_model "github.com/prometheus/common/model" +) + const ( InternalPrefix = "grafana.com/" GroupLabelKey = InternalPrefix + "group" GroupIndexLabelKey = GroupLabelKey + "-index" ProvenanceStatusAnnotationKey = InternalPrefix + "provenance" + // Copy of the max title length used in legacy validation path + AlertRuleMaxTitleLength = 190 + // Annotation key used to store the folder UID on resources + FolderAnnotationKey = "grafana.app/folder" + FolderLabelKey = FolderAnnotationKey ) const ( @@ -15,3 +27,20 @@ const ( var ( AcceptedProvenanceStatuses = []string{ProvenanceStatusNone, ProvenanceStatusAPI} ) + +func ToDuration(s string) (time.Duration, error) { + promDuration, err := prom_model.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid duration format: %w", err) + } + return time.Duration(promDuration), nil +} + +// Convert the string duration to the longest valid Prometheus duration format (e.g., "60s" -> "1m") +func ClampDuration(s string) (string, error) { + promDuration, err := prom_model.ParseDuration(s) + if err != nil { + return "", fmt.Errorf("invalid duration format: %w", err) + } + return promDuration.String(), nil +} diff --git a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/recordingrule_ext.go b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/recordingrule_ext.go index 0d40fc4c7b6..50bd606ef39 100644 --- a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/recordingrule_ext.go +++ b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/recordingrule_ext.go @@ -3,6 +3,7 @@ package v0alpha1 import ( "fmt" "slices" + "time" ) func (o *RecordingRule) GetProvenanceStatus() string { @@ -27,4 +28,47 @@ func (o *RecordingRule) SetProvenanceStatus(status string) (err error) { return } -// TODO: add duration clamping for the field types RecordingRulePromDurationWMillis and RecordingRulePromDuration +func (d *RecordingRulePromDuration) ToDuration() (time.Duration, error) { + return ToDuration(string(*d)) +} + +func (d *RecordingRulePromDurationWMillis) ToDuration() (time.Duration, error) { + return ToDuration(string(*d)) +} + +func (d *RecordingRulePromDuration) Clamp() error { + clampedDuration, err := ClampDuration(string(*d)) + if err != nil { + return err + } + *d = RecordingRulePromDuration(clampedDuration) + return nil +} + +func (d *RecordingRulePromDurationWMillis) Clamp() error { + clampedDuration, err := ClampDuration(string(*d)) + if err != nil { + return err + } + *d = RecordingRulePromDurationWMillis(clampedDuration) + return nil +} + +func (spec *RecordingRuleSpec) ClampDurations() error { + // clamp all duration fields + if err := spec.Trigger.Interval.Clamp(); err != nil { + return err + } + for k, expr := range spec.Expressions { + if expr.RelativeTimeRange != nil { + if err := expr.RelativeTimeRange.From.Clamp(); err != nil { + return err + } + if err := expr.RelativeTimeRange.To.Clamp(); err != nil { + return err + } + spec.Expressions[k] = expr + } + } + return nil +} diff --git a/apps/alerting/rules/pkg/apis/alerting_manifest.go b/apps/alerting/rules/pkg/apis/alerting_manifest.go index 5057a58c6e6..f81fb7d43fc 100644 --- a/apps/alerting/rules/pkg/apis/alerting_manifest.go +++ b/apps/alerting/rules/pkg/apis/alerting_manifest.go @@ -42,7 +42,21 @@ var appManifestData = app.ManifestData{ Plural: "AlertRules", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaAlertRulev0alpha1, + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + Mutation: &app.MutationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, + Schema: &versionSchemaAlertRulev0alpha1, SelectableFields: []string{ "spec.title", "spec.paused", @@ -57,7 +71,21 @@ var appManifestData = app.ManifestData{ Plural: "RecordingRules", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaRecordingRulev0alpha1, + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + Mutation: &app.MutationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, + Schema: &versionSchemaRecordingRulev0alpha1, SelectableFields: []string{ "spec.title", "spec.paused", diff --git a/apps/alerting/rules/pkg/app/alertrule/mutator.go b/apps/alerting/rules/pkg/app/alertrule/mutator.go new file mode 100644 index 00000000000..a127e0699c9 --- /dev/null +++ b/apps/alerting/rules/pkg/app/alertrule/mutator.go @@ -0,0 +1,45 @@ +package alertrule + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/simple" + v1 "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" +) + +func NewMutator(cfg config.RuntimeConfig) *simple.Mutator { + return &simple.Mutator{ + MutateFunc: func(ctx context.Context, req *app.AdmissionRequest) (*app.MutatingResponse, error) { + // Mutate folder label to match folder UID from annotation + r, ok := req.Object.(*v1.AlertRule) + if !ok || r == nil { + // Nothing to do or wrong type; no mutation + return nil, nil + } + + // Read folder UID from annotation + folderUID := "" + if r.Annotations != nil { + folderUID = r.Annotations[v1.FolderAnnotationKey] + } + + // Ensure labels map exists and set the folder label if folderUID is present + if folderUID != "" { + if r.Labels == nil { + r.Labels = make(map[string]string) + } + // Maintain folder metadata label for downstream systems (alertmanager grouping etc.) + r.Labels[v1.FolderLabelKey] = folderUID + } + + // clamp all duration fields + if err := r.Spec.ClampDurations(); err != nil { + return nil, err + } + + return &app.MutatingResponse{UpdatedObject: r}, nil + }, + } +} diff --git a/apps/alerting/rules/pkg/app/alertrule/validator.go b/apps/alerting/rules/pkg/app/alertrule/validator.go new file mode 100644 index 00000000000..e86d17cab48 --- /dev/null +++ b/apps/alerting/rules/pkg/app/alertrule/validator.go @@ -0,0 +1,123 @@ +package alertrule + +import ( + "context" + "fmt" + "slices" + "strconv" + "time" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "github.com/grafana/grafana-app-sdk/simple" + model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/util" + prom_model "github.com/prometheus/common/model" +) + +func NewValidator(cfg config.RuntimeConfig) *simple.Validator { + return &simple.Validator{ + ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error { + // Cast to specific type + r, ok := req.Object.(*model.AlertRule) + if !ok { + return fmt.Errorf("object is not of type *v0alpha1.AlertRule") + } + + // 1) Validate provenance status annotation + sourceProv := r.GetProvenanceStatus() + if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) { + return fmt.Errorf("invalid provenance status: %s", sourceProv) + } + + // 2) Validate group labels rules + group := r.Labels[model.GroupLabelKey] + groupIndexStr := r.Labels[model.GroupIndexLabelKey] + if req.Action == resource.AdmissionActionCreate { + if group != "" || groupIndexStr != "" { + return fmt.Errorf("cannot set group when creating alert rule") + } + } + if group != "" { // if group is set, group-index must be set and numeric + if groupIndexStr == "" { + return fmt.Errorf("%s must be set when %s is set", model.GroupIndexLabelKey, model.GroupLabelKey) + } + if _, err := strconv.Atoi(groupIndexStr); err != nil { + return fmt.Errorf("invalid %s: %w", model.GroupIndexLabelKey, err) + } + } + + // 3) Validate folder is set and exists + // Read folder UID directly from annotations + folderUID := "" + if r.Annotations != nil { + folderUID = r.Annotations[model.FolderAnnotationKey] + } + if folderUID == "" { + return fmt.Errorf("folder is required") + } + if cfg.FolderValidator != nil { + ok, verr := cfg.FolderValidator(ctx, folderUID) + if verr != nil { + return fmt.Errorf("failed to validate folder: %w", verr) + } + if !ok { + return fmt.Errorf("folder does not exist: %s", folderUID) + } + } + + // 4) Validate notification settings receiver if provided + if r.Spec.NotificationSettings != nil && r.Spec.NotificationSettings.Receiver != "" && cfg.NotificationSettingsValidator != nil { + ok, nerr := cfg.NotificationSettingsValidator(ctx, r.Spec.NotificationSettings.Receiver) + if nerr != nil { + return fmt.Errorf("failed to validate notification settings: %w", nerr) + } + if !ok { + return fmt.Errorf("invalid notification receiver: %s", r.Spec.NotificationSettings.Receiver) + } + } + + // 5) Enforce max title length + if len(r.Spec.Title) > model.AlertRuleMaxTitleLength { + return fmt.Errorf("alert rule title is too long. Max length is %d", model.AlertRuleMaxTitleLength) + } + + // 6) Validate evaluation interval against base interval + if err := util.ValidateInterval(cfg.BaseEvaluationInterval, &r.Spec.Trigger.Interval); err != nil { + return err + } + + // 7) Disallow reserved/spec system label keys + if r.Spec.Labels != nil { + for key := range r.Spec.Labels { + if _, bad := cfg.ReservedLabelKeys[key]; bad { + return fmt.Errorf("label key is reserved and cannot be specified: %s", key) + } + } + } + + // 8) For and KeepFiringFor must be >= 0 if set + if r.Spec.For != nil { + d, err := prom_model.ParseDuration(*r.Spec.For) + if err != nil { + return fmt.Errorf("invalid 'for' duration: %w", err) + } + if time.Duration(d) < 0 { + return fmt.Errorf("'for' cannot be less than 0") + } + } + if r.Spec.KeepFiringFor != nil { + d, err := prom_model.ParseDuration(*r.Spec.KeepFiringFor) + if err != nil { + return fmt.Errorf("invalid 'keepFiringFor' duration: %w", err) + } + if time.Duration(d) < 0 { + return fmt.Errorf("'keepFiringFor' cannot be less than 0") + } + } + + return nil + }, + } +} diff --git a/apps/alerting/rules/pkg/app/app.go b/apps/alerting/rules/pkg/app/app.go index ef0ab1883cf..3c546239c81 100644 --- a/apps/alerting/rules/pkg/app/app.go +++ b/apps/alerting/rules/pkg/app/app.go @@ -6,16 +6,29 @@ import ( "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-app-sdk/operator" + "github.com/grafana/grafana-app-sdk/resource" "github.com/grafana/grafana-app-sdk/simple" "github.com/grafana/grafana/apps/alerting/rules/pkg/apis" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/alertrule" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/recordingrule" ) func New(cfg app.Config) (app.App, error) { managedKinds := make([]simple.AppManagedKind, 0) + runtimeCfg, ok := cfg.SpecificConfig.(config.RuntimeConfig) + if !ok { + return nil, config.ErrInvalidRuntimeConfig + } for _, kinds := range apis.GetKinds() { for _, kind := range kinds { - managedKinds = append(managedKinds, simple.AppManagedKind{Kind: kind}) + managedKind := simple.AppManagedKind{ + Kind: kind, + Validator: buildKindValidator(kind, runtimeCfg), + Mutator: buildKindMutator(kind, runtimeCfg), + } + managedKinds = append(managedKinds, managedKind) } } @@ -44,3 +57,23 @@ func New(cfg app.Config) (app.App, error) { return a, nil } + +func buildKindValidator(kind resource.Kind, cfg config.RuntimeConfig) *simple.Validator { + switch kind.Kind() { + case "AlertRule": + return alertrule.NewValidator(cfg) + case "RecordingRule": + return recordingrule.NewValidator(cfg) + } + return nil +} + +func buildKindMutator(kind resource.Kind, cfg config.RuntimeConfig) *simple.Mutator { + switch kind.Kind() { + case "AlertRule": + return alertrule.NewMutator(cfg) + case "RecordingRule": + return recordingrule.NewMutator(cfg) + } + return nil +} diff --git a/apps/alerting/rules/pkg/app/app_test.go b/apps/alerting/rules/pkg/app/app_test.go new file mode 100644 index 00000000000..405e397f8cd --- /dev/null +++ b/apps/alerting/rules/pkg/app/app_test.go @@ -0,0 +1,175 @@ +package app_test + +import ( + "context" + "testing" + "time" + + appsdk "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + + v1 "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/alertrule" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/recordingrule" +) + +func makeDefaultRuntimeConfig() config.RuntimeConfig { + return config.RuntimeConfig{ + FolderValidator: func(ctx context.Context, folderUID string) (bool, error) { return folderUID == "f1", nil }, + BaseEvaluationInterval: 60 * time.Second, // seconds + ReservedLabelKeys: map[string]struct{}{"__reserved__": {}, "grafana_folder": {}}, + NotificationSettingsValidator: func(ctx context.Context, receiver string) (bool, error) { return receiver == "notif-ok", nil }, + } +} + +func TestAlertRuleValidation_Success(t *testing.T) { + r := &v1.AlertRule{} + r.SetGroupVersionKind(v1.AlertRuleKind().GroupVersionKind()) + r.Name = "uid-1" + r.Namespace = "ns1" + r.Annotations = map[string]string{v1.FolderAnnotationKey: "f1"} + r.Labels = map[string]string{} + r.Spec = v1.AlertRuleSpec{ + Title: "ok", + Trigger: v1.AlertRuleIntervalTrigger{Interval: v1.AlertRulePromDuration("60s")}, + Expressions: v1.AlertRuleExpressionMap{"A": v1.AlertRuleExpression{Model: map[string]any{"expr": "1"}, Source: boolPtr(true)}}, + NoDataState: v1.DefaultNoDataState, + ExecErrState: v1.DefaultExecErrState, + NotificationSettings: &v1.AlertRuleV0alpha1SpecNotificationSettings{Receiver: "notif-ok"}, + } + + req := &appsdk.AdmissionRequest{Action: resource.AdmissionActionCreate, Object: r} + validator := alertrule.NewValidator(makeDefaultRuntimeConfig()) + if err := validator.Validate(context.Background(), req); err != nil { + t.Fatalf("expected success, got error: %v", err) + } +} + +func TestAlertRuleValidation_Errors(t *testing.T) { + mk := func(mut func(r *v1.AlertRule)) error { + r := baseAlertRule() + mut(r) + return alertrule.NewValidator(makeDefaultRuntimeConfig()).Validate(context.Background(), &appsdk.AdmissionRequest{Action: resource.AdmissionActionCreate, Object: r}) + } + + if err := mk(func(r *v1.AlertRule) { r.Annotations = nil }); err == nil { + t.Errorf("want folder required error") + } + if err := mk(func(r *v1.AlertRule) { r.Annotations[v1.FolderAnnotationKey] = "bad" }); err == nil { + t.Errorf("want folder not exist error") + } + if err := mk(func(r *v1.AlertRule) { r.Spec.Trigger.Interval = v1.AlertRulePromDuration("30s") }); err == nil { + t.Errorf("want base interval multiple error") + } + if err := mk(func(r *v1.AlertRule) { + r.Spec.NotificationSettings = &v1.AlertRuleV0alpha1SpecNotificationSettings{Receiver: "bad"} + }); err == nil { + t.Errorf("want invalid receiver error") + } + if err := mk(func(r *v1.AlertRule) { r.Labels[v1.GroupLabelKey] = "grp" }); err == nil { + t.Errorf("want group set on create error") + } + if err := mk(func(r *v1.AlertRule) { r.Spec.For = strPtr("-10s") }); err == nil { + t.Errorf("want for>=0 error") + } + if err := mk(func(r *v1.AlertRule) { + if r.Spec.Labels == nil { + r.Spec.Labels = map[string]v1.AlertRuleTemplateString{} + } + r.Spec.Labels["__reserved__"] = v1.AlertRuleTemplateString("x") + }); err == nil { + t.Errorf("want reserved label key error") + } +} + +func baseAlertRule() *v1.AlertRule { + r := &v1.AlertRule{} + r.SetGroupVersionKind(v1.AlertRuleKind().GroupVersionKind()) + r.Name = "uid-1" + r.Namespace = "ns1" + r.Annotations = map[string]string{v1.FolderAnnotationKey: "f1"} + r.Labels = map[string]string{} + r.Spec = v1.AlertRuleSpec{ + Title: "ok", + Trigger: v1.AlertRuleIntervalTrigger{Interval: v1.AlertRulePromDuration("60s")}, + Expressions: v1.AlertRuleExpressionMap{"A": v1.AlertRuleExpression{Model: map[string]any{"expr": "1"}, Source: boolPtr(true)}}, + NoDataState: v1.DefaultNoDataState, + ExecErrState: v1.DefaultExecErrState, + } + return r +} + +func TestRecordingRuleValidation_Success(t *testing.T) { + r := &v1.RecordingRule{} + r.SetGroupVersionKind(v1.RecordingRuleKind().GroupVersionKind()) + r.Name = "uid-2" + r.Namespace = "ns1" + r.Annotations = map[string]string{v1.FolderAnnotationKey: "f1"} + r.Labels = map[string]string{} + r.Spec = v1.RecordingRuleSpec{ + Title: "ok", + Trigger: v1.RecordingRuleIntervalTrigger{Interval: v1.RecordingRulePromDuration("60s")}, + Expressions: v1.RecordingRuleExpressionMap{"A": v1.RecordingRuleExpression{Model: map[string]any{"expr": "1"}, Source: boolPtr(true)}}, + Metric: "test_metric", + TargetDatasourceUID: "ds1", + } + + req := &appsdk.AdmissionRequest{Action: resource.AdmissionActionCreate, Object: r} + validator := recordingrule.NewValidator(makeDefaultRuntimeConfig()) + if err := validator.Validate(context.Background(), req); err != nil { + t.Fatalf("expected success, got error: %v", err) + } +} + +func TestRecordingRuleValidation_Errors(t *testing.T) { + mk := func(mut func(r *v1.RecordingRule)) error { + r := baseRecordingRule() + mut(r) + return recordingrule.NewValidator(makeDefaultRuntimeConfig()).Validate(context.Background(), &appsdk.AdmissionRequest{Action: resource.AdmissionActionCreate, Object: r}) + } + + if err := mk(func(r *v1.RecordingRule) { r.Annotations = nil }); err == nil { + t.Errorf("want folder required error") + } + if err := mk(func(r *v1.RecordingRule) { r.Annotations[v1.FolderAnnotationKey] = "bad" }); err == nil { + t.Errorf("want folder not exist error") + } + if err := mk(func(r *v1.RecordingRule) { r.Spec.Trigger.Interval = v1.RecordingRulePromDuration("30s") }); err == nil { + t.Errorf("want base interval multiple error") + } + if err := mk(func(r *v1.RecordingRule) { r.Labels[v1.GroupLabelKey] = "grp" }); err == nil { + t.Errorf("want group set on create error") + } + if err := mk(func(r *v1.RecordingRule) { r.Spec.Metric = "" }); err == nil { + t.Errorf("want metric required error") + } + if err := mk(func(r *v1.RecordingRule) { + if r.Spec.Labels == nil { + r.Spec.Labels = map[string]v1.RecordingRuleTemplateString{} + } + r.Spec.Labels["__reserved__"] = v1.RecordingRuleTemplateString("x") + }); err == nil { + t.Errorf("want reserved label key error") + } +} + +func baseRecordingRule() *v1.RecordingRule { + r := &v1.RecordingRule{} + r.SetGroupVersionKind(v1.RecordingRuleKind().GroupVersionKind()) + r.Name = "uid-1" + r.Namespace = "ns1" + r.Annotations = map[string]string{v1.FolderAnnotationKey: "f1"} + r.Labels = map[string]string{} + r.Spec = v1.RecordingRuleSpec{ + Title: "ok", + Trigger: v1.RecordingRuleIntervalTrigger{Interval: v1.RecordingRulePromDuration("60s")}, + Expressions: v1.RecordingRuleExpressionMap{"A": v1.RecordingRuleExpression{Model: map[string]any{"expr": "1"}, Source: boolPtr(true)}}, + Metric: "test_metric", + TargetDatasourceUID: "ds1", + } + return r +} + +func boolPtr(b bool) *bool { return &b } +func strPtr(s string) *string { return &s } diff --git a/apps/alerting/rules/pkg/app/config/runtime.go b/apps/alerting/rules/pkg/app/config/runtime.go new file mode 100644 index 00000000000..60936925a15 --- /dev/null +++ b/apps/alerting/rules/pkg/app/config/runtime.go @@ -0,0 +1,22 @@ +package config + +import ( + "context" + "errors" + "time" +) + +var ( + ErrInvalidRuntimeConfig = errors.New("invalid runtime config provided to alerting/rules app") +) + +// RuntimeConfig holds configuration values needed at runtime by the alerting/rules app from the running Grafana instance. +type RuntimeConfig struct { + // function to check folder existence given its uid + FolderValidator func(ctx context.Context, folderUID string) (bool, error) + // base evaluation interval + BaseEvaluationInterval time.Duration + // set of strings which are illegal for label keys on rules + ReservedLabelKeys map[string]struct{} + NotificationSettingsValidator func(ctx context.Context, receiver string) (bool, error) +} diff --git a/apps/alerting/rules/pkg/app/recordingrule/mutator.go b/apps/alerting/rules/pkg/app/recordingrule/mutator.go new file mode 100644 index 00000000000..31378c553f0 --- /dev/null +++ b/apps/alerting/rules/pkg/app/recordingrule/mutator.go @@ -0,0 +1,37 @@ +package recordingrule + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/simple" + v1 "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" +) + +func NewMutator(cfg config.RuntimeConfig) *simple.Mutator { + return &simple.Mutator{ + MutateFunc: func(ctx context.Context, req *app.AdmissionRequest) (*app.MutatingResponse, error) { + r, ok := req.Object.(*v1.RecordingRule) + if !ok || r == nil { + return nil, nil + } + + folderUID := "" + if r.Annotations != nil { + folderUID = r.Annotations[v1.FolderAnnotationKey] + } + + if folderUID != "" { + if r.Labels == nil { + r.Labels = make(map[string]string) + } + r.Labels[v1.FolderLabelKey] = folderUID + } + if err := r.Spec.ClampDurations(); err != nil { + return nil, err + } + return &app.MutatingResponse{UpdatedObject: r}, nil + }, + } +} diff --git a/apps/alerting/rules/pkg/app/recordingrule/validator.go b/apps/alerting/rules/pkg/app/recordingrule/validator.go new file mode 100644 index 00000000000..8354b9d7501 --- /dev/null +++ b/apps/alerting/rules/pkg/app/recordingrule/validator.go @@ -0,0 +1,95 @@ +package recordingrule + +import ( + "context" + "fmt" + "slices" + "strconv" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "github.com/grafana/grafana-app-sdk/simple" + model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" + "github.com/grafana/grafana/apps/alerting/rules/pkg/app/util" + prom_model "github.com/prometheus/common/model" +) + +func NewValidator(cfg config.RuntimeConfig) *simple.Validator { + return &simple.Validator{ + ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error { + // Cast to specific type + r, ok := req.Object.(*model.RecordingRule) + if !ok { + return fmt.Errorf("object is not of type *v0alpha1.RecordingRule") + } + + sourceProv := r.GetProvenanceStatus() + if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) { + return fmt.Errorf("invalid provenance status: %s", sourceProv) + } + + group := r.Labels[model.GroupLabelKey] + groupIndexStr := r.Labels[model.GroupIndexLabelKey] + if req.Action == resource.AdmissionActionCreate { + if group != "" || groupIndexStr != "" { + return fmt.Errorf("cannot set group when creating recording rule") + } + } + if group != "" { + if groupIndexStr == "" { + return fmt.Errorf("%s must be set when %s is set", model.GroupIndexLabelKey, model.GroupLabelKey) + } + if _, err := strconv.Atoi(groupIndexStr); err != nil { + return fmt.Errorf("invalid %s: %w", model.GroupIndexLabelKey, err) + } + } + + folderUID := "" + if r.Annotations != nil { + folderUID = r.Annotations[model.FolderAnnotationKey] + } + if folderUID == "" { + return fmt.Errorf("folder is required") + } + if cfg.FolderValidator != nil { + ok, verr := cfg.FolderValidator(ctx, folderUID) + if verr != nil { + return fmt.Errorf("failed to validate folder: %w", verr) + } + if !ok { + return fmt.Errorf("folder does not exist: %s", folderUID) + } + } + + if len(r.Spec.Title) > model.AlertRuleMaxTitleLength { + return fmt.Errorf("recording rule title is too long. Max length is %d", model.AlertRuleMaxTitleLength) + } + + if err := util.ValidateInterval(cfg.BaseEvaluationInterval, &r.Spec.Trigger.Interval); err != nil { + return err + } + + if r.Spec.Labels != nil { + for key := range r.Spec.Labels { + if _, bad := cfg.ReservedLabelKeys[key]; bad { + return fmt.Errorf("label key is reserved and cannot be specified: %s", key) + } + } + } + + if r.Spec.Metric == "" { + return fmt.Errorf("metric must be specified") + } + metric := prom_model.LabelValue(r.Spec.Metric) + if !metric.IsValid() { + return fmt.Errorf("metric contains invalid characters") + } + if !prom_model.IsValidMetricName(metric) { // nolint:staticcheck + return fmt.Errorf("invalid metric name") + } + + return nil + }, + } +} diff --git a/apps/alerting/rules/pkg/app/util/validator.go b/apps/alerting/rules/pkg/app/util/validator.go new file mode 100644 index 00000000000..5f09c61e1cc --- /dev/null +++ b/apps/alerting/rules/pkg/app/util/validator.go @@ -0,0 +1,27 @@ +package util + +import ( + "fmt" + "time" +) + +type DurationLike interface { + ToDuration() (time.Duration, error) +} + +func ValidateInterval(baseInterval time.Duration, d DurationLike) error { + interval, err := d.ToDuration() + if err != nil { + return fmt.Errorf("invalid trigger interval: %w", err) + } + // Ensure interval is positive and an integer multiple of BaseEvaluationInterval (if provided) + if interval <= 0 { + return fmt.Errorf("trigger interval must be greater than 0") + } + if baseInterval > 0 { + if (interval % baseInterval) != 0 { + return fmt.Errorf("trigger interval must be a multiple of base evaluation interval (%s)", baseInterval.String()) + } + } + return nil +} diff --git a/pkg/registry/apps/alerting/rules/alertrule/compat.go b/pkg/registry/apps/alerting/rules/alertrule/compat.go index 0ea9507c6e0..3b780f0c1d7 100644 --- a/pkg/registry/apps/alerting/rules/alertrule/compat.go +++ b/pkg/registry/apps/alerting/rules/alertrule/compat.go @@ -128,6 +128,10 @@ func convertToK8sResource( return nil, fmt.Errorf("failed to get metadata: %w", err) } meta.SetFolder(rule.NamespaceUID) + // Keep metadata label in sync with folder annotation for downstream consumers + if rule.NamespaceUID != "" { + k8sRule.Labels[model.FolderLabelKey] = rule.NamespaceUID + } if rule.UpdatedBy != nil { meta.SetUpdatedBy(string(*rule.UpdatedBy)) k8sRule.SetUpdatedBy(string(*rule.UpdatedBy)) diff --git a/pkg/registry/apps/alerting/rules/recordingrule/compat.go b/pkg/registry/apps/alerting/rules/recordingrule/compat.go index e9ef0c4e295..8626a0b7e91 100644 --- a/pkg/registry/apps/alerting/rules/recordingrule/compat.go +++ b/pkg/registry/apps/alerting/rules/recordingrule/compat.go @@ -76,6 +76,10 @@ func convertToK8sResource( return nil, fmt.Errorf("failed to get metadata: %w", err) } meta.SetFolder(rule.NamespaceUID) + // Keep metadata label in sync with folder annotation for downstream consumers + if rule.NamespaceUID != "" { + k8sRule.Labels[model.FolderLabelKey] = rule.NamespaceUID + } if rule.UpdatedBy != nil { meta.SetUpdatedBy(string(*rule.UpdatedBy)) k8sRule.SetUpdatedBy(string(*rule.UpdatedBy)) diff --git a/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go b/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go index 395b955e0d0..df1b57db523 100644 --- a/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go +++ b/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go @@ -104,7 +104,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptio return obj, err } -func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { +func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { info, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err @@ -114,6 +114,11 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.V if err != nil { return nil, err } + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, err + } + } p, ok := obj.(*model.RecordingRule) if !ok { diff --git a/pkg/registry/apps/alerting/rules/register.go b/pkg/registry/apps/alerting/rules/register.go index 85b602ea832..d406495ac0e 100644 --- a/pkg/registry/apps/alerting/rules/register.go +++ b/pkg/registry/apps/alerting/rules/register.go @@ -14,13 +14,17 @@ import ( "github.com/grafana/grafana/apps/alerting/rules/pkg/apis" rulesApp "github.com/grafana/grafana/apps/alerting/rules/pkg/app" + rulesAppConfig "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config" + "github.com/grafana/grafana/pkg/apimachinery/identity" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules/alertrule" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules/recordingrule" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + reqns "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/ngalert" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/setting" ) @@ -50,11 +54,66 @@ func RegisterAppInstaller( ng: ng, } - provider := simple.NewAppProvider(apis.LocalManifest(), nil, rulesApp.New) + appSpecificConfig := rulesAppConfig.RuntimeConfig{ + // Validate folder existence using the folder service + FolderValidator: func(ctx context.Context, folderUID string) (bool, error) { + if folderUID == "" { + return false, nil + } + orgID, err := reqns.OrgIDForList(ctx) + user, _ := identity.GetRequester(ctx) + if (err != nil || orgID < 1) && user != nil { + orgID = user.GetOrgID() + } + if user == nil || orgID < 1 { + // If we can't resolve identity/org in this context, don't block creation based on existence + return true, nil + } + // Use the RuleStore to check namespace (folder) visibility + _, err = ng.Api.RuleStore.GetNamespaceByUID(ctx, folderUID, orgID, user) + if err != nil { + return false, nil + } + return true, nil + }, + BaseEvaluationInterval: ng.Cfg.UnifiedAlerting.BaseInterval, + ReservedLabelKeys: ngmodels.LabelsUserCannotSpecify, + // Validate that the configured notification receiver exists in the Alertmanager config + NotificationSettingsValidator: func(ctx context.Context, receiver string) (bool, error) { + if receiver == "" { + return false, nil + } + orgID, err := reqns.OrgIDForList(ctx) + if err != nil || orgID < 1 { + if user, _ := identity.GetRequester(ctx); user != nil { + orgID = user.GetOrgID() + } + } + if orgID < 1 { + // Without org context, skip validation rather than block + return true, nil + } + provider := notifier.NewCachedNotificationSettingsValidationService(ng.Api.AlertingStore) + vd, err := provider.Validator(ctx, orgID) + if err != nil { + log.New("alerting.rules.app").Error("failed to create notification settings validator", "error", err) + // If we cannot build a validator, don't block admission + return true, nil + } + // Only validate receiver presence; construct minimal settings + if err := vd.Validate(ngmodels.NotificationSettings{Receiver: receiver}); err != nil { + return false, nil + } + return true, nil + }, + } + + provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, rulesApp.New) appConfig := app.Config{ - KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method - ManifestData: *apis.LocalManifest().ManifestData, + KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method + ManifestData: *apis.LocalManifest().ManifestData, + SpecificConfig: appSpecificConfig, } i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{}) @@ -81,7 +140,7 @@ func (a *AlertingRulesAppInstaller) GetAuthorizer() authorizer.Authorizer { } func (a *AlertingRulesAppInstaller) GetLegacyStorage(gvr schema.GroupVersionResource) grafanarest.Storage { - namespacer := request.GetNamespaceMapper(a.cfg) + namespacer := reqns.GetNamespaceMapper(a.cfg) switch gvr { case recordingrule.ResourceInfo.GroupVersionResource(): return recordingrule.NewStorage(*a.ng.Api.AlertRules, namespacer) diff --git a/pkg/tests/apis/alerting/rules/alertrule/alertrule_test.go b/pkg/tests/apis/alerting/rules/alertrule/alertrule_test.go index ef44d030360..20b19346a3d 100644 --- a/pkg/tests/apis/alerting/rules/alertrule/alertrule_test.go +++ b/pkg/tests/apis/alerting/rules/alertrule/alertrule_test.go @@ -461,7 +461,7 @@ func TestIntegrationCRUD(t *testing.T) { } created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{}) - require.ErrorContains(t, err, "invalid alert rule") + require.ErrorContains(t, err, "trigger interval must be a multiple of base evaluation interval") require.Nil(t, created) }) } @@ -564,3 +564,148 @@ func TestIntegrationBasicAPI(t *testing.T) { t.Logf("Got error: %s", err) }) } + +func TestIntegrationFolderLabelSyncAndValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := common.GetTestHelper(t) + client := common.NewAlertRuleClient(t, helper.Org1.Admin) + + // Prepare two folders for label sync update scenario + common.CreateTestFolder(t, helper, "test-folder-a") + common.CreateTestFolder(t, helper, "test-folder-b") + + baseGen := ngmodels.RuleGen.With( + ngmodels.RuleMuts.WithUniqueUID(), + ngmodels.RuleMuts.WithUniqueTitle(), + ngmodels.RuleMuts.WithNamespaceUID("test-folder-a"), + ngmodels.RuleMuts.WithGroupName("test-group"), + ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second), + ) + + t.Run("should keep folder label in sync with folder annotation on create and update", func(t *testing.T) { + rule := baseGen.Generate() + + alertRule := &v0alpha1.AlertRule{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{ + v0alpha1.FolderAnnotationKey: "test-folder-a", + }, + }, + Spec: v0alpha1.AlertRuleSpec{ + Title: rule.Title, + Expressions: v0alpha1.AlertRuleExpressionMap{ + "A": { + QueryType: util.Pointer(rule.Data[0].QueryType), + DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)), + Model: rule.Data[0].Model, + Source: util.Pointer(true), + RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{ + From: v0alpha1.AlertRulePromDurationWMillis("5m"), + To: v0alpha1.AlertRulePromDurationWMillis("0s"), + }, + }, + }, + Trigger: v0alpha1.AlertRuleIntervalTrigger{ + Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)), + }, + NoDataState: string(rule.NoDataState), + ExecErrState: string(rule.ExecErrState), + }, + } + + created, err := client.Create(ctx, alertRule, v1.CreateOptions{}) + require.NoError(t, err) + defer func() { _ = client.Delete(ctx, created.Name, v1.DeleteOptions{}) }() + + // On create, metadata.labels[v0alpha1.FolderLabelKey] should mirror annotation + require.Equal(t, "test-folder-a", created.Labels[v0alpha1.FolderLabelKey]) + + // Update annotation to point to a different folder and ensure label follows + updated := created.Copy().(*v0alpha1.AlertRule) + if updated.Annotations == nil { + updated.Annotations = map[string]string{} + } + updated.Annotations[v0alpha1.FolderAnnotationKey] = "test-folder-b" + + after, err := client.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.Equal(t, "test-folder-b", after.Annotations[v0alpha1.FolderAnnotationKey]) + require.Equal(t, "test-folder-b", after.Labels[v0alpha1.FolderLabelKey]) + }) + + t.Run("should fail to create rule without folder annotation", func(t *testing.T) { + rule := baseGen.Generate() + + alertRule := &v0alpha1.AlertRule{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{}, // missing grafana.app/folder + }, + Spec: v0alpha1.AlertRuleSpec{ + Title: rule.Title, + Expressions: v0alpha1.AlertRuleExpressionMap{ + "A": { + QueryType: util.Pointer(rule.Data[0].QueryType), + DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)), + Model: rule.Data[0].Model, + Source: util.Pointer(true), + RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{ + From: v0alpha1.AlertRulePromDurationWMillis("5m"), + To: v0alpha1.AlertRulePromDurationWMillis("0s"), + }, + }, + }, + Trigger: v0alpha1.AlertRuleIntervalTrigger{ + Interval: v0alpha1.AlertRulePromDuration("10s"), + }, + NoDataState: "NoData", + ExecErrState: "Error", + }, + } + + created, err := client.Create(ctx, alertRule, v1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, created) + }) + + t.Run("should fail to create rule with group labels preset", func(t *testing.T) { + rule := baseGen.Generate() + alertRule := &v0alpha1.AlertRule{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{ + v0alpha1.FolderAnnotationKey: "test-folder-a", + }, + Labels: map[string]string{ + v0alpha1.GroupLabelKey: "some-group", + v0alpha1.GroupIndexLabelKey: "0", + }, + }, + Spec: v0alpha1.AlertRuleSpec{ + Title: rule.Title, + Expressions: v0alpha1.AlertRuleExpressionMap{ + "A": { + QueryType: util.Pointer(rule.Data[0].QueryType), + DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)), + Model: rule.Data[0].Model, + Source: util.Pointer(true), + RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{ + From: v0alpha1.AlertRulePromDurationWMillis("5m"), + To: v0alpha1.AlertRulePromDurationWMillis("0s"), + }, + }, + }, + Trigger: v0alpha1.AlertRuleIntervalTrigger{Interval: v0alpha1.AlertRulePromDuration("10s")}, + NoDataState: "NoData", + ExecErrState: "Error", + }, + } + + created, err := client.Create(ctx, alertRule, v1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, created) + }) +} diff --git a/pkg/tests/apis/alerting/rules/recordingrule/recordingrule_test.go b/pkg/tests/apis/alerting/rules/recordingrule/recordingrule_test.go index 783a90f777d..e1c586f9bb9 100644 --- a/pkg/tests/apis/alerting/rules/recordingrule/recordingrule_test.go +++ b/pkg/tests/apis/alerting/rules/recordingrule/recordingrule_test.go @@ -454,7 +454,7 @@ func TestIntegrationCRUD(t *testing.T) { } created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{}) - require.ErrorContains(t, err, "invalid alert rule") + require.ErrorContains(t, err, "trigger interval must be a multiple of base evaluation interval") require.Nil(t, created) }) } @@ -557,3 +557,139 @@ func TestIntegrationBasicAPI(t *testing.T) { t.Logf("Got error: %s", err) }) } + +func TestIntegrationFolderLabelSyncAndValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := common.GetTestHelper(t) + client := common.NewRecordingRuleClient(t, helper.Org1.Admin) + + // Prepare two folders for label sync update scenario + common.CreateTestFolder(t, helper, "test-folder-a") + common.CreateTestFolder(t, helper, "test-folder-b") + + baseGen := ngmodels.RuleGen.With( + ngmodels.RuleMuts.WithUniqueUID(), + ngmodels.RuleMuts.WithUniqueTitle(), + ngmodels.RuleMuts.WithNamespaceUID("test-folder-a"), + ngmodels.RuleMuts.WithGroupName("test-group"), + ngmodels.RuleMuts.WithAllRecordingRules(), + ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second), + ) + + t.Run("should keep folder label in sync with folder annotation on create and update", func(t *testing.T) { + rule := baseGen.Generate() + recordingRule := &v0alpha1.RecordingRule{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{ + v0alpha1.FolderAnnotationKey: "test-folder-a", + }, + }, + Spec: v0alpha1.RecordingRuleSpec{ + Title: rule.Title, + Metric: rule.Record.Metric, + Expressions: v0alpha1.RecordingRuleExpressionMap{ + "A": { + QueryType: util.Pointer(rule.Data[0].QueryType), + DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)), + Model: rule.Data[0].Model, + Source: util.Pointer(true), + RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{ + From: v0alpha1.RecordingRulePromDurationWMillis("5m"), + To: v0alpha1.RecordingRulePromDurationWMillis("0s"), + }, + }, + }, + Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")}, + }, + } + + created, err := client.Create(ctx, recordingRule, v1.CreateOptions{}) + require.NoError(t, err) + defer func() { _ = client.Delete(ctx, created.Name, v1.DeleteOptions{}) }() + + // On create, metadata.labels[v0alpha1.FolderLabelKey] should mirror annotation + require.Equal(t, "test-folder-a", created.Labels[v0alpha1.FolderLabelKey]) + + updated := created.Copy().(*v0alpha1.RecordingRule) + if updated.Annotations == nil { + updated.Annotations = map[string]string{} + } + updated.Annotations[v0alpha1.FolderAnnotationKey] = "test-folder-b" + + after, err := client.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.Equal(t, "test-folder-b", after.Annotations[v0alpha1.FolderAnnotationKey]) + require.Equal(t, "test-folder-b", after.Labels[v0alpha1.FolderLabelKey]) + }) + + t.Run("should fail to create recording rule without folder annotation", func(t *testing.T) { + rule := baseGen.Generate() + recordingRule := &v0alpha1.RecordingRule{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{}, + }, + Spec: v0alpha1.RecordingRuleSpec{ + Title: rule.Title, + Metric: rule.Record.Metric, + Expressions: v0alpha1.RecordingRuleExpressionMap{ + "A": { + QueryType: util.Pointer(rule.Data[0].QueryType), + DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)), + Model: rule.Data[0].Model, + Source: util.Pointer(true), + RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{ + From: v0alpha1.RecordingRulePromDurationWMillis("5m"), + To: v0alpha1.RecordingRulePromDurationWMillis("0s"), + }, + }, + }, + Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")}, + }, + } + + created, err := client.Create(ctx, recordingRule, v1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, created) + }) + + t.Run("should fail to create rule with group labels preset", func(t *testing.T) { + rule := baseGen.Generate() + recordingRule := &v0alpha1.RecordingRule{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Annotations: map[string]string{ + v0alpha1.FolderAnnotationKey: "test-folder-a", + }, + Labels: map[string]string{ + v0alpha1.GroupLabelKey: "some-group", + v0alpha1.GroupIndexLabelKey: "0", + }, + }, + Spec: v0alpha1.RecordingRuleSpec{ + Title: rule.Title, + Metric: rule.Record.Metric, + Expressions: v0alpha1.RecordingRuleExpressionMap{ + "A": { + QueryType: util.Pointer(rule.Data[0].QueryType), + DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)), + Model: rule.Data[0].Model, + Source: util.Pointer(true), + RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{ + From: v0alpha1.RecordingRulePromDurationWMillis("5m"), + To: v0alpha1.RecordingRulePromDurationWMillis("0s"), + }, + }, + }, + Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")}, + }, + } + + created, err := client.Create(ctx, recordingRule, v1.CreateOptions{}) + require.Error(t, err) + require.Nil(t, created) + }) +} From 90ddd922ad29165d886ee9d8e42792b857cf43ba Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 7 Nov 2025 14:04:42 -0500 Subject: [PATCH 102/209] Chore: Cleanup panelMonitoring feature flag (#113530) --- .../configure-grafana/feature-toggles/index.md | 1 - packages/grafana-data/src/types/featureToggles.gen.ts | 5 ----- pkg/services/featuremgmt/registry.go | 8 -------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- .../app/features/dashboard/dashgrid/PanelStateWrapper.tsx | 7 +++---- 7 files changed, 5 insertions(+), 24 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index b9d830495c5..45ddf656223 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -40,7 +40,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `transformationsRedesign` | Enables the transformations redesign | Yes | | `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes | | `dashgpt` | Enable AI powered features in dashboards | Yes | -| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `formatString` | Enable format string transformer | Yes | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | Yes | | `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8ac393b412c..8a50becd224 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -248,11 +248,6 @@ export interface FeatureToggles { */ externalServiceAccounts?: boolean; /** - * Enables panel monitoring through logs and measurements - * @default true - */ - panelMonitoring?: boolean; - /** * Enables native HTTP Histograms */ enableNativeHTTPHistogram?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 008ed41d566..27a978a6c27 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -405,14 +405,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: identityAccessTeam, }, - { - Name: "panelMonitoring", - Description: "Enables panel monitoring through logs and measurements", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: grafanaDatavizSquad, - FrontendOnly: true, - }, { Name: "enableNativeHTTPHistogram", Description: "Enables native HTTP Histograms", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d2108887cb9..71f681c0171 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -52,7 +52,6 @@ reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,f sseGroupByDatasource,experimental,@grafana/observability-metrics,false,false,false lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false -panelMonitoring,GA,@grafana/dataviz-squad,false,false,true enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false formatString,GA,@grafana/dataviz-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f4701c6fb19..443fb196228 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -219,10 +219,6 @@ const ( // Automatic service account and token setup for plugins FlagExternalServiceAccounts = "externalServiceAccounts" - // FlagPanelMonitoring - // Enables panel monitoring through logs and measurements - FlagPanelMonitoring = "panelMonitoring" - // FlagEnableNativeHTTPHistogram // Enables native HTTP Histograms FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index e8c5715fe73..48155d54721 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2905,7 +2905,8 @@ "metadata": { "name": "panelMonitoring", "resourceVersion": "1753448760331", - "creationTimestamp": "2023-10-09T05:19:08Z" + "creationTimestamp": "2023-10-09T05:19:08Z", + "deletionTimestamp": "2025-11-06T15:46:51Z" }, "spec": { "description": "Enables panel monitoring through logs and measurements", diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 5d1558fa7f5..645cc75f811 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -33,7 +33,6 @@ import { AdHocFilterItem, } from '@grafana/ui'; import appEvents from 'app/core/app_events'; -import config from 'app/core/config'; import { profiler } from 'app/core/profiler'; import { annotationServer } from 'app/features/annotations/api'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; @@ -121,7 +120,7 @@ export class PanelStateWrapper extends PureComponent { data: this.getInitialPanelDataState(), }; - if (config.featureToggles.panelMonitoring && this.getPanelContextApp() === CoreApp.PanelEditor) { + if (this.getPanelContextApp() === CoreApp.PanelEditor) { const panelInfo = { panelId: String(props.panel.id), panelType: props.panel.type, @@ -395,7 +394,7 @@ export class PanelStateWrapper extends PureComponent { } onPanelError = (error: Error) => { - if (config.featureToggles.panelMonitoring && this.getPanelContextApp() === CoreApp.PanelEditor) { + if (this.getPanelContextApp() === CoreApp.PanelEditor) { this.logPanelChangesOnError(); } @@ -543,7 +542,7 @@ export class PanelStateWrapper extends PureComponent { onChangeTimeRange={this.onChangeTimeRange} eventBus={dashboard.events} /> - {config.featureToggles.panelMonitoring && this.state.errorMessage === undefined && ( + {this.state.errorMessage === undefined && ( )} From 0e9fe9dc409669767fa6e5a7a0e09fac1c95ef68 Mon Sep 17 00:00:00 2001 From: beejeebus Date: Wed, 5 Nov 2025 22:07:44 +0000 Subject: [PATCH 103/209] Register external datasource plugins on startup Current code only registers core datasource k8s api groups. Add external plugins. Companion grafana-enterprise PR: https://github.com/grafana/grafana-enterprise/pull/10125 --- pkg/registry/apis/datasource/register.go | 48 ++++++++++++------------ pkg/server/wire_gen.go | 4 +- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 90b1ad8a52f..61709652d31 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -3,10 +3,8 @@ package datasource import ( "context" "encoding/json" - "errors" "fmt" "maps" - "path/filepath" "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,7 +21,6 @@ import ( datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/promlib/models" @@ -31,7 +28,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds" ) @@ -53,7 +49,6 @@ type DataSourceAPIBuilder struct { } func RegisterAPIService( - cfgProvider configprovider.ConfigProvider, features featuremgmt.FeatureToggles, apiRegistrar builder.APIRegistrar, pluginClient plugins.Client, // access to everything @@ -61,6 +56,7 @@ func RegisterAPIService( contextProvider PluginContextWrapper, accessControl accesscontrol.AccessControl, reg prometheus.Registerer, + pluginSources sources.Registry, ) (*DataSourceAPIBuilder, error) { // We want to expose just a limited set of plugins //nolint:staticcheck // not yet migrated to OpenFeature @@ -75,13 +71,9 @@ func RegisterAPIService( var err error var builder *DataSourceAPIBuilder - cfg, err := cfgProvider.Get(context.Background()) + pluginJSONs, err := getDatasourcePlugins(pluginSources) if err != nil { - return nil, err - } - pluginJSONs, err := getCorePlugins(cfg) - if err != nil { - return nil, err + return nil, fmt.Errorf("error getting list of datasource plugins: %s", err) } ids := []string{ @@ -299,21 +291,29 @@ func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() openapi.GetOpenAPIDefinit } } -func getCorePlugins(cfg *setting.Cfg) ([]plugins.JSONData, error) { - coreDataSourcesPath := filepath.Join(cfg.StaticRootPath, "app", "plugins", "datasource") - coreDataSourcesSrc := sources.NewLocalSource( - plugins.ClassCore, - []string{coreDataSourcesPath}, - ) +func getDatasourcePlugins(pluginSources sources.Registry) ([]plugins.JSONData, error) { + var pluginJSONs []plugins.JSONData - res, err := coreDataSourcesSrc.Discover(context.Background()) - if err != nil { - return nil, errors.New("failed to load core data source plugins") - } + // It's possible that the same plugin will be found in different sources. + // Registering the same plugin twice in the API is Probably A Bad Thing, + // so this map keeps track of uniques, so we can skip duplicates. + var uniquePlugins = map[string]bool{} - pluginJSONs := make([]plugins.JSONData, 0, len(res)) - for _, p := range res { - pluginJSONs = append(pluginJSONs, p.Primary.JSONData) + for _, pluginSource := range pluginSources.List(context.Background()) { + res, err := pluginSource.Discover(context.Background()) + if err != nil { + return nil, err + } + for _, p := range res { + if p.Primary.JSONData.Type == plugins.TypeDataSource { + if _, found := uniquePlugins[p.Primary.JSONData.ID]; found { + backend.Logger.Info("Found duplicate plugin %s when registering API groups.", p.Primary.JSONData.ID) + continue + } + uniquePlugins[p.Primary.JSONData.ID] = true + pluginJSONs = append(pluginJSONs, p.Primary.JSONData) + } + } } return pluginJSONs, nil } diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 13b764f7238..b37a6913ce4 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -847,7 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer) + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) if err != nil { return nil, err } @@ -1485,7 +1485,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer) + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) if err != nil { return nil, err } From f3843fc67a6e10dd910cb6aa5d333c3e06537024 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Fri, 7 Nov 2025 17:18:50 -0400 Subject: [PATCH 104/209] Linter: Custom eslint rule to prevent in-repo plugins from reaching into neighbouring plugin code (#112248) * feat(linter-rule): prevent cross plugin relative path imports * docs(readme): document new grafana/no-plugin-external-import-paths rule * chore(test): add an NPM script to run linter rule tests * test(linter-rule): prevent cross plugin relative path imports * docs(eslint-config): add a commented out example of new rule usage --- eslint.config.js | 13 +++ packages/grafana-eslint-rules/README.md | 41 ++++++++ packages/grafana-eslint-rules/index.cjs | 2 + packages/grafana-eslint-rules/jest.config.js | 4 + packages/grafana-eslint-rules/package.json | 5 +- .../rules/no-plugin-external-import-paths.cjs | 98 +++++++++++++++++++ .../no-plugin-external-import-paths.test.js | 81 +++++++++++++++ yarn.lock | 1 + 8 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packages/grafana-eslint-rules/jest.config.js create mode 100644 packages/grafana-eslint-rules/rules/no-plugin-external-import-paths.cjs create mode 100644 packages/grafana-eslint-rules/tests/no-plugin-external-import-paths.test.js diff --git a/eslint.config.js b/eslint.config.js index b88a4231fd3..78a7a880597 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -599,4 +599,17 @@ module.exports = [ ], }, }, + + // { + // name: 'grafana/plugin-external-import-paths', + // files: [ + // 'public/app/plugins/panel/histogram/**/*.{ts,tsx}', + // ], + // plugins: { + // '@grafana': grafanaPlugin, + // }, + // rules: { + // '@grafana/no-plugin-external-import-paths': 'error', + // }, + // }, ]; diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md index be49122b758..aa6d4e880ff 100644 --- a/packages/grafana-eslint-rules/README.md +++ b/packages/grafana-eslint-rules/README.md @@ -140,3 +140,44 @@ export default storyConfig; const storyConfig = { title: 'Components/Forms/Button' }; export default storyConfig; ``` + +### `no-plugin-external-import-paths` + +Prevent plugins from importing anything outside their own directory. + +This rule enforces strict plugin isolation by preventing plugins from importing anything that reaches outside their own plugin directory. This helps maintain clean plugin boundaries and prevents tight coupling between plugins and other parts of the codebase. + +The rule automatically detects the current plugin directory from the file path and blocks any relative imports that would reach outside that directory. + +The rule is applied to specific plugins by configuring the `files` pattern in the ESLint configuration, similar to `grafana/decoupled-plugins-overrides`. + +#### Examples + +```tsx +// Bad ❌ - Importing from sibling plugin +import { getDataLinks } from '../status-history/utils'; +import { isTooltipScrollable } from '../timeseries/utils'; + +// Bad ❌ - Importing from Grafana core +import { something } from '../../../features/dashboard/state'; + +// Bad ❌ - Importing from outside plugin directory +import { other } from '../some-other-folder/utils'; + +// Good ✅ - Importing from same plugin +import { someUtil } from './utils'; +import { Component } from './Component'; +import { helper } from './subfolder/helper'; + +// Good ✅ - Importing from external packages +import React from 'react'; +import { Button } from '@grafana/ui'; +``` + +#### Error Message + +When a violation is detected, the rule reports: + +``` +Import '../status-history/utils' reaches outside the 'histogram' plugin directory. Plugins should only import from external dependencies or relative paths within their own directory. +``` diff --git a/packages/grafana-eslint-rules/index.cjs b/packages/grafana-eslint-rules/index.cjs index 438d88332bc..89a76ee60a1 100644 --- a/packages/grafana-eslint-rules/index.cjs +++ b/packages/grafana-eslint-rules/index.cjs @@ -4,6 +4,7 @@ const noUnreducedMotion = require('./rules/no-unreduced-motion.cjs'); const themeTokenUsage = require('./rules/theme-token-usage.cjs'); const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs'); const consistentStoryTitles = require('./rules/consistent-story-titles.cjs'); +const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs'); module.exports = { rules: { @@ -13,5 +14,6 @@ module.exports = { 'theme-token-usage': themeTokenUsage, 'no-restricted-img-srcs': noRestrictedImgSrcs, 'consistent-story-titles': consistentStoryTitles, + 'no-plugin-external-import-paths': noPluginExternalImportPaths, }, }; diff --git a/packages/grafana-eslint-rules/jest.config.js b/packages/grafana-eslint-rules/jest.config.js new file mode 100644 index 00000000000..757e62524d6 --- /dev/null +++ b/packages/grafana-eslint-rules/jest.config.js @@ -0,0 +1,4 @@ +export default { + testEnvironment: 'node', + testMatch: ['/**/*.test.js'], +}; diff --git a/packages/grafana-eslint-rules/package.json b/packages/grafana-eslint-rules/package.json index 9fcf5867033..0fe0ca61193 100644 --- a/packages/grafana-eslint-rules/package.json +++ b/packages/grafana-eslint-rules/package.json @@ -2,11 +2,13 @@ "name": "@grafana/eslint-plugin", "description": "ESLint rules for use within the Grafana repo. Not suitable (or supported) for external use.", "version": "12.4.0-pre", + "type": "module", "main": "./index.cjs", "author": "Grafana Labs", "license": "Apache-2.0", "scripts": { - "typecheck": "tsc --emitDeclarationOnly false --noEmit" + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "test": "NODE_OPTIONS='--experimental-vm-modules' jest" }, "repository": { "type": "git", @@ -19,6 +21,7 @@ "devDependencies": { "@typescript-eslint/types": "^8.9.0", "eslint": "9.32.0", + "jest": "29.7.0", "tslib": "2.8.1" }, "private": true diff --git a/packages/grafana-eslint-rules/rules/no-plugin-external-import-paths.cjs b/packages/grafana-eslint-rules/rules/no-plugin-external-import-paths.cjs new file mode 100644 index 00000000000..63f44fe3077 --- /dev/null +++ b/packages/grafana-eslint-rules/rules/no-plugin-external-import-paths.cjs @@ -0,0 +1,98 @@ +// @ts-check +/** @typedef {import('@typescript-eslint/utils').TSESTree.ImportDeclaration} ImportDeclaration */ +const { ESLintUtils } = require('@typescript-eslint/utils'); +const path = require('path'); + +const createRule = ESLintUtils.RuleCreator( + (name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}` +); + +/** + * Extract the plugin root directory from the file path + * @param {string} filePath - The file path being linted + * @returns {string|null} - The plugin root directory or null if not in a plugin directory + */ +function getPluginRootDirectory(filePath) { + const pluginMatch = filePath.match(/\/plugins\/(?:panel|datasource)\/([^/]+)\//); + if (pluginMatch) { + const pluginName = pluginMatch[1]; + const pluginType = pluginMatch[0].includes('/panel/') ? 'panel' : 'datasource'; + + const pluginDirPath = `/plugins/${pluginType}/${pluginName}`; + const pluginDirStart = filePath.indexOf(pluginDirPath); + if (pluginDirStart !== -1) { + const pluginRoot = filePath.substring(0, pluginDirStart + pluginDirPath.length); + return path.isAbsolute(pluginRoot) ? pluginRoot : path.resolve(pluginRoot); + } + } + return null; +} + +/** + * Check if an import path reaches outside the plugin's root directory boundaries + * @param {string} importPath - The import path to check + * @param {string} currentFilePath - The current file path being linted + * @param {string} pluginRoot - The plugin root directory + * @returns {boolean} - True if the import goes outside plugin boundaries + */ +function isImportOutsidePluginBoundaries(importPath, currentFilePath, pluginRoot) { + const isRelativeImport = importPath.startsWith('./') || importPath.startsWith('../'); + if (!isRelativeImport) { + return false; + } + + const currentDir = path.dirname(currentFilePath); + const resolvedPath = path.resolve(currentDir, importPath); + + const normalizedResolvedPath = path.normalize(resolvedPath); + const normalizedPluginRoot = path.normalize(pluginRoot); + + return !normalizedResolvedPath.startsWith(normalizedPluginRoot); +} + +const noRestrictedPeerPluginPathsRule = createRule({ + create(context) { + const currentFilePath = context.getFilename(); + const pluginRoot = getPluginRootDirectory(currentFilePath); + + if (!pluginRoot) { + return {}; + } + + return { + /** @param {ImportDeclaration} node */ + ImportDeclaration(node) { + const importPath = node.source.value; + + if ( + typeof importPath === 'string' && + isImportOutsidePluginBoundaries(importPath, currentFilePath, pluginRoot) + ) { + return context.report({ + node: node.source, + messageId: 'importOutsidePluginBoundaries', + data: { + importPath, + pluginRoot: path.basename(pluginRoot), + }, + }); + } + }, + }; + }, + name: 'no-plugin-external-import-paths', + meta: { + type: 'problem', + docs: { + description: 'Disallow imports that reach outside plugin root directory boundaries', + }, + messages: { + importOutsidePluginBoundaries: + "Import '{{importPath}}' reaches outside the '{{pluginRoot}}' plugin directory. Plugins should only import from external dependencies or relative paths within their own directory.", + }, + schema: [], + }, + defaultOptions: [], +}); + +module.exports = noRestrictedPeerPluginPathsRule; diff --git a/packages/grafana-eslint-rules/tests/no-plugin-external-import-paths.test.js b/packages/grafana-eslint-rules/tests/no-plugin-external-import-paths.test.js new file mode 100644 index 00000000000..3c25742d3eb --- /dev/null +++ b/packages/grafana-eslint-rules/tests/no-plugin-external-import-paths.test.js @@ -0,0 +1,81 @@ +import { RuleTester } from 'eslint'; + +import rule from '../rules/no-plugin-external-import-paths.cjs'; + +RuleTester.setDefaultConfig({ + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, +}); + +const ruleTester = new RuleTester(); + +ruleTester.run('eslint no-plugin-external-import-paths', rule, { + valid: [ + { + name: 'external npm package import', + filename: 'public/app/plugins/panel/histogram/HistogramTooltip.tsx', + code: "import React from 'react';", + }, + { + name: 'grafana package import', + filename: 'public/app/plugins/panel/histogram/HistogramTooltip.tsx', + code: "import { Button } from '@grafana/ui';", + }, + { + name: 'same plugin file import', + filename: 'public/app/plugins/panel/histogram/HistogramTooltip.tsx', + code: "import { someUtil } from './utils';", + }, + { + name: 'same plugin subdirectory import', + filename: 'public/app/plugins/panel/histogram/components/HistogramTooltip.tsx', + code: "import { Component } from '../Component';", + }, + ], + invalid: [ + { + name: 'sibling plugin import', + filename: 'public/app/plugins/panel/histogram/HistogramTooltip.tsx', + code: "import { getDataLinks } from '../status-history/utils';", + errors: [ + { + messageId: 'importOutsidePluginBoundaries', + data: { + importPath: '../status-history/utils', + pluginRoot: 'histogram', + }, + }, + ], + }, + { + name: 'grafana core import', + filename: 'public/app/plugins/panel/histogram/HistogramTooltip.tsx', + code: "import { something } from '../../../features/dashboard/state';", + errors: [ + { + messageId: 'importOutsidePluginBoundaries', + data: { + importPath: '../../../features/dashboard/state', + pluginRoot: 'histogram', + }, + }, + ], + }, + { + name: 'datasource plugin sibling import', + filename: 'public/app/plugins/datasource/loki/datasource.ts', + code: "import { something } from '../prometheus/utils';", + errors: [ + { + messageId: 'importOutsidePluginBoundaries', + data: { + importPath: '../prometheus/utils', + pluginRoot: 'loki', + }, + }, + ], + }, + ], +}); diff --git a/yarn.lock b/yarn.lock index 5b1cbe5e476..5b9221f61fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3163,6 +3163,7 @@ __metadata: "@typescript-eslint/types": "npm:^8.9.0" "@typescript-eslint/utils": "npm:^8.9.0" eslint: "npm:9.32.0" + jest: "npm:29.7.0" tslib: "npm:2.8.1" languageName: unknown linkType: soft From cb43dea319f871bbefbc22b8a4dd413104bad183 Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Fri, 7 Nov 2025 16:10:39 -0600 Subject: [PATCH 105/209] docs: clarifying info on what's new lading page (#113562) --- docs/sources/whatsnew/_index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index bb401ea136d..6779856cdeb 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -170,7 +170,6 @@ aliases: description: Learn about new and updated features in Grafana. labels: products: - - cloud - enterprise - oss menuTitle: What's new @@ -180,7 +179,9 @@ weight: 1 # What's new in Grafana -For release highlights, deprecations, and breaking changes in Grafana releases, refer to these "What's new" pages for each version. +For release highlights, deprecations, and breaking changes in self-managed Grafana releases, refer to these "What's new" pages for each version. + +For information on new Grafana Cloud highlights, refer to [What's new from Grafana Labs](https://grafana.com/whats-new). {{< admonition type="note" >}} For Grafana versions prior to v9.2, additional information might also be available in the archived release notes. To access archived release notes, use the documentation for the minor version you want to see. From aa75cc5bbe60a5e75857ef392837952a80e849ff Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Sat, 8 Nov 2025 00:38:38 +0000 Subject: [PATCH 106/209] I18n: Download translations from Crowdin (#113568) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 6 ++++-- public/locales/de-DE/grafana.json | 6 ++++-- public/locales/es-ES/grafana.json | 6 ++++-- public/locales/fr-FR/grafana.json | 6 ++++-- public/locales/hu-HU/grafana.json | 6 ++++-- public/locales/id-ID/grafana.json | 6 ++++-- public/locales/it-IT/grafana.json | 6 ++++-- public/locales/ja-JP/grafana.json | 6 ++++-- public/locales/ko-KR/grafana.json | 6 ++++-- public/locales/nl-NL/grafana.json | 6 ++++-- public/locales/pl-PL/grafana.json | 6 ++++-- public/locales/pt-BR/grafana.json | 6 ++++-- public/locales/pt-PT/grafana.json | 6 ++++-- public/locales/ru-RU/grafana.json | 6 ++++-- public/locales/sv-SE/grafana.json | 6 ++++-- public/locales/tr-TR/grafana.json | 6 ++++-- public/locales/zh-Hans/grafana.json | 6 ++++-- public/locales/zh-Hant/grafana.json | 6 ++++-- 18 files changed, 72 insertions(+), 36 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 02e0639b853..83c9f4cf0fa 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -11610,6 +11610,8 @@ "empty-state": { "no-jobs": "Žádné úlohy…" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Využijte integraci GitHubu naplno s těmito volitelnými doplňky", "description-instant-updates": "Získejte okamžité aktualizace v Grafaně ihned po potvrzení změn. Před zveřejněním zkontrolujte a schvalte změny pomocí pull requestů.", @@ -11833,14 +11835,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "aktivní práce", "column-action": "Akce", "column-duration": "Doba trvání", "column-job-id": "", "column-message": "Zpráva", "column-started": "Zahájeno", "column-status": "Stav", - "error-loading": "Chyba při načítání {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Práce" }, "repository-actions": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e4e84df25ff..0011521f606 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Keine Aufträge ..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Nutzen Sie mit diesen optionalen Add-ons Ihre GitHub-Integration optimal", "description-instant-updates": "Erhalten Sie sofortige Updates in Grafana, sobald Änderungen übernommen werden. Überprüfen und genehmigen Sie Änderungen mithilfe von Pull-Requests, bevor sie übernommen werden.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "Aktive Aufträge", "column-action": "Aktion", "column-duration": "Dauer", "column-job-id": "", "column-message": "Nachricht", "column-started": "Gestartet", "column-status": "Status", - "error-loading": "Fehler beim Laden von {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Aufträge" }, "repository-actions": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 548d36b6f09..4091f204c11 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "No hay trabajos..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Aprovecha al máximo tu integración de GitHub con estos plugins opcionales", "description-instant-updates": "Consigue actualizaciones instantáneas en Grafana tan pronto como se confirmen los cambios. Revisa y aprueba los cambios mediante solicitudes de extracción antes de que se publiquen.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "trabajos activos", "column-action": "Acción", "column-duration": "Duración", "column-job-id": "", "column-message": "Mensaje", "column-started": "Iniciados", "column-status": "Estado", - "error-loading": "Error al cargar {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Trabajos" }, "repository-actions": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 868edd138d7..31ad160363e 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Pas de mission..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Tirez le meilleur parti de votre intégration GitHub avec ces add-ons facultatifs", "description-instant-updates": "Obtenez des mises à jour instantanées dans Grafana dès que les modifications sont validées. Examinez et approuvez les modifications à l’aide de demandes de fusion avant leur mise en ligne.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "missions actives", "column-action": "Action", "column-duration": "Durée", "column-job-id": "", "column-message": "Message", "column-started": "Démarré", "column-status": "Statut", - "error-loading": "Erreur lors du chargement : {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Missions" }, "repository-actions": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index f8b3e6728d7..6c67235aea9 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Nincsenek feladatok…" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Hozza ki a legtöbbet a GitHub-integrációból ezekkel az opcionális kiegészítőkkel", "description-instant-updates": "Azonnali frissítéseket kaphat a Grafanában, amint a változások végrehajtása megtörtént. Tekintse át és hagyja jóvá a módosításokat a lekérések használatával, mielőtt életbe lépnének.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "aktív feladatok", "column-action": "Művelet", "column-duration": "Időtartam", "column-job-id": "", "column-message": "Üzenet", "column-started": "Elindítva", "column-status": "Állapot", - "error-loading": "Hiba történt a(z) {{type}} betöltése során", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Feladatok" }, "repository-actions": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 7b2e629b3ed..c6c191c4587 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -11460,6 +11460,8 @@ "empty-state": { "no-jobs": "Tidak ada pekerjaan..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Optimalkan integrasi GitHub Anda dengan add-on opsional ini", "description-instant-updates": "Segera dapatkan pembaruan instan di Grafana setelah perubahan dilakukan. Tinjau dan setujui perubahan menggunakan permintaan pull sebelum live.", @@ -11677,14 +11679,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "pekerjaan aktif", "column-action": "Tindakan", "column-duration": "Durasi", "column-job-id": "", "column-message": "Pesan", "column-started": "Dimulai", "column-status": "Status", - "error-loading": "Kesalahan saat memuat {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Pekerjaan" }, "repository-actions": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 97dd480c90e..78e2f719603 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Nessuna attività..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Ottieni il massimo dalla tua integrazione GitHub con questi componenti aggiuntivi opzionali", "description-instant-updates": "Ricevi aggiornamenti istantanei in Grafana non appena vengono apportate le modifiche. Esamina e approva le modifiche utilizzando le richieste di pull prima che vengano pubblicate.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "attività attive", "column-action": "Azione", "column-duration": "Durata", "column-job-id": "", "column-message": "Messaggio", "column-started": "Iniziata", "column-status": "Stato", - "error-loading": "Errore durante il caricamento {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Attività" }, "repository-actions": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 0647b0b4393..4389152544c 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -11460,6 +11460,8 @@ "empty-state": { "no-jobs": "ジョブはありません…" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "これらのオプションのアドオンを使って、GitHub連携を最大限に活用しましょう", "description-instant-updates": "変更がコミットされるとすぐに、Grafanaでリアルタイムに更新を受け取ります。変更が公開される前に、プルリクエストを使用して変更を確認・承認します。", @@ -11677,14 +11679,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "アクティブなジョブ", "column-action": "操作", "column-duration": "継続時間", "column-job-id": "", "column-message": "メッセージ", "column-started": "開始済み", "column-status": "ステータス", - "error-loading": "{{type}}の読み込みエラー", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "ジョブ" }, "repository-actions": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index afbdc50e1d2..77d02ce82c8 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -11460,6 +11460,8 @@ "empty-state": { "no-jobs": "작업 없음..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "선택적 애드온으로 GitHub 통합을 최대한 활용하세요", "description-instant-updates": "변경 사항이 커밋되는 즉시 Grafana에서 업데이트를 받으세요. 변경 사항이 실제로 적용되기 전에 풀 요청을 사용하여 검토하고 승인하세요.", @@ -11677,14 +11679,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "활성 작업", "column-action": "동작", "column-duration": "지속 시간", "column-job-id": "", "column-message": "메시지", "column-started": "시작됨", "column-status": "상태", - "error-loading": "{{type}} 로딩 중 오류 발생", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "작업" }, "repository-actions": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index ae2b06a0f64..63dcaf4269c 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Geen taken..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Haal het meeste uit je GitHub-integratie met deze optionele add-ons", "description-instant-updates": "Krijg directe updates in Grafana zodra wijzigingen zijn doorgevoerd. Bekijk en keur wijzigingen goed met pull requests voordat ze live gaan.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "actieve taken", "column-action": "Actie", "column-duration": "Duur", "column-job-id": "", "column-message": "Bericht", "column-started": "Gestart", "column-status": "Status", - "error-loading": "Er is een fout opgetreden bij het laden van {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Taken" }, "repository-actions": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index b9c397005fe..81bfb1ea800 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -11610,6 +11610,8 @@ "empty-state": { "no-jobs": "Brak zadań…" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Wykorzystaj w pełni integrację z GitHub dzięki tym opcjonalnym dodatkom", "description-instant-updates": "Otrzymuj aktualizacje w usłudze Grafana natychmiast po zatwierdzeniu zmian (commit). Przejrzyj i zatwierdź zmiany za pomocą żądań pull przed ich opublikowaniem.", @@ -11833,14 +11835,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "aktywne zadania", "column-action": "Działanie", "column-duration": "Czas trwania", "column-job-id": "", "column-message": "Wiadomość", "column-started": "Rozpoczęto", "column-status": "Status", - "error-loading": "Błąd podczas wczytywania: {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Zadania" }, "repository-actions": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 40bb380702d..1cd7779f260 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Sem tarefas…" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Aproveite ao máximo sua integração com o GitHub usando estes complementos opcionais", "description-instant-updates": "Receba atualizações instantâneas na Grafana assim que as alterações forem confirmadas. Revise e aprove as alterações por meio de solicitações de extração antes que elas sejam implementadas.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "tarefas ativas", "column-action": "Ação", "column-duration": "Duração", "column-job-id": "", "column-message": "Mensagem", "column-started": "Iniciado", "column-status": "Status", - "error-loading": "Erro ao carregar {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Tarefas" }, "repository-actions": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 12f6ce60a54..09fc80f06d2 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Sem serviços..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Tire o máximo partido da sua integração no GitHub com estes suplementos opcionais", "description-instant-updates": "Obtenha atualizações instantâneas na Grafana assim que as alterações forem efetuadas. Reveja e aprove as alterações com pedidos de extração antes da sua publicação.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "trabalhos ativos", "column-action": "Ação", "column-duration": "Duração", "column-job-id": "", "column-message": "Mensagem", "column-started": "Iniciado", "column-status": "Estado", - "error-loading": "Erro ao carregar {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Trabalhos" }, "repository-actions": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index b3d46874259..1dceba0a5e7 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -11610,6 +11610,8 @@ "empty-state": { "no-jobs": "Нет заданий..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Получите максимум от интеграции с GitHub с помощью этих дополнительных надстроек.", "description-instant-updates": "Получайте мгновенные обновления в Grafana сразу после фиксации изменений. Просматривайте и утверждайте изменения с помощью соответствующих запросов перед их внедрением.", @@ -11833,14 +11835,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "активные задания", "column-action": "Действие", "column-duration": "Длительность", "column-job-id": "", "column-message": "Сообщение", "column-started": "Запущено", "column-status": "Статус", - "error-loading": "Ошибка при загрузке {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Задания" }, "repository-actions": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index b7497fcef5c..b20dc1a3476 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "Inga jobb …" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "Få ut mesta möjliga av GitHub-integrationen med dessa valfria tillägg", "description-instant-updates": "Få omedelbara uppdateringar i Grafana så snart ändringar görs. Granska och godkänn ändringar med hjälp av pull-förfrågningar innan de publiceras.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "aktiva jobb", "column-action": "Åtgärd", "column-duration": "Varaktighet", "column-job-id": "", "column-message": "Meddelande", "column-started": "Startat", "column-status": "Status", - "error-loading": "Fel vid laddning av {{type}}", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "Jobb" }, "repository-actions": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index b328286b358..86687a59c6d 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -11510,6 +11510,8 @@ "empty-state": { "no-jobs": "İş yok..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "GitHub entegrasyonunuzdan en iyi şekilde yararlanmak için bu isteğe bağlı eklentileri kullanın", "description-instant-updates": "Değişiklikler gönderildiği anda Grafana'da anlık güncellemeler alın. Değişiklikleri kullanıma sunmadan önce çekme isteklerini kullanarak gözden geçirip onaylayın.", @@ -11729,14 +11731,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "etkin işler", "column-action": "Eylem", "column-duration": "Süre", "column-job-id": "", "column-message": "Mesaj", "column-started": "Başlatıldı", "column-status": "Durum", - "error-loading": "{{type}} yüklenirken hata oluştu", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "İşler" }, "repository-actions": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 3310d6b7fa2..45795577166 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -11460,6 +11460,8 @@ "empty-state": { "no-jobs": "无作业..." }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "使用这些可选附加组件,充分利用 GitHub 集成功能", "description-instant-updates": "在提交更改后立即在 Grafana 中获取更新。使用拉取请求审核和批准更改,然后再发布。", @@ -11677,14 +11679,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "活跃的作业", "column-action": "操作", "column-duration": "持续时间", "column-job-id": "", "column-message": "消息", "column-started": "已启动", "column-status": "状态", - "error-loading": "加载 {{type}} 时出错", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "作业" }, "repository-actions": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 15059e2e5dd..6c4db25605f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -11460,6 +11460,8 @@ "empty-state": { "no-jobs": "沒有作業…" }, + "enable-push-to-configured-branch-description": "", + "enable-push-to-configured-branch-label": "", "enhanced-features": { "description": "使用這些可選附加元件,充分利用 GitHub 整合", "description-instant-updates": "在 Grafana 中,一旦提交變更,就能即時取得更新。在變更上線之前,使用拉取請求來檢查並核准變更。", @@ -11677,14 +11679,14 @@ "read-only-local-tooltip": "", "read-only-remote-tooltip": "", "recent-jobs": { - "active-jobs": "進行中的作業", "column-action": "動作", "column-duration": "持續時間", "column-job-id": "", "column-message": "訊息", "column-started": "已開始", "column-status": "狀態", - "error-loading": "載入{{type}}時發生錯誤", + "error-loading-active-jobs": "", + "error-loading-historic-jobs": "", "jobs": "作業" }, "repository-actions": { From 24e4e0946dd14a4e51ffd6489c00390648c2377e Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Sat, 8 Nov 2025 08:58:51 -0300 Subject: [PATCH 107/209] Dashboard: Improve search error response (#113617) * Dashboard: Improve search error response * improve errors --- .../dashboard/legacysearcher/search_client.go | 33 ++++++------- .../legacysearcher/search_client_test.go | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index f729822e599..dde82737e23 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -8,6 +8,7 @@ import ( "strings" "google.golang.org/grpc" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/selection" claims "github.com/grafana/authlib/types" @@ -62,7 +63,7 @@ func ParseSortName(sortName string) (string, bool, error) { } } - return "", false, fmt.Errorf("no matching sort field found for: %s", sortName) + return "", false, apierrors.NewBadRequest(fmt.Sprintf("no matching sort field found for: %s", sortName)) } // nolint:gocyclo @@ -97,11 +98,11 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso case folders.RESOURCE: queryType = searchstore.TypeFolder default: - return nil, fmt.Errorf("bad type request") + return nil, apierrors.NewBadRequest("bad type request") } if len(req.Federated) > 1 { - return nil, fmt.Errorf("bad type request") + return nil, apierrors.NewBadRequest("bad type request") } if len(req.Federated) == 1 && @@ -117,7 +118,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso sortByField := "" if len(req.SortBy) != 0 { if len(req.SortBy) > 1 { - return nil, fmt.Errorf("only one sort field is supported") + return nil, apierrors.NewBadRequest("only one sort field is supported") } sort := req.SortBy[0] sortByField = strings.TrimPrefix(sort.Field, resource.SEARCH_FIELD_PREFIX) @@ -208,13 +209,13 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso case resource.SEARCH_FIELD_SOURCE_PATH: // only one value is supported in legacy search if len(vals) != 1 { - return nil, fmt.Errorf("only one repo path query is supported") + return nil, apierrors.NewBadRequest("only one repo path query is supported") } query.SourcePath = vals[0] case resource.SEARCH_FIELD_MANAGER_KIND: if len(vals) != 1 { - return nil, fmt.Errorf("only one manager kind supported") + return nil, apierrors.NewBadRequest("only one manager kind supported") } query.ManagedBy = utils.ManagerKind(vals[0]) @@ -226,20 +227,20 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso // only one value is supported in legacy search if len(vals) != 1 { - return nil, fmt.Errorf("only one repo name is supported") + return nil, apierrors.NewBadRequest("only one repo name is supported") } query.ManagerIdentity = vals[0] case unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE: if len(vals) != 1 { - return nil, fmt.Errorf("only one library panel uid is supported") + return nil, apierrors.NewBadRequest("only one library panel uid is supported") } // Make sure the query does not include incompatible combinations for _, f := range req.Options.Fields { switch f.Key { case resource.SEARCH_FIELD_NAME: - return nil, fmt.Errorf("libraryPanel query must not include explicit names") + return nil, apierrors.NewBadRequest("libraryPanel query must not include explicit names") } } @@ -257,7 +258,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso case resource.SEARCH_FIELD_TITLE_PHRASE: if len(vals) != 1 { - return nil, fmt.Errorf("only one title supported") + return nil, apierrors.NewBadRequest("only one title supported") } query.Title = vals[0] @@ -276,7 +277,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso // legacy sql query, since legacy search does not support this if query.ManagerIdentity != "" || len(query.ManagerIdentityNotIn) > 0 || query.ManagedBy != "" { if query.ManagedBy == utils.ManagerKindUnknown { - return nil, fmt.Errorf("query by manager identity also requires manager.kind parameter") + return nil, apierrors.NewBadRequest("query by manager identity also requires manager.kind parameter") } // for plugin and orphaned dashboards, we will only return the manager kind alongside the regular search response @@ -399,19 +400,19 @@ func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest, _ ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) { info, err := claims.ParseNamespace(req.Namespace) if err != nil { - return nil, fmt.Errorf("unable to read namespace") + return nil, apierrors.NewInternalError(fmt.Errorf("unable to read namespace: %w", err)) } if info.OrgID == 0 { - return nil, fmt.Errorf("invalid OrgID found in namespace") + return nil, apierrors.NewInternalError(fmt.Errorf("invalid OrgID found in namespace")) } if len(req.Kinds) != 1 { - return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback") + return nil, apierrors.NewBadRequest("only can query for dashboard kind in legacy fallback") } parts := strings.SplitN(req.Kinds[0], "/", 2) if len(parts) != 2 { - return nil, fmt.Errorf("invalid kind") + return nil, apierrors.NewBadRequest("invalid kind") } var count int64 @@ -421,7 +422,7 @@ func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.Re case folders.GROUP: count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true) default: - return nil, fmt.Errorf("invalid group") + return nil, apierrors.NewBadRequest("invalid group") } if err != nil { return nil, err diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go index 74f0cc2622c..8f1974bee68 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go @@ -645,6 +645,54 @@ func TestDashboardSearchClient_Search(t *testing.T) { require.Contains(t, err.Error(), "only one library panel uid is supported") require.Nil(t, resp) }) + + t.Run("Should reject library panel query when combined with explicit dashboard names", func(t *testing.T) { + req := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{ + { + Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE, + Operator: "=", + Values: []string{"test-library-panel"}, + }, + { + Key: resource.SEARCH_FIELD_NAME, + Operator: "=", + Values: []string{"dashboard-uid"}, + }, + }, + }, + } + resp, err := client.Search(ctx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "libraryPanel query must not include explicit names") + require.Nil(t, resp) + // Note: mockStore should NOT be called since validation happens before any store calls + }) + + t.Run("Should return empty results when library panel has no connected dashboards", func(t *testing.T) { + mockStore.On("GetDashboardsByLibraryPanelUID", mock.Anything, "unused-library-panel", int64(2)).Return([]*dashboards.DashboardRef{}, nil).Once() + + req := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{ + { + Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE, + Operator: "=", + Values: []string{"unused-library-panel"}, + }, + }, + }, + } + resp, err := client.Search(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, int64(0), resp.TotalHits) + require.Empty(t, resp.Results.Rows) + mockStore.AssertExpectations(t) + }) } func TestParseSortName(t *testing.T) { From 5806196797762f30666033398136d25015df1b44 Mon Sep 17 00:00:00 2001 From: Bradley <12028233+bradleypettit@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:00:05 +1000 Subject: [PATCH 108/209] Remove 'oss' label from Query Caching documentation (#113656) --- docs/sources/developers/http_api/query_and_resource_caching.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/sources/developers/http_api/query_and_resource_caching.md b/docs/sources/developers/http_api/query_and_resource_caching.md index 83da5e81d1d..1102f593a72 100644 --- a/docs/sources/developers/http_api/query_and_resource_caching.md +++ b/docs/sources/developers/http_api/query_and_resource_caching.md @@ -17,7 +17,6 @@ keywords: labels: products: - enterprise - - oss title: Query and Resource Caching HTTP API --- From 478ae025a33bd26e941a346f12ed25d30025a681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Grill?= Date: Mon, 10 Nov 2025 10:00:22 +0100 Subject: [PATCH 109/209] docs: fix typo (#111821) --- docs/sources/tutorials/run-grafana-behind-a-proxy/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md b/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md index 6b791739cbb..b1ea811531a 100644 --- a/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md +++ b/docs/sources/tutorials/run-grafana-behind-a-proxy/index.md @@ -266,7 +266,7 @@ http: You only need this if you don't handle the sub path serving via your reverse proxy configuration. {{< /admonition >}} -If you don't want or can't use the reverse proxy to handle serving Grafana from a _sub path_, you can set the configuration variable `server_from_sub_path` to `true`. +If you don't want or can't use the reverse proxy to handle serving Grafana from a _sub path_, you can set the configuration variable `serve_from_sub_path` to `true`. 1. Include the sub path at the end of the `root_url`. 1. Set `serve_from_sub_path` to `true`: From 834ff1be3c7789b60ae8c12d898186c0fb7dd782 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 10 Nov 2025 09:47:01 +0000 Subject: [PATCH 110/209] MultiCombobox: Fix a11y and enable storybook a11y tests (#113411) fix multicombobox a11y and enable storybook a11y tests --- eslint-suppressions.json | 8 - .../Combobox/MultiCombobox.story.tsx | 208 ++++++++++-------- .../src/components/Combobox/MultiCombobox.tsx | 14 +- 3 files changed, 123 insertions(+), 107 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5456b0d38dd..a5155bbdd95 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -597,14 +597,6 @@ "count": 5 } }, - "packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx": { - "no-restricted-syntax": { - "count": 1 - }, - "react-hooks/rules-of-hooks": { - "count": 5 - } - }, "packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx index b3794bf3fa9..581cb6223bf 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx @@ -1,7 +1,7 @@ import { action } from '@storybook/addon-actions'; import { useArgs, useEffect, useState } from '@storybook/preview-api'; import type { Meta, StoryFn, StoryObj } from '@storybook/react'; -import { ComponentProps } from 'react'; +import { ComponentProps, useId } from 'react'; import { Field } from '../Forms/Field'; @@ -17,8 +17,6 @@ const meta: Meta = { docs: { page: mdx, }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, }; @@ -48,22 +46,47 @@ type ManyOptionsArgs = storyArgs & { numberOfOptions?: number }; type Story = StoryObj; -export const Basic: Story = { - args: commonArgs, - render: (args) => { - const [{ value }, setArgs] = useArgs(); +const BasicStory: StoryFn = (args) => { + const [{ value }, setArgs] = useArgs(); + const comboboxId = useId(); - return ( + return ( + { onChangeAction(val); setArgs({ value: val }); }} /> - ); - }, + + ); +}; + +export const Basic: Story = { + args: commonArgs, + render: BasicStory, +}; + +const WithInfoOptionStory: StoryFn = (args) => { + const [{ value }, setArgs] = useArgs(); + const comboboxId = useId(); + + return ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); }; export const WithInfoOption: Story = { @@ -75,44 +98,37 @@ export const WithInfoOption: Story = { { label: 'Can’t find your country? Select “Other” or contact an admin', value: '__INFO__', infoOption: true }, ], }, - render: (args) => { - const [{ value }, setArgs] = useArgs(); - - return ( - { - onChangeAction(val); - setArgs({ value: val }); - }} - /> - ); - }, + render: WithInfoOptionStory, }; -export const AutoSize: Story = { - args: { ...commonArgs, width: 'auto', minWidth: 20 }, - render: (args) => { - const [{ value }, setArgs] = useArgs(); +const AutoSizeStory: StoryFn = (args) => { + const [{ value }, setArgs] = useArgs(); + const comboboxId = useId(); - return ( + return ( + { action('onChange')(val); setArgs({ value: val }); }} /> - ); - }, + + ); +}; + +export const AutoSize: Story = { + args: { ...commonArgs, width: 'auto', minWidth: 20 }, + render: AutoSizeStory, }; const ManyOptionsStory: StoryFn = ({ numberOfOptions = 1e4, ...args }) => { const [dynamicArgs, setArgs] = useArgs(); - const [options, setOptions] = useState([]); + const comboboxId = useId(); useEffect(() => { setTimeout(async () => { @@ -123,15 +139,18 @@ const ManyOptionsStory: StoryFn = ({ numberOfOptions = 1e4, ... const { onChange, ...rest } = args; return ( - { - setArgs({ value: opts }); - onChangeAction(opts); - }} - /> + + { + setArgs({ value: opts }); + onChangeAction(opts); + }} + /> + ); }; @@ -146,8 +165,8 @@ export const ManyOptions: StoryObj = { const ManyOptionsGroupedStory: StoryFn = ({ numberOfOptions = 1e5, ...args }) => { const [dynamicArgs, setArgs] = useArgs(); - const [options, setOptions] = useState([]); + const comboboxId = useId(); useEffect(() => { setTimeout(async () => { @@ -157,15 +176,18 @@ const ManyOptionsGroupedStory: StoryFn = ({ numberOfOptions = 1 }, [numberOfOptions]); const { onChange, ...rest } = args; return ( - { - setArgs({ value: opts }); - onChangeAction(opts); - }} - /> + + { + setArgs({ value: opts }); + onChangeAction(opts); + }} + /> + ); }; @@ -183,6 +205,28 @@ function loadOptionsWithLabels(inputValue: string) { return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`); } +const AsyncOptionsWithLabelsStory: StoryFn = (args) => { + const [dynamicArgs, setArgs] = useArgs(); + const comboboxId = useId(); + + return ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); +}; + export const AsyncOptionsWithLabels: Story = { name: 'Async - options returns labels', args: { @@ -190,25 +234,7 @@ export const AsyncOptionsWithLabels: Story = { value: [{ label: 'Option 69', value: '69' }], placeholder: 'Select an option', }, - render: (args) => { - const [dynamicArgs, setArgs] = useArgs(); - - return ( - - { - onChangeAction(val); - setArgs({ value: val }); - }} - /> - - ); - }, + render: AsyncOptionsWithLabelsStory, }; function loadOptionsOnlyValues(inputValue: string) { @@ -218,6 +244,28 @@ function loadOptionsOnlyValues(inputValue: string) { ); } +const AsyncOptionsWithOnlyValuesStory: StoryFn = (args) => { + const [dynamicArgs, setArgs] = useArgs(); + const comboboxId = useId(); + + return ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); +}; + export const AsyncOptionsWithOnlyValues: Story = { name: 'Async - options returns only values', args: { @@ -225,23 +273,5 @@ export const AsyncOptionsWithOnlyValues: Story = { value: [{ value: 'Option 69' }], placeholder: 'Select an option', }, - render: (args) => { - const [dynamicArgs, setArgs] = useArgs(); - - return ( - - { - onChangeAction(val); - setArgs({ value: val }); - }} - /> - - ); - }, + render: AsyncOptionsWithOnlyValuesStory, }; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 3a1e2856a68..82759befffb 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -57,6 +57,7 @@ export const MultiCombobox = (props: MultiComboboxPro 'data-testid': dataTestId, portalContainer, prefixIcon, + id, } = props; const styles = useStyles2(getComboboxStyles); @@ -152,17 +153,10 @@ export const MultiCombobox = (props: MultiComboboxPro }, }); - const { - getToggleButtonProps, - //getLabelProps, - isOpen, - highlightedIndex, - getMenuProps, - getInputProps, - getItemProps, - } = useCombobox({ + const { isOpen, highlightedIndex, getMenuProps, getInputProps, getItemProps } = useCombobox({ items: options, itemToString, + inputId: id, inputValue, selectedItem: null, stateReducer: (state, actionAndChanges) => { @@ -325,7 +319,7 @@ export const MultiCombobox = (props: MultiComboboxPro })} /> -
+
{isClearable && selectedItems.length > 0 && ( Date: Mon, 10 Nov 2025 07:29:33 -0300 Subject: [PATCH 111/209] ShortURL: Graduate api v1alpha1 to v1beta1 (#113597) --- apps/shorturl/kinds/manifest.cue | 63 +------ .../{v1alpha1 => v1beta1}/constants.go | 4 +- .../shorturl_client_gen.go | 2 +- .../shorturl_codec_gen.go | 2 +- .../shorturl_getgoto_response_types_gen.go | 2 +- .../shorturl_metadata_gen.go | 2 +- .../shorturl_object_gen.go | 2 +- .../shorturl_schema_gen.go | 4 +- .../shorturl_spec_gen.go | 2 +- .../shorturl_status_gen.go | 2 +- apps/shorturl/pkg/apis/shorturl_manifest.go | 18 +- apps/shorturl/pkg/app/app.go | 18 +- .../shorturl_object_gen.ts | 0 .../types.metadata.gen.ts | 0 .../{v1alpha1 => v1beta1}/types.spec.gen.ts | 0 .../{v1alpha1 => v1beta1}/types.status.gen.ts | 0 packages/grafana-api-clients/package.json | 6 +- .../src/clients/rtkq/index.ts | 6 +- .../shorturl/{v1alpha1 => v1beta1}/baseAPI.ts | 4 +- .../{v1alpha1 => v1beta1}/endpoints.gen.ts | 2 +- .../shorturl/{v1alpha1 => v1beta1}/index.ts | 0 .../src/scripts/generate-rtk-apis.ts | 4 +- pkg/api/short_url.go | 10 +- pkg/registry/apps/shorturl/conversions.go | 2 +- pkg/registry/apps/shorturl/legacy_storage.go | 2 +- pkg/registry/apps/shorturl/register.go | 2 +- pkg/registry/apps/shorturl/status.go | 2 +- pkg/services/cleanup/cleanup.go | 6 +- ...json => shorturl.grafana.app-v1beta1.json} | 166 +++++++++--------- pkg/tests/apis/openapi_test.go | 2 +- pkg/tests/apis/shorturl/shorturl_test.go | 14 +- .../shorturl/{v1alpha1 => v1beta1}/index.ts | 4 +- public/app/core/utils/shortLinks.test.ts | 10 +- public/app/core/utils/shortLinks.ts | 8 +- 34 files changed, 158 insertions(+), 213 deletions(-) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/constants.go (91%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_client_gen.go (99%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_codec_gen.go (97%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_getgoto_response_types_gen.go (92%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_metadata_gen.go (98%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_object_gen.go (99%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_schema_gen.go (89%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_spec_gen.go (95%) rename apps/shorturl/pkg/apis/shorturl/{v1alpha1 => v1beta1}/shorturl_status_gen.go (99%) rename apps/shorturl/plugin/src/generated/shorturl/{v1alpha1 => v1beta1}/shorturl_object_gen.ts (100%) rename apps/shorturl/plugin/src/generated/shorturl/{v1alpha1 => v1beta1}/types.metadata.gen.ts (100%) rename apps/shorturl/plugin/src/generated/shorturl/{v1alpha1 => v1beta1}/types.spec.gen.ts (100%) rename apps/shorturl/plugin/src/generated/shorturl/{v1alpha1 => v1beta1}/types.status.gen.ts (100%) rename packages/grafana-api-clients/src/clients/rtkq/shorturl/{v1alpha1 => v1beta1}/baseAPI.ts (82%) rename packages/grafana-api-clients/src/clients/rtkq/shorturl/{v1alpha1 => v1beta1}/endpoints.gen.ts (99%) rename packages/grafana-api-clients/src/clients/rtkq/shorturl/{v1alpha1 => v1beta1}/index.ts (100%) rename pkg/tests/apis/openapi_snapshots/{shorturl.grafana.app-v1alpha1.json => shorturl.grafana.app-v1beta1.json} (95%) rename public/app/api/clients/shorturl/{v1alpha1 => v1beta1}/index.ts (90%) diff --git a/apps/shorturl/kinds/manifest.cue b/apps/shorturl/kinds/manifest.cue index 27519d040af..c078f591c81 100644 --- a/apps/shorturl/kinds/manifest.cue +++ b/apps/shorturl/kinds/manifest.cue @@ -1,68 +1,11 @@ package kinds manifest: { - // appName is the unique name of your app. It is used to reference the app from other config objects, - // and to generate the group used by your app in the app platform API. appName: "shorturl" groupOverride: "shorturl.grafana.app" - // groupOverride can be used to specify a non-appName-based API group. - // By default, an app's API group is LOWER(REPLACE(appName, '-', '')).ext.grafana.com, - // but there are cases where this needs to be changed. - // Keep in mind that changing this after an app is deployed can cause problems with clients and/or kind data. - // groupOverride: foo.ext.grafana.app - - // versions is a map of versions supported by your app. Version names should follow the format "v" or - // "v(alpha|beta)". Each version contains the kinds your app manages for that version. - // If your app needs access to kinds managed by another app, use permissions.accessKinds to allow your app access. versions: { - "v1alpha1": v1alpha1 - } - // extraPermissions contains any additional permissions your app may require to function. - // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). - extraPermissions: { - // If your app needs access to additional kinds supplied by other apps, you can list them here - accessKinds: [ - // Here is an example for your app accessing the playlist kind for reads and watch - // { - // group: "playlist.grafana.app" - // resource: "playlists" - // actions: ["get","list","watch"] - // } - ] + "v1beta1": { + kinds: [shorturl] + } } } - -// v1alpha1 is the v1alpha1 version of the app's API. -// It includes kinds which the v1alpha1 API serves, and (future) custom routes served globally from the v1alpha1 version. -v1alpha1: { - // kinds is the list of kinds served by this version - kinds: [shorturl] - // [OPTIONAL] - // served indicates whether this particular version is served by the API server. - // served should be set to false before a version is removed from the manifest entirely. - // served defaults to true if not present. - served: true - // [OPTIONAL] - // Codegen is a trait that tells the grafana-app-sdk, or other code generation tooling, how to process this kind. - // If not present, default values within the codegen trait are used. - // If you wish to specify codegen per-version, put this section in the version's object - // (for example, v1alpha1) instead. - codegen: { - // [OPTIONAL] - // ts contains TypeScript code generation properties for the kind - ts: { - // [OPTIONAL] - // enabled indicates whether the CLI should generate front-end TypeScript code for the kind. - // Defaults to true if not present. - enabled: true - } - // [OPTIONAL] - // go contains go code generation properties for the kind - go: { - // [OPTIONAL] - // enabled indicates whether the CLI should generate back-end go code for the kind. - // Defaults to true if not present. - enabled: true - } - } -} diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/constants.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/constants.go similarity index 91% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/constants.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/constants.go index e6000ff1f42..2fe732d2371 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/constants.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/constants.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import "k8s.io/apimachinery/pkg/runtime/schema" @@ -6,7 +6,7 @@ const ( // APIGroup is the API group used by all kinds in this package APIGroup = "shorturl.grafana.app" // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" + APIVersion = "v1beta1" ) var ( diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_client_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_client_gen.go similarity index 99% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_client_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_client_gen.go index 1c2a81b3c34..6528fd89b98 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_client_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_client_gen.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import ( "context" diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_codec_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_codec_gen.go similarity index 97% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_codec_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_codec_gen.go index 15d7493bb80..37b343f5a53 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_codec_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_codec_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_getgoto_response_types_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_getgoto_response_types_gen.go similarity index 92% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_getgoto_response_types_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_getgoto_response_types_gen.go index dfe47f8cbc1..c622afb824c 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_getgoto_response_types_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_getgoto_response_types_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type GetGoto struct { diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_metadata_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_metadata_gen.go similarity index 98% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_metadata_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_metadata_gen.go index e7509c1a38e..bb2aa3dda44 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_metadata_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_metadata_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 import ( time "time" diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_object_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_object_gen.go similarity index 99% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_object_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_object_gen.go index 734af74aae3..cfa817bc239 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_object_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_object_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "fmt" diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_schema_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_schema_gen.go similarity index 89% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_schema_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_schema_gen.go index 78f0d5839d2..2cebbc15437 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_schema_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_schema_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "github.com/grafana/grafana-app-sdk/resource" @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaShortURL = resource.NewSimpleSchema("shorturl.grafana.app", "v1alpha1", &ShortURL{}, &ShortURLList{}, resource.WithKind("ShortURL"), + schemaShortURL = resource.NewSimpleSchema("shorturl.grafana.app", "v1beta1", &ShortURL{}, &ShortURLList{}, resource.WithKind("ShortURL"), resource.WithPlural("shorturls"), resource.WithScope(resource.NamespacedScope)) kindShortURL = resource.Kind{ Schema: schemaShortURL, diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_spec_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_spec_gen.go similarity index 95% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_spec_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_spec_gen.go index 56a453e5b69..92b14036f7a 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_spec_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_spec_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type ShortURLSpec struct { diff --git a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_status_gen.go b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_status_gen.go similarity index 99% rename from apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_status_gen.go rename to apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_status_gen.go index cfbeb15b338..d8400bd7882 100644 --- a/apps/shorturl/pkg/apis/shorturl/v1alpha1/shorturl_status_gen.go +++ b/apps/shorturl/pkg/apis/shorturl/v1beta1/shorturl_status_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type ShortURLstatusOperatorState struct { diff --git a/apps/shorturl/pkg/apis/shorturl_manifest.go b/apps/shorturl/pkg/apis/shorturl_manifest.go index 8cc2e9acf90..4e8acbe5797 100644 --- a/apps/shorturl/pkg/apis/shorturl_manifest.go +++ b/apps/shorturl/pkg/apis/shorturl_manifest.go @@ -16,22 +16,22 @@ import ( "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" - v1alpha1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + v1beta1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" ) var ( - rawSchemaShortURLv1alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"ShortURL":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"path":{"description":"The original path to where the short url is linking too e.g. https://localhost:3000/eer8i1kictngga/new-dashboard-with-lib-panel","type":"string"}},"required":["path"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"lastSeenAt":{"description":"The last time the short URL was used, 0 is the initial value","type":"integer"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"required":["lastSeenAt"],"type":"object"}}`) - versionSchemaShortURLv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaShortURLv1alpha1, &versionSchemaShortURLv1alpha1) + rawSchemaShortURLv1beta1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"ShortURL":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"path":{"description":"The original path to where the short url is linking too e.g. https://localhost:3000/eer8i1kictngga/new-dashboard-with-lib-panel","type":"string"}},"required":["path"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"lastSeenAt":{"description":"The last time the short URL was used, 0 is the initial value","type":"integer"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"required":["lastSeenAt"],"type":"object"}}`) + versionSchemaShortURLv1beta1 app.VersionSchema + _ = json.Unmarshal(rawSchemaShortURLv1beta1, &versionSchemaShortURLv1beta1) ) var appManifestData = app.ManifestData{ AppName: "shorturl", Group: "shorturl.grafana.app", - PreferredVersion: "v1alpha1", + PreferredVersion: "v1beta1", Versions: []app.ManifestVersion{ { - Name: "v1alpha1", + Name: "v1beta1", Served: true, Kinds: []app.ManifestVersionKind{ { @@ -47,7 +47,7 @@ var appManifestData = app.ManifestData{ }, }, }, - Schema: &versionSchemaShortURLv1alpha1, + Schema: &versionSchemaShortURLv1beta1, Routes: map[string]spec3.PathProps{ "/goto": { Get: &spec3.Operation{ @@ -106,7 +106,7 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "ShortURL/v1alpha1": v1alpha1.ShortURLKind(), + "ShortURL/v1beta1": v1beta1.ShortURLKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. @@ -117,7 +117,7 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist } var customRouteToGoResponseType = map[string]any{ - "v1alpha1|ShortURL|goto|GET": v1alpha1.GetGoto{}, + "v1beta1|ShortURL|goto|GET": v1beta1.GetGoto{}, } // ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. diff --git a/apps/shorturl/pkg/app/app.go b/apps/shorturl/pkg/app/app.go index bb97db78a93..dc2e604aba3 100644 --- a/apps/shorturl/pkg/app/app.go +++ b/apps/shorturl/pkg/app/app.go @@ -18,7 +18,7 @@ import ( "github.com/grafana/grafana-app-sdk/operator" "github.com/grafana/grafana-app-sdk/resource" "github.com/grafana/grafana-app-sdk/simple" - shorturlv1alpha1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + shorturlv1beta1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" ) @@ -31,11 +31,11 @@ var ( func New(cfg app.Config) (app.App, error) { cfg.KubeConfig.APIPath = "apis" tmp, err := k8s.NewClientRegistry(cfg.KubeConfig, k8s.DefaultClientConfig()). - ClientFor(shorturlv1alpha1.ShortURLKind()) + ClientFor(shorturlv1beta1.ShortURLKind()) if err != nil { return nil, fmt.Errorf("unable to create client") } - client := shorturlv1alpha1.NewShortURLClient(tmp) + client := shorturlv1beta1.NewShortURLClient(tmp) simpleConfig := simple.AppConfig{ Name: "shorturl", @@ -49,11 +49,11 @@ func New(cfg app.Config) (app.App, error) { }, ManagedKinds: []simple.AppManagedKind{ { - Kind: shorturlv1alpha1.ShortURLKind(), + Kind: shorturlv1beta1.ShortURLKind(), Validator: &simple.Validator{ ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error { // Cast the incoming object to ShortURL for validation - shortURL, ok := req.Object.(*shorturlv1alpha1.ShortURL) + shortURL, ok := req.Object.(*shorturlv1beta1.ShortURL) if !ok { return fmt.Errorf("expected ShortURL object, got %T", req.Object) } @@ -105,7 +105,7 @@ func New(cfg app.Config) (app.App, error) { url = url + "/" + info.Spec.Path if req.URL.Query().Get("redirect") == "false" { // helpful for testing - return json.NewEncoder(w).Encode(shorturlv1alpha1.GetGoto{ + return json.NewEncoder(w).Encode(shorturlv1beta1.GetGoto{ Url: url, }) } @@ -133,10 +133,10 @@ func New(cfg app.Config) (app.App, error) { func GetKinds() map[schema.GroupVersion][]resource.Kind { gv := schema.GroupVersion{ - Group: shorturlv1alpha1.ShortURLKind().Group(), - Version: shorturlv1alpha1.ShortURLKind().Version(), + Group: shorturlv1beta1.ShortURLKind().Group(), + Version: shorturlv1beta1.ShortURLKind().Version(), } return map[schema.GroupVersion][]resource.Kind{ - gv: {shorturlv1alpha1.ShortURLKind()}, + gv: {shorturlv1beta1.ShortURLKind()}, } } diff --git a/apps/shorturl/plugin/src/generated/shorturl/v1alpha1/shorturl_object_gen.ts b/apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen.ts similarity index 100% rename from apps/shorturl/plugin/src/generated/shorturl/v1alpha1/shorturl_object_gen.ts rename to apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen.ts diff --git a/apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.metadata.gen.ts b/apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.metadata.gen.ts similarity index 100% rename from apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.metadata.gen.ts rename to apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.metadata.gen.ts diff --git a/apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.spec.gen.ts b/apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.spec.gen.ts similarity index 100% rename from apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.spec.gen.ts rename to apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.spec.gen.ts diff --git a/apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.status.gen.ts b/apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.status.gen.ts similarity index 100% rename from apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.status.gen.ts rename to apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.status.gen.ts diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 3f5482340a6..79809210045 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -76,9 +76,9 @@ "import": "./src/clients/rtkq/provisioning/v0alpha1/index.ts", "require": "./src/clients/rtkq/provisioning/v0alpha1/index.ts" }, - "./rtkq/shorturl/v1alpha1": { - "import": "./src/clients/rtkq/shorturl/v1alpha1/index.ts", - "require": "./src/clients/rtkq/shorturl/v1alpha1/index.ts" + "./rtkq/shorturl/v1beta1": { + "import": "./src/clients/rtkq/shorturl/v1beta1/index.ts", + "require": "./src/clients/rtkq/shorturl/v1beta1/index.ts" } }, "publishConfig": { diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index 77c02e78170..4bdede94ed9 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -11,7 +11,7 @@ import { generatedAPI as playlistAPIv0alpha1 } from './playlist/v0alpha1'; import { generatedAPI as preferencesUserAPI } from './preferences/user'; import { generatedAPI as preferencesAPIv1alpha1 } from './preferences/v1alpha1'; import { generatedAPI as provisioningAPIv0alpha1 } from './provisioning/v0alpha1'; -import { generatedAPI as shortURLAPIv1alpha1 } from './shorturl/v1alpha1'; +import { generatedAPI as shortURLAPIv1beta1 } from './shorturl/v1beta1'; import { generatedAPI as legacyUserAPI } from './user'; // PLOP_INJECT_IMPORT @@ -26,7 +26,7 @@ export const allMiddleware = [ preferencesAPIv1alpha1.middleware, preferencesUserAPI.middleware, provisioningAPIv0alpha1.middleware, - shortURLAPIv1alpha1.middleware, + shortURLAPIv1beta1.middleware, correlationsAPIv0alpha1.middleware, legacyUserAPI.middleware, // PLOP_INJECT_MIDDLEWARE @@ -43,7 +43,7 @@ export const allReducers = { [preferencesAPIv1alpha1.reducerPath]: preferencesAPIv1alpha1.reducer, [preferencesUserAPI.reducerPath]: preferencesUserAPI.reducer, [provisioningAPIv0alpha1.reducerPath]: provisioningAPIv0alpha1.reducer, - [shortURLAPIv1alpha1.reducerPath]: shortURLAPIv1alpha1.reducer, + [shortURLAPIv1beta1.reducerPath]: shortURLAPIv1beta1.reducer, [correlationsAPIv0alpha1.reducerPath]: correlationsAPIv0alpha1.reducer, [legacyUserAPI.reducerPath]: legacyUserAPI.reducer, // PLOP_INJECT_REDUCER diff --git a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/baseAPI.ts similarity index 82% rename from packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/baseAPI.ts rename to packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/baseAPI.ts index 4d1f28bd5d8..c5e91c9f452 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/baseAPI.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/baseAPI.ts @@ -4,11 +4,11 @@ import { getAPIBaseURL } from '../../../../utils/utils'; import { createBaseQuery } from '../../createBaseQuery'; export const API_GROUP = 'shorturl.grafana.app' as const; -export const API_VERSION = 'v1alpha1' as const; +export const API_VERSION = 'v1beta1' as const; export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); export const api = createApi({ - reducerPath: 'shortURLAPIv1alpha1', + reducerPath: 'shortURLAPIv1beta1', baseQuery: createBaseQuery({ baseURL: BASE_URL, }), diff --git a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts similarity index 99% rename from packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts rename to packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts index a4bfc1df1ba..06331d13171 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/shorturl.grafana.app/v1alpha1/` }), + query: () => ({ url: `/apis/shorturl.grafana.app/v1beta1/` }), providesTags: ['API Discovery'], }), listShortUrl: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/index.ts similarity index 100% rename from packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/index.ts rename to packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/index.ts diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index a9422a02815..afa6f35372f 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -100,7 +100,9 @@ const config: ConfigFile = { ...createAPIConfig('playlist', 'v0alpha1'), ...createAPIConfig('preferences', 'v1alpha1'), ...createAPIConfig('provisioning', 'v0alpha1'), - ...createAPIConfig('shorturl', 'v1alpha1'), + ...createAPIConfig('shorturl', 'v1beta1'), + ...createAPIConfig('shorturl', 'v1beta1'), + ...createAPIConfig('shorturl', 'v1beta1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator }, }; diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 1597c1bddac..7b17fdb01a7 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -11,7 +11,7 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" - "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" @@ -120,7 +120,7 @@ type shortURLK8sHandler struct { func newShortURLK8sHandler(hs *HTTPServer) *shortURLK8sHandler { return &shortURLK8sHandler{ - gvr: v1alpha1.ShortURLKind().GroupVersionResource(), + gvr: v1beta1.ShortURLKind().GroupVersionResource(), namespacer: request.GetNamespaceMapper(hs.Cfg), clientConfigProvider: hs.clientConfigProvider, cfg: hs.Cfg, @@ -164,9 +164,9 @@ func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmode } result := client.RESTClient().Get(). - Prefix("apis", v1alpha1.APIGroup, v1alpha1.APIVersion). + Prefix("apis", v1beta1.APIGroup, v1beta1.APIVersion). Namespace(sk8s.namespacer(c.OrgID)). - Resource(v1alpha1.ShortURLKind().Plural()). + Resource(v1beta1.ShortURLKind().Plural()). Name(uid). SubResource("goto"). Param("redirect", "false"). // returns the URL and then we will do the redirect @@ -183,7 +183,7 @@ func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmode return } - value := &v1alpha1.GetGoto{} + value := &v1beta1.GetGoto{} if err = json.Unmarshal(body, value); err != nil { c.JsonApiErr(500, "unmarshal", err) return diff --git a/pkg/registry/apps/shorturl/conversions.go b/pkg/registry/apps/shorturl/conversions.go index c23f014adbd..4392dd8a44b 100644 --- a/pkg/registry/apps/shorturl/conversions.go +++ b/pkg/registry/apps/shorturl/conversions.go @@ -8,7 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/shorturls" diff --git a/pkg/registry/apps/shorturl/legacy_storage.go b/pkg/registry/apps/shorturl/legacy_storage.go index de36560b679..3ad408b1b4b 100644 --- a/pkg/registry/apps/shorturl/legacy_storage.go +++ b/pkg/registry/apps/shorturl/legacy_storage.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" - shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" diff --git a/pkg/registry/apps/shorturl/register.go b/pkg/registry/apps/shorturl/register.go index 423eae92073..c5d314ff2bc 100644 --- a/pkg/registry/apps/shorturl/register.go +++ b/pkg/registry/apps/shorturl/register.go @@ -12,7 +12,7 @@ import ( appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/simple" "github.com/grafana/grafana/apps/shorturl/pkg/apis" - shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" shorturlapp "github.com/grafana/grafana/apps/shorturl/pkg/app" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" diff --git a/pkg/registry/apps/shorturl/status.go b/pkg/registry/apps/shorturl/status.go index ed9fe063a98..2e50c3f6b78 100644 --- a/pkg/registry/apps/shorturl/status.go +++ b/pkg/registry/apps/shorturl/status.go @@ -14,7 +14,7 @@ import ( "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/logging" - shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/shorturls" ) diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index 0e4fe0d319f..fde4ab729d5 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/dynamic" - "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" @@ -325,7 +325,7 @@ func (srv *CleanUpService) deleteStaleKubernetesShortURLs(ctx context.Context) { } // Set up the GroupVersionResource for shortURLs - gvr := v1alpha1.ShortURLKind().GroupVersionResource() + gvr := v1beta1.ShortURLKind().GroupVersionResource() // Calculate the expiration time expirationTime := time.Now().Add(-time.Duration(srv.Cfg.ShortLinkExpiration*24) * time.Hour) @@ -350,7 +350,7 @@ func (srv *CleanUpService) deleteStaleKubernetesShortURLs(ctx context.Context) { // Check each shortURL for expiration for _, item := range shortURLs.Items { // Convert unstructured object to ShortURL struct - var shortURL v1alpha1.ShortURL + var shortURL v1beta1.ShortURL err := runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, &shortURL) if err != nil { logger.Error("Failed to convert unstructured object to ShortURL", "name", item.GetName(), "namespace", item.GetNamespace(), "error", err.Error()) diff --git a/pkg/tests/apis/openapi_snapshots/shorturl.grafana.app-v1alpha1.json b/pkg/tests/apis/openapi_snapshots/shorturl.grafana.app-v1beta1.json similarity index 95% rename from pkg/tests/apis/openapi_snapshots/shorturl.grafana.app-v1alpha1.json rename to pkg/tests/apis/openapi_snapshots/shorturl.grafana.app-v1beta1.json index 574ce2f474a..88cabe3ce2d 100644 --- a/pkg/tests/apis/openapi_snapshots/shorturl.grafana.app-v1alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/shorturl.grafana.app-v1beta1.json @@ -1,10 +1,10 @@ { "openapi": "3.0.0", "info": { - "title": "shorturl.grafana.app/v1alpha1" + "title": "shorturl.grafana.app/v1beta1" }, "paths": { - "/apis/shorturl.grafana.app/v1alpha1/": { + "/apis/shorturl.grafana.app/v1beta1/": { "get": { "tags": [ "API Discovery" @@ -35,7 +35,7 @@ } } }, - "/apis/shorturl.grafana.app/v1alpha1/namespaces/{namespace}/shorturls": { + "/apis/shorturl.grafana.app/v1beta1/namespaces/{namespace}/shorturls": { "get": { "tags": [ "ShortURL" @@ -140,27 +140,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLList" } }, "application/json;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLList" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLList" } }, "application/vnd.kubernetes.protobuf;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLList" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLList" } } } @@ -169,7 +169,7 @@ "x-kubernetes-action": "list", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -212,17 +212,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } }, @@ -234,17 +234,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -254,17 +254,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -274,17 +274,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -293,7 +293,7 @@ "x-kubernetes-action": "post", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -447,7 +447,7 @@ "x-kubernetes-action": "deletecollection", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -473,7 +473,7 @@ } ] }, - "/apis/shorturl.grafana.app/v1alpha1/namespaces/{namespace}/shorturls/{name}": { + "/apis/shorturl.grafana.app/v1beta1/namespaces/{namespace}/shorturls/{name}": { "get": { "tags": [ "ShortURL" @@ -486,17 +486,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -505,7 +505,7 @@ "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -548,17 +548,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } }, @@ -570,17 +570,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -590,17 +590,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -609,7 +609,7 @@ "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -711,7 +711,7 @@ "x-kubernetes-action": "delete", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -790,17 +790,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -810,17 +810,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -829,7 +829,7 @@ "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -865,7 +865,7 @@ } ] }, - "/apis/shorturl.grafana.app/v1alpha1/namespaces/{namespace}/shorturls/{name}/goto": { + "/apis/shorturl.grafana.app/v1beta1/namespaces/{namespace}/shorturls/{name}/goto": { "get": { "tags": [ "ShortURL" @@ -878,7 +878,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.GetGoto" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.GetGoto" } } } @@ -887,7 +887,7 @@ "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ResourceCallOptions" } }, @@ -914,7 +914,7 @@ } ] }, - "/apis/shorturl.grafana.app/v1alpha1/namespaces/{namespace}/shorturls/{name}/status": { + "/apis/shorturl.grafana.app/v1beta1/namespaces/{namespace}/shorturls/{name}/status": { "get": { "tags": [ "ShortURL" @@ -927,17 +927,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -946,7 +946,7 @@ "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -989,17 +989,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } }, @@ -1011,17 +1011,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -1031,17 +1031,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -1050,7 +1050,7 @@ "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -1129,17 +1129,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -1149,17 +1149,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } } } @@ -1168,7 +1168,7 @@ "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { "group": "shorturl.grafana.app", - "version": "v1alpha1", + "version": "v1beta1", "kind": "ShortURL" } }, @@ -1207,7 +1207,7 @@ }, "components": { "schemas": { - "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.GetGoto": { + "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.GetGoto": { "type": "object", "required": [ "url" @@ -1218,7 +1218,7 @@ } } }, - "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL": { + "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL": { "type": "object", "required": [ "kind", @@ -1244,21 +1244,21 @@ ] }, "spec": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLSpec" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLSpec" }, "status": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLStatus" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLStatus" } }, "x-kubernetes-group-version-kind": [ { "group": "shorturl.grafana.app", "kind": "ShortURL", - "version": "v1alpha1" + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLList": { + "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLList": { "type": "object", "required": [ "metadata", @@ -1275,7 +1275,7 @@ "default": {}, "allOf": [ { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURL" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURL" } ] } @@ -1297,11 +1297,11 @@ { "group": "shorturl.grafana.app", "kind": "ShortURLList", - "version": "v1alpha1" + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLOperatorState": { + "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLOperatorState": { "type": "object", "required": [ "lastEvaluation", @@ -1336,7 +1336,7 @@ }, "additionalProperties": false }, - "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLSpec": { + "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLSpec": { "type": "object", "required": [ "path" @@ -1349,7 +1349,7 @@ }, "additionalProperties": false }, - "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLStatus": { + "com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLStatus": { "type": "object", "required": [ "lastSeenAt" @@ -1371,7 +1371,7 @@ "description": "operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.", "type": "object", "additionalProperties": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1alpha1.ShortURLOperatorState" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.shorturl.pkg.apis.shorturl.v1beta1.ShortURLOperatorState" } } }, diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index 1e56532fb84..b218095d938 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -108,7 +108,7 @@ func TestIntegrationOpenAPIs(t *testing.T) { Version: "v0alpha1", }, { Group: "shorturl.grafana.app", - Version: "v1alpha1", + Version: "v1beta1", }, { Group: "testdata.datasource.grafana.app", Version: "v0alpha1", diff --git a/pkg/tests/apis/shorturl/shorturl_test.go b/pkg/tests/apis/shorturl/shorturl_test.go index 125eb63966b..4eae45ecf57 100644 --- a/pkg/tests/apis/shorturl/shorturl_test.go +++ b/pkg/tests/apis/shorturl/shorturl_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - shorturlV1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + shorturlV1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1" "github.com/grafana/grafana/pkg/api/dtos" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/options" @@ -230,7 +230,7 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest obj := apis.DoRequest(helper, apis.RequestParams{ User: client.Args.User, Method: http.MethodPost, - Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Path: "/apis/shorturl.grafana.app/v1beta1/namespaces/default/shorturls", Body: []byte(`{ "metadata": { "generateName": "test-" }, "spec": { "path": "d/xCmMwXdVz/k8s-dual-write" } }`), }, &unstructured.Unstructured{}) require.NotNil(t, obj.Result) @@ -266,7 +266,7 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest obj := apis.DoRequest(helper, apis.RequestParams{ User: client.Args.User, Method: http.MethodPost, - Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Path: "/apis/shorturl.grafana.app/v1beta1/namespaces/default/shorturls", Body: []byte(`{ "metadata": { "generateName": "redirect-" }, "spec": { "path": "d/test/redirect" } }`), }, &unstructured.Unstructured{}) require.NotNil(t, obj.Result) @@ -319,7 +319,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { obj := apis.DoRequest(helper, apis.RequestParams{ User: client.Args.User, Method: http.MethodPost, - Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Path: "/apis/shorturl.grafana.app/v1beta1/namespaces/default/shorturls", Body: []byte(`{ "metadata": { "generateName": "unified-" }, "spec": { "path": "d/xCmMwXdVz/unified-only" } }`), }, &unstructured.Unstructured{}) require.NotNil(t, obj.Result) @@ -381,7 +381,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { response := apis.DoRequest(helper, apis.RequestParams{ User: client.Args.User, Method: http.MethodPost, - Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Path: "/apis/shorturl.grafana.app/v1beta1/namespaces/default/shorturls", Body: []byte(invalidBody), }, (*unstructured.Unstructured)(nil)) @@ -418,7 +418,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { response := apis.DoRequest(helper, apis.RequestParams{ User: client.Args.User, Method: http.MethodPost, - Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Path: "/apis/shorturl.grafana.app/v1beta1/namespaces/default/shorturls", Body: []byte(validBody), }, &unstructured.Unstructured{}) @@ -448,7 +448,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { obj := apis.DoRequest[unstructured.Unstructured](helper, apis.RequestParams{ User: client.Args.User, Method: http.MethodPost, - Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Path: "/apis/shorturl.grafana.app/v1beta1/namespaces/default/shorturls", Body: []byte(`{ "metadata": { "generateName": "redirect-unified-" }, "spec": { "path": "d/test/unified-redirect" } }`), }, &unstructured.Unstructured{}) require.NotNil(t, obj.Result) diff --git a/public/app/api/clients/shorturl/v1alpha1/index.ts b/public/app/api/clients/shorturl/v1beta1/index.ts similarity index 90% rename from public/app/api/clients/shorturl/v1alpha1/index.ts rename to public/app/api/clients/shorturl/v1beta1/index.ts index 85d0576dd91..ff238707b93 100644 --- a/public/app/api/clients/shorturl/v1alpha1/index.ts +++ b/public/app/api/clients/shorturl/v1beta1/index.ts @@ -1,6 +1,6 @@ -import { generatedAPI } from '@grafana/api-clients/rtkq/shorturl/v1alpha1'; +import { generatedAPI } from '@grafana/api-clients/rtkq/shorturl/v1beta1'; -export const shortURLAPIv1alpha1 = generatedAPI.enhanceEndpoints({ +export const shortURLAPIv1beta1 = generatedAPI.enhanceEndpoints({ endpoints: { createShortUrl: (endpointDefinition) => { const originalQuery = endpointDefinition.query; diff --git a/public/app/core/utils/shortLinks.test.ts b/public/app/core/utils/shortLinks.test.ts index a37c55ba5e6..c6732d6f4f1 100644 --- a/public/app/core/utils/shortLinks.test.ts +++ b/public/app/core/utils/shortLinks.test.ts @@ -2,9 +2,9 @@ import { LogRowModel } from '@grafana/data'; import { config } from '@grafana/runtime'; import { createLogRow } from 'app/features/logs/components/mocks/logRow'; -import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1alpha1/shorturl_object_gen'; -import { defaultSpec } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.spec.gen'; -import { defaultStatus } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1alpha1/types.status.gen'; +import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; +import { defaultSpec } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.spec.gen'; +import { defaultStatus } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.status.gen'; import { createShortLink, createAndCopyShortLink, getLogsPermalinkRange, buildShortUrl } from './shortLinks'; @@ -122,7 +122,7 @@ describe('buildShortUrl', () => { it('builds short URL with metadata name and namespace', () => { const shortUrl: ShortURL = { kind: 'ShortURL', - apiVersion: 'shorturl.grafana.app/v1alpha1', + apiVersion: 'shorturl.grafana.app/v1beta1', metadata: { name: 'abc123def', namespace: 'org-5', @@ -140,7 +140,7 @@ describe('buildShortUrl', () => { const shortUrl: ShortURL = { kind: 'ShortURL', - apiVersion: 'shorturl.grafana.app/v1alpha1', + apiVersion: 'shorturl.grafana.app/v1beta1', metadata: { name: 'xyz789', namespace: 'org-1', diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 011b5c66baa..536911f9942 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -4,14 +4,14 @@ import { AbsoluteTimeRange, LogRowModel, UrlQueryMap } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; -import { shortURLAPIv1alpha1 } from 'app/api/clients/shorturl/v1alpha1'; +import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { dispatch } from 'app/store/store'; -import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1alpha1/shorturl_object_gen'; +import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; import { extractErrorMessage } from '../../api/utils'; import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils'; @@ -46,9 +46,9 @@ export const createShortLink = async function (path: string) { if (config.featureToggles.useKubernetesShortURLsAPI) { // Use RTK API - it handles caching/failures/retries automatically const result = await dispatch( - shortURLAPIv1alpha1.endpoints.createShortUrl.initiate({ + shortURLAPIv1beta1.endpoints.createShortUrl.initiate({ shortUrl: { - apiVersion: 'shorturl.grafana.app/v1alpha1', + apiVersion: 'shorturl.grafana.app/v1beta1', kind: 'ShortURL', metadata: {}, spec: { From 51c18b57a27a1433f18190c6aaf9c6f959c0000b Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 10 Nov 2025 10:57:33 +0000 Subject: [PATCH 112/209] Chore: some `any` fixes (#113290) * fix some anys in select styles * remove anys from ModalsContext * improve some types * improve ArrayDataFrame * restore behavior * use unknown for Parser type --- eslint-suppressions.json | 41 ++++++------------- .../src/dataframe/ArrayDataFrame.ts | 6 +-- .../src/dataframe/DataFrameView.ts | 2 +- .../src/dataframe/MutableDataFrame.ts | 12 +++--- .../src/dataframe/StreamingDataFrame.ts | 4 +- .../src/dataframe/processDataFrame.test.ts | 8 ++-- .../src/dataframe/processDataFrame.ts | 13 +++--- .../grafana-data/src/panel/PanelPlugin.ts | 5 --- packages/grafana-data/src/utils/csv.ts | 2 +- .../src/utils/throwIfAngular.test.ts | 1 + .../grafana-data/src/vector/CircularVector.ts | 2 +- .../src/components/Modal/ModalsContext.tsx | 8 +--- .../src/components/Select/SelectBase.tsx | 4 +- .../components/Select/resetSelectStyles.ts | 6 +-- .../SingleStatShared/SingleStatBaseOptions.ts | 2 +- .../serialization/angularMigration.ts | 2 +- .../PanelEditor/state/actions.test.ts | 2 - .../app/features/search/service/frontend.ts | 3 +- .../plugins/panel/live/LiveChannelEditor.tsx | 2 +- 19 files changed, 50 insertions(+), 75 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a5155bbdd95..7464e82bb3e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -24,11 +24,6 @@ "count": 1 } }, - "packages/grafana-data/src/dataframe/ArrayDataFrame.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "packages/grafana-data/src/dataframe/CircularDataFrame.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -36,7 +31,7 @@ }, "packages/grafana-data/src/dataframe/DataFrameView.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 2 + "count": 1 }, "@typescript-eslint/no-explicit-any": { "count": 2 @@ -44,28 +39,23 @@ }, "packages/grafana-data/src/dataframe/MutableDataFrame.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 3 + "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 9 + "count": 6 } }, "packages/grafana-data/src/dataframe/StreamingDataFrame.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 4 - } - }, - "packages/grafana-data/src/dataframe/processDataFrame.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 + "count": 3 } }, "packages/grafana-data/src/dataframe/processDataFrame.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 15 + "count": 11 }, "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 4 } }, "packages/grafana-data/src/datetime/moment_wrapper.ts": { @@ -106,7 +96,7 @@ "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 4 } }, "packages/grafana-data/src/panel/registryFactories.ts": { @@ -305,7 +295,7 @@ }, "packages/grafana-data/src/utils/csv.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 3 + "count": 2 }, "@typescript-eslint/no-explicit-any": { "count": 2 @@ -347,11 +337,6 @@ "count": 3 } }, - "packages/grafana-data/src/vector/CircularVector.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "packages/grafana-data/src/vector/FunctionalVector.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 @@ -769,7 +754,7 @@ }, "packages/grafana-ui/src/components/Modal/ModalsContext.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 2 }, "react-prefer-function-component/react-prefer-function-component": { "count": 1 @@ -847,7 +832,7 @@ }, "packages/grafana-ui/src/components/Select/SelectBase.tsx": { "@typescript-eslint/consistent-type-assertions": { - "count": 2 + "count": 1 }, "@typescript-eslint/no-explicit-any": { "count": 4 @@ -860,7 +845,7 @@ }, "packages/grafana-ui/src/components/Select/resetSelectStyles.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 1 } }, "packages/grafana-ui/src/components/Select/types.ts": { @@ -875,7 +860,7 @@ }, "packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 3 + "count": 2 }, "@typescript-eslint/no-explicit-any": { "count": 13 @@ -4751,7 +4736,7 @@ }, "public/app/plugins/panel/live/LiveChannelEditor.tsx": { "@typescript-eslint/consistent-type-assertions": { - "count": 2 + "count": 1 }, "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts index 8817812b5bd..fdcfed8c039 100644 --- a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts @@ -8,14 +8,14 @@ import { guessFieldTypeForField } from './processDataFrame'; * * @deprecated use arrayToDataFrame */ -export class ArrayDataFrame implements DataFrame { +export class ArrayDataFrame implements DataFrame { fields: Field[] = []; length = 0; name?: string; refId?: string; meta?: QueryResultMeta; - constructor(source: T[], names?: string[]) { + constructor(source: unknown[], names?: string[]) { return arrayToDataFrame(source, names); // returns a standard DataFrame } } @@ -27,7 +27,7 @@ export class ArrayDataFrame implements DataFrame { * * @public */ -export function arrayToDataFrame(source: Array> | unknown[], names?: string[]): DataFrame { +export function arrayToDataFrame(source: unknown[], names?: string[]): DataFrame { const df: DataFrame = { fields: [], length: source.length, diff --git a/packages/grafana-data/src/dataframe/DataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts index d4d3c157d62..e911748fa39 100644 --- a/packages/grafana-data/src/dataframe/DataFrameView.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -23,7 +23,7 @@ export class DataFrameView extends FunctionalVector { constructor(private data: DataFrame) { super(); const obj = {} as T; - const fields = {} as any; + const fields: any = {}; for (let i = 0; i < data.fields.length; i++) { const field = data.fields[i]; diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts index 4437aa44b5e..6ac9b79e38a 100644 --- a/packages/grafana-data/src/dataframe/MutableDataFrame.ts +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -13,6 +13,8 @@ export type MutableField = Field; /** @deprecated */ type MutableVectorCreator = (buffer?: unknown[]) => unknown[]; +type Parser = (v: string) => unknown; + export const MISSING_VALUE = undefined; // Treated as connected in new graph panel /** @@ -149,14 +151,14 @@ export class MutableDataFrame extends FunctionalVector implements Da } } - private parsers: Map any> | undefined = undefined; + private parsers: Map | undefined = undefined; /** * @deprecated unclear if this is actually used */ - setParser(field: Field, parser: (v: string) => any) { + setParser(field: Field, parser: Parser) { if (!this.parsers) { - this.parsers = new Map any>(); + this.parsers = new Map(); } this.parsers.set(field, parser); return parser; @@ -222,7 +224,7 @@ export class MutableDataFrame extends FunctionalVector implements Da */ add(value: T): void { // Will add one value for every field - const obj = value as any; + const obj: any = value; for (const field of this.fields) { let val = obj[field.name]; @@ -243,7 +245,7 @@ export class MutableDataFrame extends FunctionalVector implements Da throw new Error('Unable to set value beyond current length'); } - const obj = (value as Record) || {}; + const obj: Record = value || {}; for (const field of this.fields) { field.values[index] = obj[field.name]; } diff --git a/packages/grafana-data/src/dataframe/StreamingDataFrame.ts b/packages/grafana-data/src/dataframe/StreamingDataFrame.ts index bb410406ed9..8d37543f73e 100644 --- a/packages/grafana-data/src/dataframe/StreamingDataFrame.ts +++ b/packages/grafana-data/src/dataframe/StreamingDataFrame.ts @@ -420,9 +420,7 @@ export class StreamingDataFrame implements DataFrame { }; getMatchingFieldIndexes = (fieldPredicate: (f: Field) => boolean): number[] => - this.fields - .map((f, index) => (fieldPredicate(f) ? index : undefined)) - .filter((val) => val !== undefined) as number[]; + this.fields.map((f, index) => (fieldPredicate(f) ? index : undefined)).filter((val) => val !== undefined); getValuesFromLastPacket = (): unknown[][] => this.fields.map((f) => { diff --git a/packages/grafana-data/src/dataframe/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts index 5c7593e91c0..bb9ffefefcd 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -348,11 +348,11 @@ describe('SeriesData backwards compatibility', () => { expect(isDataFrame(timeseries)).toBeFalsy(); expect(isDataFrame(series)).toBeTruthy(); - const roundtrip = toLegacyResponseData(series) as any; + const roundtrip = toLegacyResponseData(series); expect(isDataFrame(roundtrip)).toBeFalsy(); - expect(roundtrip.type).toBe('docs'); - expect(roundtrip.target).toBe('docs'); - expect(roundtrip.filterable).toBeTruthy(); + expect('type' in roundtrip && roundtrip.type).toBe('docs'); + expect('target' in roundtrip && roundtrip.target).toBe('docs'); + expect('filterable' in roundtrip && roundtrip.filterable).toBeTruthy(); }); }); diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts index 14621547725..c3fd65486c7 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -26,13 +26,13 @@ import { dataFrameFromJSON } from './DataFrameJSON'; function convertTableToDataFrame(table: TableData): DataFrame { const fields = table.columns.map((c) => { // TODO: should be Column but type does not exists there so not sure whats up here. - const { text, type, ...disp } = c as any; + const { text, type, ...disp } = c as Column & { type?: FieldType }; const values: unknown[] = []; return { - name: text?.length ? text : c, // rename 'text' to the 'name' field - config: (disp || {}) as FieldConfig, + name: text ?? c, // rename 'text' to the 'name' field + config: disp || {}, values, - type: type && Object.values(FieldType).includes(type as FieldType) ? (type as FieldType) : FieldType.other, + type: type && Object.values(FieldType).includes(type) ? type : FieldType.other, }; }); @@ -400,8 +400,9 @@ export const toLegacyResponseData = (frame: DataFrame): TimeSeries | TableData = if (config) { // keep unit etc const { ...column } = config; - (column as Column).text = name; - return column as Column; + const result = column as Column; + result.text = name; + return result; } return { text: name }; }), diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index 61985074be9..4822d4c6acb 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -122,11 +122,6 @@ export class PanelPlugin< alertStates: false, }; - /** - * Legacy angular ctrl. If this exists it will be used instead of the panel - */ - angularPanelCtrl?: any; - constructor(panel: ComponentType> | null) { super(); this.panel = panel; diff --git a/packages/grafana-data/src/utils/csv.ts b/packages/grafana-data/src/utils/csv.ts index 7c225e47ac0..9823aa4c550 100644 --- a/packages/grafana-data/src/utils/csv.ts +++ b/packages/grafana-data/src/utils/csv.ts @@ -115,7 +115,7 @@ export class CSVReader { if (!fields[j].config) { fields[j].config = {}; } - const disp = fields[j].config as any; // any lets name lookup + const disp: any = fields[j].config; // any lets name lookup disp[k] = j === 0 ? v : line[j]; } } diff --git a/packages/grafana-data/src/utils/throwIfAngular.test.ts b/packages/grafana-data/src/utils/throwIfAngular.test.ts index 8cdbae71cd9..8b17e2f10e6 100644 --- a/packages/grafana-data/src/utils/throwIfAngular.test.ts +++ b/packages/grafana-data/src/utils/throwIfAngular.test.ts @@ -33,6 +33,7 @@ describe('throwIfAngular', () => { it('should throw if angular panel', () => { const underTest = new PanelPlugin(null); + // @ts-expect-error underTest.angularPanelCtrl = {}; expect(() => throwIfAngular(underTest)).toThrow('Angular plugins are not supported'); }); diff --git a/packages/grafana-data/src/vector/CircularVector.ts b/packages/grafana-data/src/vector/CircularVector.ts index 221f7c488e5..70b570630bc 100644 --- a/packages/grafana-data/src/vector/CircularVector.ts +++ b/packages/grafana-data/src/vector/CircularVector.ts @@ -16,7 +16,7 @@ interface CircularOptions { * @public * @deprecated use a simple Arrays */ -export class CircularVector extends FunctionalVector { +export class CircularVector extends FunctionalVector { private buffer: T[]; private index: number; private capacity: number; diff --git a/packages/grafana-ui/src/components/Modal/ModalsContext.tsx b/packages/grafana-ui/src/components/Modal/ModalsContext.tsx index 4a540716fee..734e3fe620b 100644 --- a/packages/grafana-ui/src/components/Modal/ModalsContext.tsx +++ b/packages/grafana-ui/src/components/Modal/ModalsContext.tsx @@ -17,10 +17,6 @@ export const ModalsContext = React.createContext({ interface ModalsProviderProps { children: React.ReactNode; - /** Set default component to render as modal. Useful when rendering modals from Angular */ - component?: React.ComponentType | null; - /** Set default component props. Useful when rendering modals from Angular */ - props?: any; } /** @@ -31,8 +27,8 @@ export class ModalsProvider extends Component({ const creatableProps: ComponentProps>> = {}; let asyncSelectProps: any = {}; - let selectedValue; + let selectedValue: any; if (isMulti && loadOptions) { - selectedValue = value as any; + selectedValue = value; } else { // If option is passed as a plain value (value property from SelectableValue property) // we are selecting the corresponding value from the options diff --git a/packages/grafana-ui/src/components/Select/resetSelectStyles.ts b/packages/grafana-ui/src/components/Select/resetSelectStyles.ts index 067cdaadf76..90ac3877c2a 100644 --- a/packages/grafana-ui/src/components/Select/resetSelectStyles.ts +++ b/packages/grafana-ui/src/components/Select/resetSelectStyles.ts @@ -51,7 +51,7 @@ export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | stri return useMemo(() => { return { ...resetSelectStyles(theme), - menuPortal: (base: any) => { + menuPortal: (base: CSSObjectWithLabel) => { // Would like to correct top position when menu is placed bottom, but have props are not sent to this style function. // Only state is. https://github.com/JedWatson/react-select/blob/master/packages/react-select/src/components/Menu.tsx#L605 return { @@ -60,7 +60,7 @@ export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | stri }; }, //These are required for the menu positioning to function - menu: ({ top, bottom, position }: any) => { + menu: ({ top, bottom, position }: CSSObjectWithLabel) => { return { top, bottom, @@ -73,7 +73,7 @@ export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | stri width: width ? theme.spacing(width) : '100%', display: width === 'auto' ? 'inline-flex' : 'flex', }), - option: (provided: any, state: any) => ({ + option: (provided: CSSObjectWithLabel, state: any) => ({ ...provided, opacity: state.isDisabled ? 0.5 : 1, }), diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index cf2dc4fffb0..b9aa005074c 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -197,7 +197,7 @@ export function sharedSingleStatMigrationHandler(panel: PanelModel { it('should not increment configRev when no changes made and leaving panel edit', async () => { const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); sourcePanel.plugin = getPanelPlugin({}); - sourcePanel.plugin.angularPanelCtrl = undefined; const dashboard = createDashboardModelFixture({ panels: [{ id: 12, type: 'graph' }], @@ -169,7 +168,6 @@ describe('panelEditor actions', () => { it('should apply changes when dashboard was saved from panel edit', async () => { const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); sourcePanel.plugin = getPanelPlugin({}); - sourcePanel.plugin.angularPanelCtrl = undefined; const dashboard = createDashboardModelFixture({ panels: [{ id: 12, type: 'graph' }], diff --git a/public/app/features/search/service/frontend.ts b/public/app/features/search/service/frontend.ts index b926d0c2cb2..40740144077 100644 --- a/public/app/features/search/service/frontend.ts +++ b/public/app/features/search/service/frontend.ts @@ -117,8 +117,7 @@ class FullResultCache { const allFields = this.full.dataFrame.fields; const haystack = this.names; - // eslint-disable-next-line - const values = allFields.map((v) => [] as any[]); // empty value for each field + const values = allFields.map((v) => []); // empty value for each field let [idxs, info, order] = this.ufuzzy.search(haystack, query, 5); diff --git a/public/app/plugins/panel/live/LiveChannelEditor.tsx b/public/app/plugins/panel/live/LiveChannelEditor.tsx index 90cd5f4b474..06af567317e 100644 --- a/public/app/plugins/panel/live/LiveChannelEditor.tsx +++ b/public/app/plugins/panel/live/LiveChannelEditor.tsx @@ -135,7 +135,7 @@ export function LiveChannelEditor(props: Props) { 'Select watchable resource' )} onChange={(v) => { - const resource = (v as any).resource as GroupDiscoveryResource; + const resource: GroupDiscoveryResource = (v as any).resource; if (resource) { props.onChange({ scope: LiveChannelScope.Watch, From a7ace2dcddf7a8ed48850112e862b475dff63e58 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Mon, 10 Nov 2025 13:43:00 +0200 Subject: [PATCH 113/209] API clients: Use open_snapshots for possibleOpenAPISpecs (#113587) * API clients: Use open_snapshots for possibleOpenAPISpecs * Tweak logic to throw error if we can't load openapi specs --------- Co-authored-by: Tom Ratcliffe --- .../src/generator/plopfile.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/grafana-api-clients/src/generator/plopfile.ts b/packages/grafana-api-clients/src/generator/plopfile.ts index ed3953bfdc7..3f40c4adc42 100644 --- a/packages/grafana-api-clients/src/generator/plopfile.ts +++ b/packages/grafana-api-clients/src/generator/plopfile.ts @@ -107,15 +107,20 @@ export default function plopGenerator(plop: NodePlopAPI) { return actions; }; - // Read files from data/openapi directory and use as an array of API clients that the user can select + const getOpenAPISpecs = (isEnterprise: boolean): string[] => { + const openapiDir = isEnterprise + ? path.join(basePath, 'pkg/extensions/apiserver/tests/openapi_snapshots') + : path.join(basePath, 'pkg/tests/apis/openapi_snapshots'); - const openapiDir = path.join(basePath, 'data/openapi'); - let possibleOpenAPISpecs: string[] = []; - try { - possibleOpenAPISpecs = fs.readdirSync(openapiDir).filter((file: string) => file.endsWith('.json')); - } catch (e) { - possibleOpenAPISpecs = []; - } + try { + const files = fs.readdirSync(openapiDir).filter((file: string) => file.endsWith('.json')); + return files; + } catch (e) { + throw new Error( + "No OpenAPI specs found! Are you trying to generate an API client for enterprise but haven't linked your local environment?" + ); + } + }; const generator: PlopGeneratorConfig = { description: 'Generate RTK Query API client for a Grafana API group', @@ -129,7 +134,9 @@ export default function plopGenerator(plop: NodePlopAPI) { { type: 'list', loop: false, - choices: possibleOpenAPISpecs, + choices: (answers: { isEnterprise?: boolean }) => { + return getOpenAPISpecs(answers.isEnterprise ?? false); + }, pageSize: 50, name: 'apiInfo', message: 'OpenAPI spec:', From 8ce90987656c099cdf2d5eff45f2d7537d8e32fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:05:05 +0100 Subject: [PATCH 114/209] test: improve folder integration test performance (#113518) --- .github/workflows/pr-test-integration.yml | 6 +- pkg/tests/apis/folder/folders_test.go | 526 +++++++++++++--------- pkg/tests/apis/helper.go | 70 ++- 3 files changed, 380 insertions(+), 222 deletions(-) diff --git a/.github/workflows/pr-test-integration.yml b/.github/workflows/pr-test-integration.yml index a864f1604f0..b80d801b1b0 100644 --- a/.github/workflows/pr-test-integration.yml +++ b/.github/workflows/pr-test-integration.yml @@ -68,7 +68,7 @@ jobs: run: | set -euo pipefail readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N"$SHARD" -d-)" - go test -tags=sqlite -timeout=12m -run '^TestIntegration' "${PACKAGES[@]}" + go test -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" sqlite_nocgo: needs: detect-changes @@ -109,7 +109,7 @@ jobs: # Build regex pattern like: pkg1$|pkg2$|pkg3$ SKIP_PATTERN=$(echo "$SKIP_PACKAGES" | sed '/^$/d' | sed 's|.*|&$|' | paste -sd '|' -) readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N "$SHARD" -d - | grep -Ev "($SKIP_PATTERN)")" - go test -tags=sqlite -timeout=12m -run '^TestIntegration' "${PACKAGES[@]}" + go test -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" - name: Run profiled tests id: run-profiled-tests if: matrix.shard == 'profiled' @@ -135,7 +135,7 @@ jobs: pkg_name=$(basename "$full_pkg" | tr '/' '_' | tr '.' '_') echo "📦 Running $full_pkg" set +e - go test -tags=sqlite -timeout=12m -run '^TestIntegration' \ + go test -tags=sqlite -timeout=8m -run '^TestIntegration' \ -outputdir=profiles \ -cpuprofile="cpu_${pkg_name}.prof" \ -memprofile="mem_${pkg_name}.prof" \ diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 3fe36daa1e0..4e272df1825 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "slices" + "strings" "testing" "time" @@ -730,50 +731,74 @@ func TestIntegrationFolderCreatePermissions(t *testing.T) { // test on all dualwriter modes for mode := 0; mode <= 4; mode++ { - for _, tc := range tcs { - t.Run(fmt.Sprintf("[Mode: %v] "+tc.description, mode), func(t *testing.T) { - modeDw := grafanarest.DualWriterMode(mode) - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", - UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folders.RESOURCEGROUP: { - DualWriterMode: modeDw, - }, + t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, }, - }) - - user := helper.CreateUser("user", apis.Org1, org.RoleViewer, tc.permissions) - - parentPayload := `{ - "title": "Test/parent", - "uid": "parentuid" - }` - parentCreate := apis.DoRequest(helper, apis.RequestParams{ - User: helper.Org1.Admin, - Method: http.MethodPost, - Path: "/api/folders", - Body: []byte(parentPayload), - }, &folder.Folder{}) - require.NotNil(t, parentCreate.Result) - parentUID := parentCreate.Result.UID - require.NotEmpty(t, parentUID) - - resp := apis.DoRequest(helper, apis.RequestParams{ - User: user, - Method: http.MethodPost, - Path: "/api/folders", - Body: []byte(tc.input), - }, &dtos.Folder{}) - require.Equal(t, tc.expectedCode, resp.Response.StatusCode) - - if tc.expectedCode == http.StatusOK { - require.Equal(t, "uid", resp.Result.UID) - require.Equal(t, "Folder", resp.Result.Title) - } + }, }) - } + for i, tc := range tcs { + t.Run(fmt.Sprintf("[Mode: %v] "+tc.description, mode), func(t *testing.T) { + username := fmt.Sprintf("user-%d", i) + parentUID := fmt.Sprintf("parentuid-%d", i) + childUID := fmt.Sprintf("uid-%d", i) + + // Update permissions to use unique parent UID + permissions := make([]resourcepermissions.SetResourcePermissionCommand, len(tc.permissions)) + for j, perm := range tc.permissions { + permissions[j] = perm + if perm.ResourceID == "parentuid" { + permissions[j].ResourceID = parentUID + } + } + + user := helper.CreateUser(username, apis.Org1, org.RoleViewer, permissions) + + // Get user ID for cleanup + userID, _ := user.Identity.GetInternalID() + + // Register cleanup for this test case + t.Cleanup(helper.CleanupTestResources([]string{parentUID, childUID}, []int64{userID})) + + parentPayload := fmt.Sprintf(`{ + "title": "Test/parent", + "uid": "%s" + }`, parentUID) + parentCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(parentPayload), + }, &folder.Folder{}) + require.NotNil(t, parentCreate.Result) + createdParentUID := parentCreate.Result.UID + require.NotEmpty(t, createdParentUID) + + // Update input to use unique UIDs + input := strings.ReplaceAll(tc.input, "parentuid", parentUID) + input = strings.ReplaceAll(input, `"uid": "uid"`, fmt.Sprintf(`"uid": "%s"`, childUID)) + + resp := apis.DoRequest(helper, apis.RequestParams{ + User: user, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(input), + }, &dtos.Folder{}) + require.Equal(t, tc.expectedCode, resp.Response.StatusCode) + + if tc.expectedCode == http.StatusOK { + require.Equal(t, childUID, resp.Result.UID) + require.Equal(t, "Folder", resp.Result.Title) + } + }) + } + }) } } @@ -833,98 +858,137 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { // test on all dualwriter modes for mode := 0; mode <= 4; mode++ { - for _, tc := range tcs { - t.Run(fmt.Sprintf("[Mode: %v] "+tc.description, mode), func(t *testing.T) { - modeDw := grafanarest.DualWriterMode(mode) - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", - UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folders.RESOURCEGROUP: { - DualWriterMode: modeDw, - }, + t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, }, - }) - - // Create parent folder - parentPayload := `{ - "title": "testparent", - "uid": "parentuid" - }` - parentCreate := apis.DoRequest(helper, apis.RequestParams{ - User: helper.Org1.Admin, - Method: http.MethodPost, - Path: "/api/folders", - Body: []byte(parentPayload), - }, &folder.Folder{}) - require.NotNil(t, parentCreate.Result) - parentUID := parentCreate.Result.UID - require.NotEmpty(t, parentUID) - - // Create descendant folder - payload := "{ \"uid\": \"descuid\", \"title\": \"Folder\", \"parentUid\": \"parentuid\"}" - resp := apis.DoRequest(helper, apis.RequestParams{ - User: helper.Org1.Admin, - Method: http.MethodPost, - Path: "/api/folders", - Body: []byte(payload), - }, &dtos.Folder{}) - require.Equal(t, http.StatusOK, resp.Response.StatusCode) - - user := helper.CreateUser("user", apis.Org1, org.RoleNone, tc.permissions) - - // Get with accesscontrol disabled - getResp := apis.DoRequest(helper, apis.RequestParams{ - User: user, - Method: http.MethodGet, - Path: "/api/folders/descuid", - }, &dtos.Folder{}) - require.Equal(t, tc.expectedCode, getResp.Response.StatusCode) - require.NotNil(t, getResp.Result) - - require.False(t, getResp.Result.AccessControl[dashboards.ActionFoldersRead]) - require.False(t, getResp.Result.AccessControl[dashboards.ActionFoldersWrite]) - - parents := getResp.Result.Parents - require.Equal(t, len(tc.expectedParentUIDs), len(parents)) - require.Equal(t, len(tc.expectedParentTitles), len(parents)) - for i := 0; i < len(tc.expectedParentUIDs); i++ { - require.Equal(t, tc.expectedParentUIDs[i], parents[i].UID) - require.Equal(t, tc.expectedParentTitles[i], parents[i].Title) - } - - // Get with accesscontrol enabled - if tc.checkAccessControl { - acPerms := []resourcepermissions.SetResourcePermissionCommand{ - { - Actions: []string{dashboards.ActionFoldersRead}, - Resource: "folders", - ResourceAttribute: "uid", - ResourceID: "*", - }, - { - Actions: []string{dashboards.ActionFoldersWrite}, - Resource: "folders", - ResourceAttribute: "uid", - ResourceID: "parentuid", - }, - } - acUser := helper.CreateUser("acuser", apis.Org1, org.RoleNone, acPerms) - - getWithAC := apis.DoRequest(helper, apis.RequestParams{ - User: acUser, - Method: http.MethodGet, - Path: "/api/folders/descuid?accesscontrol=true", - }, &dtos.Folder{}) - require.Equal(t, tc.expectedCode, getWithAC.Response.StatusCode) - require.NotNil(t, getWithAC.Result) - - require.True(t, getWithAC.Result.AccessControl[dashboards.ActionFoldersRead]) - require.True(t, getWithAC.Result.AccessControl[dashboards.ActionFoldersWrite]) - } + }, }) - } + + // Run all test cases within the same server instance + for i, tc := range tcs { + t.Run(tc.description, func(t *testing.T) { + // Use unique UIDs per test case to avoid conflicts + parentUID := fmt.Sprintf("parentuid-%d", i) + descUID := fmt.Sprintf("descuid-%d", i) + parentTitle := fmt.Sprintf("testparent-%d", i) + userLogin := fmt.Sprintf("user-%d", i) + acUserLogin := fmt.Sprintf("acuser-%d", i) + + // Create parent folder + parentPayload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s" + }`, parentTitle, parentUID) + parentCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(parentPayload), + }, &folder.Folder{}) + require.NotNil(t, parentCreate.Result) + require.Equal(t, parentUID, parentCreate.Result.UID) + + // Create descendant folder + payload := fmt.Sprintf(`{ "uid": "%s", "title": "Folder-%d", "parentUid": "%s"}`, descUID, i, parentUID) + resp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &dtos.Folder{}) + require.Equal(t, http.StatusOK, resp.Response.StatusCode) + + // Update permissions to use unique descUID where needed + permissions := tc.permissions + for j := range permissions { + if permissions[j].ResourceID == "descuid" { + permissions[j].ResourceID = descUID + } + } + + user := helper.CreateUser(userLogin, apis.Org1, org.RoleNone, permissions) + + // Get user ID for cleanup + userID, err := user.Identity.GetInternalID() + require.NoError(t, err) + + // Register cleanup to delete created resources + t.Cleanup(helper.CleanupTestResources([]string{descUID, parentUID}, []int64{userID})) + + // Adjust expected UIDs and titles + expectedParentUIDs := tc.expectedParentUIDs + expectedParentTitles := tc.expectedParentTitles + if len(expectedParentUIDs) > 0 { + expectedParentUIDs = []string{parentUID} + expectedParentTitles = []string{parentTitle} + } + + // Get with accesscontrol disabled + getResp := apis.DoRequest(helper, apis.RequestParams{ + User: user, + Method: http.MethodGet, + Path: "/api/folders/" + descUID, + }, &dtos.Folder{}) + require.Equal(t, tc.expectedCode, getResp.Response.StatusCode) + + if tc.expectedCode == http.StatusOK { + require.NotNil(t, getResp.Result) + require.False(t, getResp.Result.AccessControl[dashboards.ActionFoldersRead]) + require.False(t, getResp.Result.AccessControl[dashboards.ActionFoldersWrite]) + + parents := getResp.Result.Parents + require.Equal(t, len(expectedParentUIDs), len(parents)) + require.Equal(t, len(expectedParentTitles), len(parents)) + for j := 0; j < len(expectedParentUIDs); j++ { + require.Equal(t, expectedParentUIDs[j], parents[j].UID) + require.Equal(t, expectedParentTitles[j], parents[j].Title) + } + + // Get with accesscontrol enabled + if tc.checkAccessControl { + acPerms := []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{dashboards.ActionFoldersRead}, + Resource: "folders", + ResourceAttribute: "uid", + ResourceID: "*", + }, + { + Actions: []string{dashboards.ActionFoldersWrite}, + Resource: "folders", + ResourceAttribute: "uid", + ResourceID: parentUID, + }, + } + acUser := helper.CreateUser(acUserLogin, apis.Org1, org.RoleNone, acPerms) + + // Get user ID for cleanup + acUserID, err := acUser.Identity.GetInternalID() + require.NoError(t, err) + t.Cleanup(helper.CleanupTestResources([]string{}, []int64{acUserID})) + + getWithAC := apis.DoRequest(helper, apis.RequestParams{ + User: acUser, + Method: http.MethodGet, + Path: "/api/folders/" + descUID + "?accesscontrol=true", + }, &dtos.Folder{}) + require.Equal(t, tc.expectedCode, getWithAC.Response.StatusCode) + require.NotNil(t, getWithAC.Result) + + require.True(t, getWithAC.Result.AccessControl[dashboards.ActionFoldersRead]) + require.True(t, getWithAC.Result.AccessControl[dashboards.ActionFoldersWrite]) + } + } + }) + } + }) } } @@ -1012,21 +1076,26 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { // test on all dualwriter modes for mode := 0; mode <= 4; mode++ { - for _, tc := range tcs { + modeDw := grafanarest.DualWriterMode(mode) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, + }, + }, + }) + for i, tc := range tcs { t.Run(fmt.Sprintf("[Mode: %v] "+testDescription(tc.description, tc.expectedFolderSvcError), mode), func(t *testing.T) { - modeDw := grafanarest.DualWriterMode(mode) - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", - UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folders.RESOURCEGROUP: { - DualWriterMode: modeDw, - }, - }, - }) + username := fmt.Sprintf("user-%d", i) + folderUID := fmt.Sprintf("uid-%d", i) - userTest := helper.CreateUser("user", apis.Org1, org.RoleViewer, tc.permissions) + // Update input to use unique UIDs + input := strings.ReplaceAll(tc.input, `"uid": "uid"`, fmt.Sprintf(`"uid": "%s"`, folderUID)) + + userTest := helper.CreateUser(username, apis.Org1, org.RoleViewer, tc.permissions) if tc.createSecondRecord { client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1037,7 +1106,7 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { User: client.Args.User, Method: http.MethodPost, Path: "/api/folders", - Body: []byte(tc.input), + Body: []byte(input), }, &folder.Folder{}) require.NotEmpty(t, create2.Response) require.Equal(t, http.StatusOK, create2.Response.StatusCode) @@ -1045,13 +1114,13 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { addr := helper.GetEnv().Server.HTTPServer.Listener.Addr() login := userTest.Identity.GetLogin() - baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password("user"), addr) + baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password(username), addr) req, err := http.NewRequest(http.MethodPost, fmt.Sprintf( "%s%s", baseUrl, "/api/folders", - ), bytes.NewBuffer([]byte(tc.input))) + ), bytes.NewBuffer([]byte(input))) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -1071,7 +1140,7 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { require.NoError(t, resp.Body.Close()) if tc.expectedCode == http.StatusOK { - require.Equal(t, "uid", folder.UID) + require.Equal(t, folderUID, folder.UID) require.Equal(t, "Folder", folder.Title) } @@ -1179,78 +1248,109 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) { }, } - // test on all dualwriter modes for mode := 0; mode <= 4; mode++ { - for _, tc := range tcs { - t.Run(fmt.Sprintf("Mode: %d, %s", mode, tc.description), func(t *testing.T) { - modeDw := grafanarest.DualWriterMode(mode) + t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", - UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - folders.RESOURCEGROUP: { - DualWriterMode: modeDw, - }, + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: { + DualWriterMode: modeDw, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, - }) + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagUnifiedStorageSearch, + }, + }) - userTest := helper.CreateUser("user", apis.Org1, org.RoleNone, tc.permissions) + // Run all test cases within the same server instance + for i, tc := range tcs { + t.Run(tc.description, func(t *testing.T) { + // Use unique UIDs for folders to avoid conflicts between test cases + userTest := helper.CreateUser(fmt.Sprintf("user-%d", i), apis.Org1, org.RoleNone, tc.permissions) - for _, f := range tc.createFolders { - client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: userTest, - GVR: gvr, - }) - create2 := apis.DoRequest(helper, apis.RequestParams{ - User: client.Args.User, - Method: http.MethodPost, - Path: "/api/folders", - Body: []byte(f), - }, &folder.Folder{}) - require.NotEmpty(t, create2.Response) - require.Equal(t, http.StatusOK, create2.Response.StatusCode) - } + // Create folders with unique UIDs per test case + for _, f := range tc.createFolders { + // Replace hardcoded UIDs with unique ones + uniqueFolder := f + uniqueFolder = strings.Replace(uniqueFolder, `"foo"`, fmt.Sprintf(`"foo-%d"`, i), 1) + uniqueFolder = strings.Replace(uniqueFolder, `"bar"`, fmt.Sprintf(`"bar-%d"`, i), 1) + uniqueFolder = strings.Replace(uniqueFolder, `"qux"`, fmt.Sprintf(`"qux-%d"`, i), 1) + uniqueFolder = strings.Replace(uniqueFolder, `"parentUid": "foo"`, fmt.Sprintf(`"parentUid": "foo-%d"`, i), 1) - addr := helper.GetEnv().Server.HTTPServer.Listener.Addr() - login := userTest.Identity.GetLogin() - baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password("user"), addr) - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf( - "%s%s", - baseUrl, - fmt.Sprintf("/api/folders%s", tc.params), - ), nil) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - if tc.requestToAnotherOrg { - req.Header.Set("x-grafana-org-id", "2") - } - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - require.NotNil(t, resp) - require.Equal(t, tc.expectedCode, resp.StatusCode) - - if tc.expectedCode == http.StatusOK { - list := []dtos.FolderSearchHit{} - err = json.NewDecoder(resp.Body).Decode(&list) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - - // ignore IDs - for i := 0; i < len(list); i++ { - list[i].ID = 0 + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: userTest, + GVR: gvr, + }) + create2 := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(uniqueFolder), + }, &folder.Folder{}) + require.NotEmpty(t, create2.Response) + require.Equal(t, http.StatusOK, create2.Response.StatusCode) } - require.ElementsMatch(t, tc.expectedOutput, list) - } - }) - } + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr() + login := userTest.Identity.GetLogin() + baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password(fmt.Sprintf("user-%d", i)), addr) + + // Adjust params with unique UIDs + params := tc.params + params = strings.ReplaceAll(params, "foo", fmt.Sprintf("foo-%d", i)) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf( + "%s%s", + baseUrl, + fmt.Sprintf("/api/folders%s", params), + ), nil) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + if tc.requestToAnotherOrg { + req.Header.Set("x-grafana-org-id", "2") + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, tc.expectedCode, resp.StatusCode) + + if tc.expectedCode == http.StatusOK { + list := []dtos.FolderSearchHit{} + err = json.NewDecoder(resp.Body).Decode(&list) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // Adjust expected output with unique UIDs + expectedOutput := make([]dtos.FolderSearchHit, len(tc.expectedOutput)) + for j, output := range tc.expectedOutput { + expectedOutput[j] = output + expectedOutput[j].ID = 0 // ignore IDs + switch output.UID { + case "foo": + expectedOutput[j].UID = fmt.Sprintf("foo-%d", i) + case "bar": + expectedOutput[j].UID = fmt.Sprintf("bar-%d", i) + expectedOutput[j].ParentUID = fmt.Sprintf("foo-%d", i) + case "qux": + expectedOutput[j].UID = fmt.Sprintf("qux-%d", i) + } + } + + // ignore IDs in actual list + for j := 0; j < len(list); j++ { + list[j].ID = 0 + } + + require.ElementsMatch(t, expectedOutput, list) + } + }) + } + }) } } diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 4eca0e024b3..59b7d2c4c28 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -58,6 +58,24 @@ const ( Org2 = "OrgB" ) +var ( + sharedHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 500, + MaxConnsPerHost: 500, + IdleConnTimeout: 90 * time.Second, + DisableKeepAlives: false, + DisableCompression: true, + ForceAttemptHTTP2: false, + }, + } +) + type K8sTestHelper struct { t *testing.T listenerAddress string @@ -498,12 +516,8 @@ func DoRequest[T any](c *K8sTestHelper, params RequestParams, result *T) K8sResp if params.Accept != "" { req.Header.Set("Accept", params.Accept) } - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - rsp, err := client.Do(req) + + rsp, err := sharedHTTPClient.Do(req) require.NoError(c.t, err) r := K8sResponse[T]{ @@ -943,6 +957,50 @@ func (c *K8sTestHelper) DeleteServiceAccount(user User, orgID int64, saID int64) require.Equal(c.t, http.StatusOK, resp.Response.StatusCode, "failed to delete service account, body: %s", string(resp.Body)) } +func (c *K8sTestHelper) DeleteFolder(user User, folderUID string) error { + c.t.Helper() + + resp := DoRequest(c, RequestParams{ + User: user, + Method: http.MethodDelete, + Path: fmt.Sprintf("/api/folders/%s", folderUID), + }, &struct{}{}) + + if resp.Response.StatusCode != http.StatusOK && resp.Response.StatusCode != http.StatusNotFound { + return fmt.Errorf("failed to delete folder %s: status %d, body: %s", folderUID, resp.Response.StatusCode, string(resp.Body)) + } + return nil +} + +func (c *K8sTestHelper) DeleteUser(adminUser User, userID int64) error { + c.t.Helper() + + resp := DoRequest(c, RequestParams{ + User: adminUser, + Method: http.MethodDelete, + Path: fmt.Sprintf("/api/admin/users/%d", userID), + }, &struct{}{}) + + if resp.Response.StatusCode != http.StatusOK && resp.Response.StatusCode != http.StatusNotFound { + return fmt.Errorf("failed to delete user %d: status %d, body: %s", userID, resp.Response.StatusCode, string(resp.Body)) + } + return nil +} + +func (c *K8sTestHelper) CleanupTestResources(folderUIDs []string, userIDs []int64) func() { + return func() { + c.t.Helper() + // Delete folders first (they may have dependencies) + for _, uid := range folderUIDs { + _ = c.DeleteFolder(c.Org1.Admin, uid) + } + // Then delete users + for _, id := range userIDs { + _ = c.DeleteUser(c.Org1.Admin, id) + } + } +} + // Ensures that the passed error is an APIStatus error and fails the test if it is not. func (c *K8sTestHelper) RequireApiErrorStatus(err error, reason metav1.StatusReason, httpCode int) metav1.Status { require.Error(c.t, err) From 32db7e176d755315cecc9e1fba67a859ef91802e Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Mon, 10 Nov 2025 09:14:51 -0300 Subject: [PATCH 115/209] ShortURL: Fix wrong creation timestamp conversion (#113646) --- pkg/registry/apps/shorturl/conversions.go | 4 ++-- pkg/tests/apis/shorturl/shorturl_test.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/registry/apps/shorturl/conversions.go b/pkg/registry/apps/shorturl/conversions.go index 4392dd8a44b..aa4a5c09d4f 100644 --- a/pkg/registry/apps/shorturl/conversions.go +++ b/pkg/registry/apps/shorturl/conversions.go @@ -25,14 +25,14 @@ func convertToK8sResource(v *shorturls.ShortUrl, namespacer request.NamespaceMap // resourceVersion can't be 0, since we are using the lastSeenAt value, when it's zero we default to current time resourceVersion := fmt.Sprintf("%d", v.LastSeenAt) if v.LastSeenAt == 0 { - resourceVersion = fmt.Sprintf("%d", time.Now().UnixMilli()) + resourceVersion = fmt.Sprintf("%d", time.Now().Unix()) } p := &shorturl.ShortURL{ ObjectMeta: metav1.ObjectMeta{ Name: v.Uid, ResourceVersion: resourceVersion, - CreationTimestamp: metav1.NewTime(time.UnixMilli(v.CreatedAt)), + CreationTimestamp: metav1.NewTime(time.Unix(v.CreatedAt, 0)), Namespace: namespacer(v.OrgId), }, Spec: spec, diff --git a/pkg/tests/apis/shorturl/shorturl_test.go b/pkg/tests/apis/shorturl/shorturl_test.go index 4eae45ecf57..be7023c0c6a 100644 --- a/pkg/tests/apis/shorturl/shorturl_test.go +++ b/pkg/tests/apis/shorturl/shorturl_test.go @@ -211,6 +211,7 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) require.NoError(t, err) assert.Equal(t, uid, found.GetName()) + assert.LessOrEqual(t, time.Since(found.GetCreationTimestamp().Time).Seconds(), 30.0, "creation timestamp should be within last 30 seconds") // Verify cross-API consistency getFromBothAPIs(t, helper, client, uid) From 746efb4c56b223a30282668f40a483504af5ca0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Mon, 10 Nov 2025 13:34:46 +0100 Subject: [PATCH 116/209] Provisioning: Include ref field in DELETE endpoint response for branch operations (#113615) Fix: Include ref field in DELETE endpoint response for branch operations When deleting a dashboard file via DELETE endpoint with a ref query parameter (for branch operations), the response was missing the ref field. This caused the frontend branch workflow success handler to fail silently. The issue was an inverted boolean condition in the Delete method. The code was setting file.Ref = opts.Ref when shouldUpdateGrafanaDB returned true (main branch operations), but it should have been setting it when false (branch operations), since we read the file with an empty ref. Fixed by inverting the condition from: if r.shouldUpdateGrafanaDB(opts, nil) to: if !r.shouldUpdateGrafanaDB(opts, nil) This ensures the ref field is properly included in the ResourceWrapper response for branch operations. --- pkg/registry/apis/provisioning/resources/dualwriter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index 052771af700..62f4ffd3b98 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -100,7 +100,7 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa } // HACK: manual set to the provided branch so that the parser can possible read the file - if r.shouldUpdateGrafanaDB(opts, nil) { + if !r.shouldUpdateGrafanaDB(opts, nil) { file.Ref = opts.Ref } From 9a542489a7e31c8bb0c8efdc55716b5bf45ffc8f Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Mon, 10 Nov 2025 12:59:40 +0000 Subject: [PATCH 117/209] APIs: Fix pre-processing of getApiResources & update godoc for teams endpoints (#113536) --- .gitattributes | 3 + .../rtkq/advisor/v0alpha1/endpoints.gen.ts | 2 +- .../correlations/v0alpha1/endpoints.gen.ts | 2 +- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 2 +- .../rtkq/folder/v1beta1/endpoints.gen.ts | 2 +- .../rtkq/iam/v0alpha1/endpoints.gen.ts | 2 +- .../src/clients/rtkq/legacy/endpoints.gen.ts | 63 ++++++++++------- .../rtkq/playlist/v0alpha1/endpoints.gen.ts | 2 +- .../preferences/v1alpha1/endpoints.gen.ts | 2 +- .../provisioning/v0alpha1/endpoints.gen.ts | 2 +- .../rtkq/shorturl/v1beta1/endpoints.gen.ts | 2 +- .../src/scripts/generate-rtk-apis.ts | 6 ++ .../src/scripts/process-specs.ts | 2 +- pkg/services/accesscontrol/models.go | 20 ++++-- pkg/services/team/model.go | 25 ++++--- pkg/services/team/teamapi/team.go | 7 ++ public/api-enterprise-spec.json | 49 +++++++++++++- public/api-merged.json | 60 ++++++++++++++++- public/openapi3.json | 67 ++++++++++++++++++- 19 files changed, 269 insertions(+), 51 deletions(-) diff --git a/.gitattributes b/.gitattributes index e4abcc5121d..6dd73a786fb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,6 @@ *_gen.go linguist-generated **/openapi_snapshots/*.json linguist-generated apps/**/pkg/apis/*_manifest.go linguist-generated +public/openapi3.json linguist-generated +public/api-merged.json linguist-generated +public/api-enterprise-spec.json linguist-generated diff --git a/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts index 4b4de61be87..3b0fb2a5134 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/advisor.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listCheck: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts index 6cfab61cdba..7fa1043fd5c 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/correlations.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listCorrelation: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index ed3eac501b0..26f8053d29c 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/dashboard.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listDashboard: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts index 9ef9e4ebfab..984d95dcd68 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/folder.grafana.app/v1beta1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listFolder: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts index e9ce26a3cbe..851680b4be4 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts @@ -16,7 +16,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/iam.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), getDisplayMapping: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index ce5c5cae8c7..058aa2b057c 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -108,13 +108,13 @@ const injectedRtkApi = api query: () => ({ url: `/access-control/status` }), providesTags: ['access_control', 'enterprise'], }), - listTeamsRoles: build.mutation({ + listTeamsRoles: build.query({ query: (queryArg) => ({ url: `/access-control/teams/roles/search`, method: 'POST', body: queryArg.rolesSearchQuery, }), - invalidatesTags: ['access_control', 'enterprise'], + providesTags: ['access_control', 'enterprise'], }), listTeamRoles: build.query({ query: (queryArg) => ({ url: `/access-control/teams/${queryArg.teamId}/roles` }), @@ -129,7 +129,11 @@ const injectedRtkApi = api invalidatesTags: ['access_control', 'enterprise'], }), setTeamRoles: build.mutation({ - query: (queryArg) => ({ url: `/access-control/teams/${queryArg.teamId}/roles`, method: 'PUT' }), + query: (queryArg) => ({ + url: `/access-control/teams/${queryArg.teamId}/roles`, + method: 'PUT', + body: queryArg.setTeamRolesCommand, + }), invalidatesTags: ['access_control', 'enterprise'], }), removeTeamRole: build.mutation({ @@ -1599,6 +1603,8 @@ const injectedRtkApi = api perpage: queryArg.perpage, name: queryArg.name, query: queryArg.query, + accesscontrol: queryArg.accesscontrol, + sort: queryArg.sort, }, }), providesTags: ['teams'], @@ -2142,6 +2148,7 @@ export type SetTeamRolesApiResponse = /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; export type SetTeamRolesApiArg = { teamId: number; + setTeamRolesCommand: SetTeamRolesCommand; }; export type RemoveTeamRoleApiResponse = /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; @@ -3445,6 +3452,8 @@ export type SearchTeamsApiArg = { name?: string; /** If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded. */ query?: string; + accesscontrol?: boolean; + sort?: string; }; export type RemoveTeamGroupApiQueryApiResponse = /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; @@ -3880,26 +3889,26 @@ export type ErrorResponseBody = { For example, a 412 Precondition Failed error may include additional information of why that error happened. */ status?: string; }; -export type PermissionIsTheModelForAccessControlPermissions = { +export type Permission = { action?: string; created?: string; scope?: string; updated?: string; }; export type RoleDto = { - created?: string; + created: string; delegatable?: boolean; - description?: string; - displayName?: string; + description: string; + displayName: string; global?: boolean; - group?: string; + group: string; hidden?: boolean; mapped?: boolean; - name?: string; - permissions?: PermissionIsTheModelForAccessControlPermissions[]; - uid?: string; - updated?: string; - version?: number; + name: string; + permissions?: Permission[]; + uid: string; + updated: string; + version: number; }; export type CreateRoleForm = { description?: string; @@ -3908,7 +3917,7 @@ export type CreateRoleForm = { group?: string; hidden?: boolean; name?: string; - permissions?: PermissionIsTheModelForAccessControlPermissions[]; + permissions?: Permission[]; uid?: string; version?: number; }; @@ -3922,7 +3931,7 @@ export type UpdateRoleCommand = { group: string; hidden?: boolean; name?: string; - permissions?: PermissionIsTheModelForAccessControlPermissions[]; + permissions?: Permission[]; version?: number; }; export type RoleAssignmentsDto = { @@ -3946,6 +3955,10 @@ export type RolesSearchQuery = { export type AddTeamRoleCommand = { roleUid?: string; }; +export type SetTeamRolesCommand = { + includeHidden?: boolean; + roleUids?: string[]; +}; export type AddUserRoleCommand = { global?: boolean; roleUid?: string; @@ -6019,7 +6032,7 @@ export type CreateDashboardSnapshotCommand = { }; export type CreateTeamCommand = { email?: string; - name?: string; + name: string; }; export type TeamDto = { accessControl?: { @@ -6028,13 +6041,14 @@ export type TeamDto = { avatarUrl?: string; email?: string; externalUID?: string; - id?: number; - isProvisioned?: boolean; - memberCount?: number; - name?: string; - orgId?: number; + /** @deprecated Use UID instead */ + id: number; + isProvisioned: boolean; + memberCount: number; + name: string; + orgId: number; permission?: PermissionType; - uid?: string; + uid: string; }; export type SearchTeamQueryResult = { page?: number; @@ -6078,7 +6092,7 @@ export type TeamMemberDto = { userUID?: string; }; export type AddTeamMemberCommand = { - userId?: number; + userId: number; }; export type SetTeamMembershipsCommand = { admins?: string[]; @@ -6515,7 +6529,8 @@ export const { useSetRoleAssignmentsMutation, useGetAccessControlStatusQuery, useLazyGetAccessControlStatusQuery, - useListTeamsRolesMutation, + useListTeamsRolesQuery, + useLazyListTeamsRolesQuery, useListTeamRolesQuery, useLazyListTeamRolesQuery, useAddTeamRoleMutation, diff --git a/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts index 8e1bdfc1c4c..3c7edccaf9c 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/playlist.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listPlaylist: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts index 0edda5148ea..c90f694defb 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/preferences.grafana.app/v1alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listPreferences: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index a3c5d30952a..67bdca12d27 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/provisioning.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listJob: build.query({ diff --git a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts index 06331d13171..1305d03dcc6 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1beta1/endpoints.gen.ts @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/shorturl.grafana.app/v1beta1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listShortUrl: build.query({ diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index afa6f35372f..8b211fbca8c 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -53,6 +53,12 @@ const config: ConfigFile = { tag: true, apiFile: '../clients/rtkq/legacy/baseAPI.ts', filterEndpoints: (_name, operation) => !operation.operation.deprecated, + endpointOverrides: [ + { + pattern: 'listTeamsRoles', + type: 'query', + }, + ], }, '../clients/rtkq/migrate-to-cloud/endpoints.gen.ts': { schemaFile: path.join(basePath, 'public/openapi3.json'), diff --git a/packages/grafana-api-clients/src/scripts/process-specs.ts b/packages/grafana-api-clients/src/scripts/process-specs.ts index f3de40bf6ed..5503b24408a 100644 --- a/packages/grafana-api-clients/src/scripts/process-specs.ts +++ b/packages/grafana-api-clients/src/scripts/process-specs.ts @@ -23,7 +23,7 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) { continue; } // Remove the specified part from the path key - const newPathKey = path.replace(/^\/apis\/[^\/]+\/[^\/]+\/namespaces\/\{namespace}/, ''); + const newPathKey = path.replace(/^\/apis\/[^\/]+\/[^\/]+/, '').replace(/^\/namespaces\/\{namespace}/, ''); // Process each method in the path (e.g., get, post) const newPathItem: Record = {}; diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 3f93ce6ad82..dfda78ac9f0 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -73,11 +73,17 @@ func (r Role) MarshalJSON() ([]byte, error) { // swagger:ignore type RoleDTO struct { - Version int64 `json:"version"` - UID string `xorm:"uid" json:"uid"` - Name string `json:"name"` - DisplayName string `json:"displayName,omitempty"` - Description string `json:"description"` + // required:true + Version int64 `json:"version"` + // required:true + UID string `xorm:"uid" json:"uid"` + // required:true + Name string `json:"name"` + // required:true + DisplayName string `json:"displayName,omitempty"` + // required:true + Description string `json:"description"` + // required:true Group string `xorm:"group_name" json:"group"` Permissions []Permission `json:"permissions,omitempty"` Delegatable *bool `json:"delegatable,omitempty"` @@ -87,7 +93,9 @@ type RoleDTO struct { ID int64 `json:"-" xorm:"pk autoincr 'id'"` OrgID int64 `json:"-" xorm:"org_id"` + // required:true Updated time.Time `json:"updated"` + // required:true Created time.Time `json:"created"` } @@ -193,7 +201,7 @@ type BuiltinRole struct { Created time.Time } -// Permission is the model for access control permissions. +// Permission is the model for access control permissions type Permission struct { ID int64 `json:"-" xorm:"pk autoincr 'id'"` RoleID int64 `json:"-" xorm:"role_id"` diff --git a/pkg/services/team/model.go b/pkg/services/team/model.go index 68f655771b0..4d01c0a3d0b 100644 --- a/pkg/services/team/model.go +++ b/pkg/services/team/model.go @@ -38,6 +38,7 @@ type Team struct { // COMMANDS type CreateTeamCommand struct { + // required:true Name string `json:"name" binding:"Required"` Email string `json:"email"` ExternalUID string `json:"-"` @@ -94,14 +95,21 @@ type SearchTeamsQuery struct { } type TeamDTO struct { - ID int64 `json:"id" xorm:"id"` - UID string `json:"uid" xorm:"uid"` - OrgID int64 `json:"orgId" xorm:"org_id"` - Name string `json:"name"` - Email string `json:"email"` - ExternalUID string `json:"externalUID" xorm:"external_uid"` - IsProvisioned bool `json:"isProvisioned"` - AvatarURL string `json:"avatarUrl"` + // @deprecated Use UID instead + // required: true + ID int64 `json:"id" xorm:"id"` + // required: true + UID string `json:"uid" xorm:"uid"` + // required: true + OrgID int64 `json:"orgId" xorm:"org_id"` + // required: true + Name string `json:"name"` + Email string `json:"email"` + ExternalUID string `json:"externalUID" xorm:"external_uid"` + // required: true + IsProvisioned bool `json:"isProvisioned"` + AvatarURL string `json:"avatarUrl"` + // required: true MemberCount int64 `json:"memberCount"` Permission PermissionType `json:"permission"` AccessControl map[string]bool `json:"accessControl"` @@ -146,6 +154,7 @@ type TeamMember struct { // COMMANDS type AddTeamMemberCommand struct { + // required:true UserID int64 `json:"userId" binding:"Required"` Permission PermissionType `json:"-"` } diff --git a/pkg/services/team/teamapi/team.go b/pkg/services/team/teamapi/team.go index 69436edd8cc..9fca7089d2b 100644 --- a/pkg/services/team/teamapi/team.go +++ b/pkg/services/team/teamapi/team.go @@ -344,6 +344,13 @@ type SearchTeamsParams struct { // If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded. // required:false Query string `json:"query"` + // in:query + // required:false + // default: false + AccessControl bool `json:"accesscontrol"` + // in:query + // required:false + Sort string `json:"sort"` } // swagger:parameters createTeam diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 34968691f50..a7dfb2004ec 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -406,6 +406,14 @@ "name": "teamId", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetTeamRolesCommand" + } } ], "responses": { @@ -2577,6 +2585,9 @@ }, "AddTeamMemberCommand": { "type": "object", + "required": [ + "userId" + ], "properties": { "userId": { "type": "integer", @@ -3973,6 +3984,9 @@ }, "CreateTeamCommand": { "type": "object", + "required": [ + "name" + ], "properties": { "email": { "type": "string" @@ -6162,8 +6176,8 @@ } }, "Permission": { + "description": "Permission is the model for access control permissions", "type": "object", - "title": "Permission is the model for access control permissions.", "properties": { "action": { "type": "string" @@ -7180,6 +7194,16 @@ }, "RoleDTO": { "type": "object", + "required": [ + "version", + "uid", + "name", + "displayName", + "description", + "group", + "updated", + "created" + ], "properties": { "created": { "type": "string", @@ -7692,6 +7716,20 @@ } } }, + "SetTeamRolesCommand": { + "type": "object", + "properties": { + "includeHidden": { + "type": "boolean" + }, + "roleUids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "SetUserRolesCommand": { "type": "object", "properties": { @@ -7865,6 +7903,14 @@ }, "TeamDTO": { "type": "object", + "required": [ + "id", + "uid", + "orgId", + "name", + "isProvisioned", + "memberCount" + ], "properties": { "accessControl": { "type": "object", @@ -7882,6 +7928,7 @@ "type": "string" }, "id": { + "description": "@deprecated Use UID instead", "type": "integer", "format": "int64" }, diff --git a/public/api-merged.json b/public/api-merged.json index 330a563bde1..87a8bcb9d35 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -406,6 +406,14 @@ "name": "teamId", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetTeamRolesCommand" + } } ], "responses": { @@ -9931,6 +9939,17 @@ "description": "If set it will return results where the query value is contained in the name field. Query values with spaces need to be URL encoded.", "name": "query", "in": "query" + }, + { + "type": "boolean", + "default": false, + "name": "accesscontrol", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" } ], "responses": { @@ -12750,6 +12769,9 @@ }, "AddTeamMemberCommand": { "type": "object", + "required": [ + "userId" + ], "properties": { "userId": { "type": "integer", @@ -14946,6 +14968,9 @@ }, "CreateTeamCommand": { "type": "object", + "required": [ + "name" + ], "properties": { "email": { "type": "string" @@ -18651,8 +18676,8 @@ } }, "Permission": { + "description": "Permission is the model for access control permissions", "type": "object", - "title": "Permission is the model for access control permissions.", "properties": { "action": { "type": "string" @@ -20562,6 +20587,16 @@ }, "RoleDTO": { "type": "object", + "required": [ + "version", + "uid", + "name", + "displayName", + "description", + "group", + "updated", + "created" + ], "properties": { "created": { "type": "string", @@ -21449,6 +21484,20 @@ } } }, + "SetTeamRolesCommand": { + "type": "object", + "properties": { + "includeHidden": { + "type": "boolean" + }, + "roleUids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "SetUserRolesCommand": { "type": "object", "properties": { @@ -21885,6 +21934,14 @@ }, "TeamDTO": { "type": "object", + "required": [ + "id", + "uid", + "orgId", + "name", + "isProvisioned", + "memberCount" + ], "properties": { "accessControl": { "type": "object", @@ -21902,6 +21959,7 @@ "type": "string" }, "id": { + "description": "@deprecated Use UID instead", "type": "integer", "format": "int64" }, diff --git a/public/openapi3.json b/public/openapi3.json index 48314b94e22..f28b394ffb1 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -2262,6 +2262,9 @@ "type": "integer" } }, + "required": [ + "userId" + ], "type": "object" }, "AddTeamRoleCommand": { @@ -4460,6 +4463,9 @@ "type": "string" } }, + "required": [ + "name" + ], "type": "object" }, "DashboardACLInfoDTO": { @@ -8158,6 +8164,7 @@ "type": "object" }, "Permission": { + "description": "Permission is the model for access control permissions", "properties": { "action": { "type": "string" @@ -8174,7 +8181,6 @@ "type": "string" } }, - "title": "Permission is the model for access control permissions.", "type": "object" }, "PermissionDenied": { @@ -10115,6 +10121,16 @@ "type": "integer" } }, + "required": [ + "version", + "uid", + "name", + "displayName", + "description", + "group", + "updated", + "created" + ], "type": "object" }, "RolesSearchQuery": { @@ -10955,6 +10971,20 @@ }, "type": "object" }, + "SetTeamRolesCommand": { + "properties": { + "includeHidden": { + "type": "boolean" + }, + "roleUids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "SetUserRolesCommand": { "properties": { "global": { @@ -11407,6 +11437,7 @@ "type": "string" }, "id": { + "description": "@deprecated Use UID instead", "format": "int64", "type": "integer" }, @@ -11431,6 +11462,14 @@ "type": "string" } }, + "required": [ + "id", + "uid", + "orgId", + "name", + "isProvisioned", + "memberCount" + ], "type": "object" }, "TeamGroupDTO": { @@ -14207,6 +14246,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetTeamRolesCommand" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, "responses": { "200": { "$ref": "#/components/responses/okResponse" @@ -24515,6 +24565,21 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "accesscontrol", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string" + } } ], "responses": { From 1a2beae38a245dfa1ff25e97b390d63280bc3117 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Mon, 10 Nov 2025 15:06:30 +0100 Subject: [PATCH 118/209] Preinstall: Replace auto update feature flag with a permanent one (#113586) --- conf/defaults.ini | 3 ++ .../setup-grafana/configure-grafana/_index.md | 9 ++++ .../feature-toggles/index.md | 1 - packages/grafana-data/src/types/config.ts | 1 + .../src/types/featureToggles.gen.ts | 5 -- packages/grafana-runtime/src/config.ts | 1 + pkg/api/dtos/frontend_settings.go | 47 ++++++++++--------- pkg/api/frontendsettings.go | 41 ++++++++-------- pkg/server/wire_gen.go | 4 +- pkg/services/featuremgmt/registry.go | 7 --- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 -- pkg/services/featuremgmt/toggles_gen.json | 3 +- .../plugininstaller/service.go | 8 +--- .../plugininstaller/service_test.go | 4 +- pkg/setting/setting.go | 1 + pkg/setting/setting_plugins.go | 2 + .../components/VersionInstallButton.test.tsx | 7 +-- .../admin/components/VersionInstallButton.tsx | 2 +- 19 files changed, 74 insertions(+), 77 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 5373b53882c..7168a6d9eee 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1934,6 +1934,9 @@ preinstall_disabled = false # Update strategy for plugins. # Available options: "latest", "minor" update_strategy = minor +# Enable automatic updates for preinstalled plugins on startup. +# When enabled, preinstalled plugins without a pinned version will be updated to the latest version. +preinstall_auto_update = true #################################### Grafana Live ########################################## [live] diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 795b529ca85..1062414d287 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2622,6 +2622,15 @@ These will be installed before starting Grafana. Useful when used with provision This option disables all preinstalled plugins. The default is `false`. To disable a specific plugin from being preinstalled, use the `disable_plugins` option. +#### `preinstall_auto_update` + +Enable automatic updates for preinstalled plugins on start-up. +When enabled, preinstalled plugins without a pinned version are automatically updated to the latest version when Grafana starts. + +The default is `true`. + +To prevent automatic updates for specific plugins, pin them to a specific version using the format `plugin_id@version` in the `preinstall` setting. +
### `[live]` diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 45ddf656223..dea03f07484 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -66,7 +66,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes | | `pluginsSriChecks` | Enables SRI checks for plugin assets | | | `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | | -| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes | | `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes | | `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes | | `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes | diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 92991080976..6d7be601af6 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -311,6 +311,7 @@ export interface GrafanaConfig { pluginCatalogHiddenPlugins: string[]; pluginCatalogManagedPlugins: string[]; pluginCatalogPreinstalledPlugins: PreinstalledPlugin[]; + pluginCatalogPreinstalledAutoUpdate?: boolean; pluginsCDNBaseURL: string; tokenExpirationDayLimit: number; listDashboardScopesEndpoint: string; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8a50becd224..89e1f8e9aed 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -733,11 +733,6 @@ export interface FeatureToggles { */ azureMonitorDisableLogLimit?: boolean; /** - * Enables automatic updates for pre-installed plugins - * @default true - */ - preinstallAutoUpdate?: boolean; - /** * Enables experimental reconciler for playlists */ playlistsReconciler?: boolean; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index bf36333e107..343e638fb03 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -169,6 +169,7 @@ export class GrafanaBootConfig { pluginCatalogHiddenPlugins: string[] = []; pluginCatalogManagedPlugins: string[] = []; pluginCatalogPreinstalledPlugins: PreinstalledPluginGrafanaData[] = []; + pluginCatalogPreinstalledAutoUpdate?: boolean; pluginsCDNBaseURL = ''; expressionsEnabled = false; awsAllowedAuthProviders: string[] = []; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 296ab669aa2..d550d6c88f9 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -235,29 +235,30 @@ type FrontendSettingsDTO struct { LicenseInfo FrontendSettingsLicenseInfoDTO `json:"licenseInfo"` - FeatureToggles map[string]bool `json:"featureToggles"` - AnonymousEnabled bool `json:"anonymousEnabled"` - AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"` - RendererAvailable bool `json:"rendererAvailable"` - RendererVersion string `json:"rendererVersion"` - RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"` - RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"` - RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"` - Http2Enabled bool `json:"http2Enabled"` - GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"` - PluginCatalogURL string `json:"pluginCatalogURL"` - PluginAdminEnabled bool `json:"pluginAdminEnabled"` - PluginAdminExternalManageEnabled bool `json:"pluginAdminExternalManageEnabled"` - PluginCatalogHiddenPlugins []string `json:"pluginCatalogHiddenPlugins"` - PluginCatalogManagedPlugins []string `json:"pluginCatalogManagedPlugins"` - PluginCatalogPreinstalledPlugins []setting.InstallPlugin `json:"pluginCatalogPreinstalledPlugins"` - ExpressionsEnabled bool `json:"expressionsEnabled"` - AwsAllowedAuthProviders []string `json:"awsAllowedAuthProviders"` - AwsAssumeRoleEnabled bool `json:"awsAssumeRoleEnabled"` - SupportBundlesEnabled bool `json:"supportBundlesEnabled"` - SnapshotEnabled bool `json:"snapshotEnabled"` - SecureSocksDSProxyEnabled bool `json:"secureSocksDSProxyEnabled"` - ReportingStaticContext map[string]string `json:"reportingStaticContext"` + FeatureToggles map[string]bool `json:"featureToggles"` + AnonymousEnabled bool `json:"anonymousEnabled"` + AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"` + RendererAvailable bool `json:"rendererAvailable"` + RendererVersion string `json:"rendererVersion"` + RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"` + RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"` + RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"` + Http2Enabled bool `json:"http2Enabled"` + GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"` + PluginCatalogURL string `json:"pluginCatalogURL"` + PluginAdminEnabled bool `json:"pluginAdminEnabled"` + PluginAdminExternalManageEnabled bool `json:"pluginAdminExternalManageEnabled"` + PluginCatalogHiddenPlugins []string `json:"pluginCatalogHiddenPlugins"` + PluginCatalogManagedPlugins []string `json:"pluginCatalogManagedPlugins"` + PluginCatalogPreinstalledPlugins []setting.InstallPlugin `json:"pluginCatalogPreinstalledPlugins"` + PluginCatalogPreinstalledAutoUpdate bool `json:"pluginCatalogPreinstalledAutoUpdate"` + ExpressionsEnabled bool `json:"expressionsEnabled"` + AwsAllowedAuthProviders []string `json:"awsAllowedAuthProviders"` + AwsAssumeRoleEnabled bool `json:"awsAssumeRoleEnabled"` + SupportBundlesEnabled bool `json:"supportBundlesEnabled"` + SnapshotEnabled bool `json:"snapshotEnabled"` + SecureSocksDSProxyEnabled bool `json:"secureSocksDSProxyEnabled"` + ReportingStaticContext map[string]string `json:"reportingStaticContext"` Azure FrontendSettingsAzureDTO `json:"azure"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 43efa380f5c..6d93ba00938 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -290,26 +290,27 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro EnabledFeatures: hs.License.EnabledFeatures(), }, - FeatureToggles: featureToggles, - AnonymousEnabled: hs.Cfg.Anonymous.Enabled, - AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit, - RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()), - RendererVersion: hs.RenderService.Version(), - RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth, - RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight, - RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale, - Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme, - GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent, - PluginCatalogURL: hs.Cfg.PluginCatalogURL, - PluginAdminEnabled: hs.Cfg.PluginAdminEnabled, - PluginAdminExternalManageEnabled: hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled, - PluginCatalogHiddenPlugins: hs.Cfg.PluginCatalogHiddenPlugins, - PluginCatalogManagedPlugins: hs.managedPluginsService.ManagedPlugins(c.Req.Context()), - PluginCatalogPreinstalledPlugins: append(hs.Cfg.PreinstallPluginsAsync, hs.Cfg.PreinstallPluginsSync...), - ExpressionsEnabled: hs.Cfg.ExpressionsEnabled, - AwsAllowedAuthProviders: hs.Cfg.AWSAllowedAuthProviders, - AwsAssumeRoleEnabled: hs.Cfg.AWSAssumeRoleEnabled, - SupportBundlesEnabled: isSupportBundlesEnabled(hs), + FeatureToggles: featureToggles, + AnonymousEnabled: hs.Cfg.Anonymous.Enabled, + AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit, + RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()), + RendererVersion: hs.RenderService.Version(), + RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth, + RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight, + RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale, + Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme, + GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent, + PluginCatalogURL: hs.Cfg.PluginCatalogURL, + PluginAdminEnabled: hs.Cfg.PluginAdminEnabled, + PluginAdminExternalManageEnabled: hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled, + PluginCatalogHiddenPlugins: hs.Cfg.PluginCatalogHiddenPlugins, + PluginCatalogManagedPlugins: hs.managedPluginsService.ManagedPlugins(c.Req.Context()), + PluginCatalogPreinstalledPlugins: append(hs.Cfg.PreinstallPluginsAsync, hs.Cfg.PreinstallPluginsSync...), + PluginCatalogPreinstalledAutoUpdate: hs.Cfg.PreinstallAutoUpdate, + ExpressionsEnabled: hs.Cfg.ExpressionsEnabled, + AwsAllowedAuthProviders: hs.Cfg.AWSAllowedAuthProviders, + AwsAssumeRoleEnabled: hs.Cfg.AWSAssumeRoleEnabled, + SupportBundlesEnabled: isSupportBundlesEnabled(hs), Azure: dtos.FrontendSettingsAzureDTO{ Cloud: hs.Cfg.Azure.Cloud, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index b37a6913ce4..75aca8fcc9a 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -817,7 +817,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - plugininstallerService, err := plugininstaller.ProvideService(cfg, pluginstoreService, pluginInstaller, registerer, repoManager, featureToggles, plugincheckerService) + plugininstallerService, err := plugininstaller.ProvideService(cfg, pluginstoreService, pluginInstaller, registerer, repoManager, plugincheckerService) if err != nil { return nil, err } @@ -1455,7 +1455,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - plugininstallerService, err := plugininstaller.ProvideService(cfg, pluginstoreService, pluginInstaller, registerer, repoManager, featureToggles, plugincheckerService) + plugininstallerService, err := plugininstaller.ProvideService(cfg, pluginstoreService, pluginInstaller, registerer, repoManager, plugincheckerService) if err != nil { return nil, err } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 27a978a6c27..824e6866aa5 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1261,13 +1261,6 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, - { - Name: "preinstallAutoUpdate", - Description: "Enables automatic updates for pre-installed plugins", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaPluginsPlatformSquad, - Expression: "true", // enabled by default - }, { Name: "playlistsReconciler", Description: "Enables experimental reconciler for playlists", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 71f681c0171..53445f94bfb 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -165,7 +165,6 @@ unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,f timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false timeRangePan,experimental,@grafana/dataviz-squad,false,false,true azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false -preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 443fb196228..fe407b9066f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -671,10 +671,6 @@ const ( // Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. FlagAzureMonitorDisableLogLimit = "azureMonitorDisableLogLimit" - // FlagPreinstallAutoUpdate - // Enables automatic updates for pre-installed plugins - FlagPreinstallAutoUpdate = "preinstallAutoUpdate" - // FlagPlaylistsReconciler // Enables experimental reconciler for playlists FlagPlaylistsReconciler = "playlistsReconciler" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 48155d54721..069c7afd1a1 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3158,7 +3158,8 @@ "metadata": { "name": "preinstallAutoUpdate", "resourceVersion": "1753448760331", - "creationTimestamp": "2024-11-07T12:14:25Z" + "creationTimestamp": "2024-11-07T12:14:25Z", + "deletionTimestamp": "2025-11-07T10:10:01Z" }, "spec": { "description": "Enables automatic updates for pre-installed plugins", diff --git a/pkg/services/pluginsintegration/plugininstaller/service.go b/pkg/services/pluginsintegration/plugininstaller/service.go index 7232703820c..670123e5743 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service.go +++ b/pkg/services/pluginsintegration/plugininstaller/service.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/repo" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -45,7 +44,6 @@ type Service struct { pluginInstaller plugins.Installer pluginStore pluginstore.Store pluginRepo repo.Service - features featuremgmt.FeatureToggles updateChecker pluginchecker.PluginUpdateChecker installComplete chan struct{} // closed when all plugins are installed (used for testing) } @@ -56,7 +54,6 @@ func ProvideService( pluginInstaller plugins.Installer, promReg prometheus.Registerer, pluginRepo repo.Service, - features featuremgmt.FeatureToggles, updateChecker pluginchecker.PluginUpdateChecker, ) (*Service, error) { once.Do(func() { @@ -70,7 +67,6 @@ func ProvideService( pluginInstaller: pluginInstaller, pluginStore: pluginStore, pluginRepo: pluginRepo, - features: features, updateChecker: updateChecker, installComplete: make(chan struct{}), } @@ -111,8 +107,8 @@ func (s *Service) installPlugins(ctx context.Context, pluginsToInstall []setting continue } if installPlugin.Version == "" { - if !s.features.IsEnabled(ctx, featuremgmt.FlagPreinstallAutoUpdate) { - // Skip updating the plugin if the feature flag is disabled + if !s.cfg.PreinstallAutoUpdate { + // Skip updating the plugin if auto-update is disabled continue } // The plugin is installed but it's not pinned to a specific version diff --git a/pkg/services/pluginsintegration/plugininstaller/service_test.go b/pkg/services/pluginsintegration/plugininstaller/service_test.go index c832ff06ee5..f0d34cfc660 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service_test.go +++ b/pkg/services/pluginsintegration/plugininstaller/service_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/repo" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync/installsyncfakes" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" @@ -31,7 +30,6 @@ func TestService_IsDisabled(t *testing.T) { &pluginfakes.FakePluginInstaller{}, prometheus.NewRegistry(), &pluginfakes.FakePluginRepo{}, - featuremgmt.WithFeatures(), &pluginchecker.FakePluginUpdateChecker{}, ) require.NoError(t, err) @@ -167,6 +165,7 @@ func TestService_Run(t *testing.T) { &setting.Cfg{ PreinstallPluginsAsync: tt.pluginsToInstall, PreinstallPluginsSync: tt.pluginsToInstallSync, + PreinstallAutoUpdate: true, }, store, &pluginfakes.FakePluginInstaller{ @@ -199,7 +198,6 @@ func TestService_Run(t *testing.T) { return tt.latestPlugin, nil }, }, - featuremgmt.WithFeatures(featuremgmt.FlagPreinstallAutoUpdate), pluginchecker.ProvideService( managedplugins.NewNoop(), provisionedplugins.NewNoop(), diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 5cad5c1ba9b..fa671ac93ca 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -215,6 +215,7 @@ type Cfg struct { ForwardHostEnvVars []string PreinstallPluginsAsync []InstallPlugin PreinstallPluginsSync []InstallPlugin + PreinstallAutoUpdate bool PluginsCDNURLTemplate string PluginLogBackendRequests bool diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 0080f61d0a4..a6842a276a0 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -185,6 +185,8 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { } cfg.PreinstallPluginsAsync = nil } + + cfg.PreinstallAutoUpdate = pluginsSection.Key("preinstall_auto_update").MustBool(true) } cfg.PluginCatalogURL = pluginsSection.Key("plugin_catalog_url").MustString("https://grafana.com/grafana/plugins/") diff --git a/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx b/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx index 16ec1d3ca70..3c2ea0ce379 100644 --- a/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx +++ b/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx @@ -15,6 +15,7 @@ describe('VersionInstallButton', () => { ...originalConfig.featureToggles, }; config.pluginCatalogPreinstalledPlugins = originalConfig.pluginCatalogPreinstalledPlugins; + config.pluginCatalogPreinstalledAutoUpdate = originalConfig.pluginCatalogPreinstalledAutoUpdate; }); it('should show install when no version is installed', () => { const version: Version = { @@ -120,7 +121,7 @@ describe('VersionInstallButton', () => { grafanaDependency: null, }; const installedVersion = '1.0.0'; - config.featureToggles.preinstallAutoUpdate = true; + config.pluginCatalogPreinstalledAutoUpdate = true; config.pluginCatalogPreinstalledPlugins = [{ id: 'test', version: '1.0.0' }]; renderWithStore( { grafanaDependency: null, }; const installedVersion = '1.0.1'; - config.featureToggles.preinstallAutoUpdate = true; + config.pluginCatalogPreinstalledAutoUpdate = true; config.pluginCatalogPreinstalledPlugins = [{ id: 'test', version: '1.0.1' }]; renderWithStore( { grafanaDependency: null, }; const installedVersion = '1.0.1'; - config.featureToggles.preinstallAutoUpdate = true; + config.pluginCatalogPreinstalledAutoUpdate = true; config.pluginCatalogPreinstalledPlugins = [{ id: 'test', version: '' }]; renderWithStore( Date: Mon, 10 Nov 2025 15:42:44 +0100 Subject: [PATCH 119/209] Docs: Add Tempo to the list of unsupported data sources (#113667) --- .../share-dashboards-panels/shared-dashboards/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/shared-dashboards/index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/shared-dashboards/index.md index dc4b41ce1fd..789d6617c18 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/shared-dashboards/index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/shared-dashboards/index.md @@ -249,6 +249,7 @@ guaranteed because plugin developers can override this functionality. The follow - Dynatrace - Graphite - Google Sheets +- Tempo ### Unconfirmed From 2dc48c0b98bbd092f4d1198533d91e627e99b2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 10 Nov 2025 15:48:59 +0100 Subject: [PATCH 120/209] datasources: querier: add mode-info (#113592) * datasources: querier: add mode-info * fixed unit test --- pkg/registry/apis/query/client/instance_provider.go | 4 ++++ pkg/registry/apis/query/clientapi/clientapi.go | 1 + pkg/registry/apis/query/query.go | 2 ++ pkg/registry/apis/query/query_test.go | 4 ++++ 4 files changed, 11 insertions(+) diff --git a/pkg/registry/apis/query/client/instance_provider.go b/pkg/registry/apis/query/client/instance_provider.go index e48e2e9a084..9a8e6138b65 100644 --- a/pkg/registry/apis/query/client/instance_provider.go +++ b/pkg/registry/apis/query/client/instance_provider.go @@ -52,6 +52,10 @@ func (s *singleTenantInstanceProvider) GetInstance(_ context.Context, logger log }, nil } +func (s *singleTenantInstanceProvider) GetMode() string { + return "st" +} + func (s *singleTenantInstance) GetSettings() clientapi.InstanceConfigurationSettings { return s.instanceConf } diff --git a/pkg/registry/apis/query/clientapi/clientapi.go b/pkg/registry/apis/query/clientapi/clientapi.go index 5d44045f77f..0248ebab8ac 100644 --- a/pkg/registry/apis/query/clientapi/clientapi.go +++ b/pkg/registry/apis/query/clientapi/clientapi.go @@ -39,4 +39,5 @@ type Instance interface { type InstanceProvider interface { GetInstance(ctx context.Context, logger log.Logger, headers map[string]string) (Instance, error) + GetMode() string } diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index 30a1ae6b567..987251bcdb0 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -122,6 +122,8 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O b := r.builder return http.HandlerFunc(func(w http.ResponseWriter, httpreq *http.Request) { + w.Header().Set("X-Ds-Querier", b.instanceProvider.GetMode()) + ctx, span := b.tracer.Start(httpreq.Context(), "QueryService.Query") defer span.End() ctx = request.WithNamespace(ctx, request.NamespaceValue(connectCtx)) diff --git a/pkg/registry/apis/query/query_test.go b/pkg/registry/apis/query/query_test.go index 53ee003882b..b23c78d55bc 100644 --- a/pkg/registry/apis/query/query_test.go +++ b/pkg/registry/apis/query/query_test.go @@ -274,6 +274,10 @@ func (m mockClient) GetInstance(ctx context.Context, logger log.Logger, headers return mclient, nil } +func (m mockClient) GetMode() string { + return "testing" +} + func (m mockClient) ReportMetrics() { } From f094a9d5e5862fc034d6b1dc68efe2119d351cfa Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 10 Nov 2025 12:08:56 -0300 Subject: [PATCH 121/209] Caching: Add flag to disable clean cache button (#113598) Caching: add flag to disable clean cache button --- packages/grafana-runtime/src/config.ts | 1 + pkg/api/dtos/frontend_settings.go | 3 ++- pkg/api/frontendsettings.go | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 343e638fb03..41211ec9abb 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -183,6 +183,7 @@ export class GrafanaBootConfig { }; caching = { enabled: false, + cleanCacheEnabled: true, }; geomapDefaultBaseLayerConfig?: MapLayerOptions; geomapDisableCustomBaseLayer?: boolean; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index d550d6c88f9..f846e25795c 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -78,7 +78,8 @@ type FrontendSettingsAzureDTO struct { } type FrontendSettingsCachingDTO struct { - Enabled bool `json:"enabled"` + Enabled bool `json:"enabled"` + CleanCacheEnabled bool `json:"cleanCacheEnabled"` } type FrontendSettingsRecordedQueriesDTO struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 6d93ba00938..f3401ca2180 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -323,7 +323,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro }, Caching: dtos.FrontendSettingsCachingDTO{ - Enabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("enabled").MustBool(true), + Enabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("enabled").MustBool(true), + CleanCacheEnabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("clean_cache_enabled").MustBool(true), }, RecordedQueries: dtos.FrontendSettingsRecordedQueriesDTO{ Enabled: hs.Cfg.SectionWithEnvOverrides("recorded_queries").Key("enabled").MustBool(true), From 243f1fc64b1c1fa5ab19100736b085d8fc2ca426 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Mon, 10 Nov 2025 11:26:00 -0400 Subject: [PATCH 122/209] Timeseries: Change mouse cursors to indicate active x-axis and y-axis zoom interactions (#113465) * feat(panel-zoom): change mouse cursor when zooming x-axis or y-axis * test(panel-zoom): browser test mouse cursor change interactions * fix(mouse-cursor-styles): no need for important --- .../timeseries-mouse-zoom-interaction.spec.ts | 93 +++++++++++++++++++ .../uPlot/plugins/TooltipPlugin2.tsx | 24 +++++ .../src/themes/GlobalStyles/uPlot.ts | 4 + 3 files changed, 121 insertions(+) create mode 100644 e2e-playwright/panels-suite/timeseries-mouse-zoom-interaction.spec.ts diff --git a/e2e-playwright/panels-suite/timeseries-mouse-zoom-interaction.spec.ts b/e2e-playwright/panels-suite/timeseries-mouse-zoom-interaction.spec.ts new file mode 100644 index 00000000000..c881151f656 --- /dev/null +++ b/e2e-playwright/panels-suite/timeseries-mouse-zoom-interaction.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'TkZXxlNG3'; + +test.describe('Panels test: Timeseries zoom interaction', { tag: ['@panels', '@timeseries'] }, () => { + test('shows zoom cursor during x-axis drag-to-zoom interaction', async ({ gotoDashboardPage, page }) => { + await gotoDashboardPage({ + uid: DASHBOARD_UID, + }); + + const uplotOverlay = page.locator('.u-over').first(); + await expect(uplotOverlay, 'uplot overlay is visible').toBeVisible(); + + const bbox = await uplotOverlay.boundingBox(); + expect(bbox, 'uplot overlay has dimensions').toBeDefined(); + + await expect(uplotOverlay, 'no zoom cursor initially').not.toHaveClass(/zoom-drag/); + + await page.mouse.move(bbox!.x + 100, bbox!.y + 50); + await page.mouse.down(); + + await expect(uplotOverlay, 'zoom cursor appears on mousedown').toHaveClass(/zoom-drag/); + + await page.mouse.move(bbox!.x + 300, bbox!.y + 50); + + await expect(uplotOverlay, 'zoom cursor persists during drag').toHaveClass(/zoom-drag/); + + await page.mouse.up(); + + await expect(uplotOverlay, 'zoom cursor removed after mouseup').not.toHaveClass(/zoom-drag/); + }); + + test('shows zoom cursor during y-axis drag-to-zoom interaction with Shift key', async ({ + gotoDashboardPage, + page, + }) => { + await gotoDashboardPage({ + uid: DASHBOARD_UID, + }); + + const uplotOverlay = page.locator('.u-over').first(); + await expect(uplotOverlay, 'uplot overlay is visible').toBeVisible(); + + const bbox = await uplotOverlay.boundingBox(); + expect(bbox, 'uplot overlay has dimensions').toBeDefined(); + + await expect(uplotOverlay, 'no zoom cursor initially').not.toHaveClass(/zoom-drag/); + + await page.keyboard.down('Shift'); + await page.mouse.move(bbox!.x + 100, bbox!.y + 50); + await page.mouse.down(); + + await expect(uplotOverlay, 'zoom cursor appears on mousedown with Shift').toHaveClass(/zoom-drag/); + + await page.mouse.move(bbox!.x + 100, bbox!.y + 150); + + await expect(uplotOverlay, 'zoom cursor persists during drag with Shift').toHaveClass(/zoom-drag/); + + await page.mouse.up(); + await page.keyboard.up('Shift'); + + await expect(uplotOverlay, 'zoom cursor removed after mouseup').not.toHaveClass(/zoom-drag/); + }); + + test('does not show zoom cursor when modifier keys are pressed', async ({ gotoDashboardPage, page }) => { + await gotoDashboardPage({ + uid: DASHBOARD_UID, + }); + + const uplotOverlay = page.locator('.u-over').first(); + await expect(uplotOverlay, 'uplot overlay is visible').toBeVisible(); + + const bbox = await uplotOverlay.boundingBox(); + + await page.keyboard.down('Control'); + await page.mouse.move(bbox!.x + 100, bbox!.y + 50); + await page.mouse.down(); + + await expect(uplotOverlay, 'no zoom cursor with Ctrl key').not.toHaveClass(/zoom-drag/); + + await page.mouse.up(); + await page.keyboard.up('Control'); + + await page.keyboard.down('Meta'); + await page.mouse.move(bbox!.x + 100, bbox!.y + 50); + await page.mouse.down(); + + await expect(uplotOverlay, 'no zoom cursor with Meta key').not.toHaveClass(/zoom-drag/); + + await page.mouse.up(); + await page.keyboard.up('Meta'); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 17147e8661b..606839aacad 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -340,6 +340,30 @@ export const TooltipPlugin2 = ({ ); } + // add zoom-in cursor during drag-to-zoom interaction + if (queryZoom != null || clientZoom) { + u.over.addEventListener( + 'mousedown', + (e) => { + if (!maybeZoomAction(e)) { + return; + } + + if (e.button === 0) { + u.over.classList.add('zoom-drag'); + + let onUp = () => { + u.over.classList.remove('zoom-drag'); + document.removeEventListener('mouseup', onUp, true); + }; + + document.addEventListener('mouseup', onUp, true); + } + }, + true + ); + } + // this handles pinning, 0-width range selection, and one-click u.over.addEventListener('click', (e) => { if (e.target === u.over) { diff --git a/packages/grafana-ui/src/themes/GlobalStyles/uPlot.ts b/packages/grafana-ui/src/themes/GlobalStyles/uPlot.ts index 3637999adee..ae0e066bbfc 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/uPlot.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/uPlot.ts @@ -12,6 +12,10 @@ export function getUplotStyles(theme: GrafanaTheme2) { background: 'rgba(120, 120, 130, 0.2)', }, + '.u-over.zoom-drag': { + cursor: 'zoom-in', + }, + '.u-hz .u-cursor-x, .u-vt .u-cursor-y': { borderRight: '1px dashed rgba(120, 120, 130, 0.5)', }, From fb20d7311ea8f2e77f21ef4896aba7247e2856b5 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 10 Nov 2025 10:26:38 -0500 Subject: [PATCH 123/209] Chore: Change ownership of some Dataviz feature flags to Datapro (#113528) * Chore: Change ownership of some Dataviz feature flags * retain ownership of csv drag n drop --- pkg/services/featuremgmt/registry.go | 12 ++--- pkg/services/featuremgmt/toggles_gen.csv | 12 ++--- pkg/services/featuremgmt/toggles_gen.json | 64 +++++++++++++++-------- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 824e6866aa5..6bc730b8d50 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -73,7 +73,7 @@ var ( Name: "correlations", Description: "Correlations page", Stage: FeatureStageGeneralAvailability, - Owner: grafanaDatavizSquad, + Owner: grafanaDataProSquad, Expression: "true", // enabled by default AllowSelfServe: true, }, @@ -430,7 +430,7 @@ var ( Description: "Enable format string transformer", Stage: FeatureStageGeneralAvailability, FrontendOnly: true, - Owner: grafanaDatavizSquad, + Owner: grafanaDataProSquad, Expression: "true", // enabled by default }, { @@ -581,7 +581,7 @@ var ( Description: "Add cumulative and window functions to the add field from calculation transformation", Stage: FeatureStageGeneralAvailability, FrontendOnly: true, - Owner: grafanaDatavizSquad, + Owner: grafanaDataProSquad, Expression: "true", // enabled by default }, { @@ -617,7 +617,7 @@ var ( Description: "Make sure extracted field names are unique in the dataframe", Stage: FeatureStageExperimental, FrontendOnly: true, - Owner: grafanaDatavizSquad, + Owner: grafanaDataProSquad, }, { Name: "dashboardSceneForViewers", @@ -728,7 +728,7 @@ var ( Description: "Enables regression analysis transformation", Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaDatavizSquad, + Owner: grafanaDataProSquad, }, { Name: "kubernetesFeatureToggles", @@ -858,7 +858,7 @@ var ( Description: "Enables the group to nested table transformation", Stage: FeatureStageGeneralAvailability, FrontendOnly: true, - Owner: grafanaDatavizSquad, + Owner: grafanaDataProSquad, Expression: "true", // enabled by default, }, { diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 53445f94bfb..8695ec64f57 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -6,7 +6,7 @@ publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false, lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false storage,experimental,@grafana/search-and-storage,false,false,false -correlations,GA,@grafana/dataviz-squad,false,false,false +correlations,GA,@grafana/datapro,false,false,false canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true logRequestsInstrumentedAsUnknown,experimental,@grafana/grafana-backend-group,false,false,false grpcServer,preview,@grafana/search-and-storage,false,false,false @@ -54,7 +54,7 @@ lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false, externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false -formatString,GA,@grafana/dataviz-squad,false,false,true +formatString,GA,@grafana/datapro,false,false,true kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesLibraryPanels,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,GA,@grafana/dashboards-squad,false,false,true @@ -76,12 +76,12 @@ queryServiceFromUI,experimental,@grafana/grafana-datasources-core-services,false queryServiceFromExplore,experimental,@grafana/grafana-datasources-core-services,false,false,true cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false -addFieldFromCalculationStatFunctions,GA,@grafana/dataviz-squad,false,false,true +addFieldFromCalculationStatFunctions,GA,@grafana/datapro,false,false,true alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false -extractFieldsNameDeduplication,experimental,@grafana/dataviz-squad,false,false,true +extractFieldsNameDeduplication,experimental,@grafana/datapro,false,false,true dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true @@ -96,7 +96,7 @@ logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true -regressionTransformation,preview,@grafana/dataviz-squad,false,false,true +regressionTransformation,preview,@grafana/datapro,false,false,true kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false @@ -112,7 +112,7 @@ useMultipleScopeNodesEndpoint,experimental,@grafana/grafana-operator-experience- logQLScope,privatePreview,@grafana/observability-logs,false,false,false sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false sqlExpressionsColumnAutoComplete,experimental,@grafana/datapro,false,false,true -groupToNestedTableTransformation,GA,@grafana/dataviz-squad,false,false,true +groupToNestedTableTransformation,GA,@grafana/datapro,false,false,true newPDFRendering,GA,@grafana/grafana-operator-experience-squad,false,false,false tlsMemcached,GA,@grafana/grafana-operator-experience-squad,false,false,false kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 069c7afd1a1..dbf4d982629 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -6,13 +6,16 @@ { "metadata": { "name": "addFieldFromCalculationStatFunctions", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-11-03T14:39:58Z" + "resourceVersion": "1762442825881", + "creationTimestamp": "2023-11-03T14:39:58Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" + } }, "spec": { "description": "Add cumulative and window functions to the add field from calculation transformation", "stage": "GA", - "codeowner": "@grafana/dataviz-squad", + "codeowner": "@grafana/datapro", "frontend": true, "expression": "true" } @@ -853,7 +856,8 @@ "metadata": { "name": "canvasPanelNesting", "resourceVersion": "1753448760331", - "creationTimestamp": "2022-05-31T19:03:34Z" + "creationTimestamp": "2022-05-31T19:03:34Z", + "deletionTimestamp": "2025-11-06T14:43:27Z" }, "spec": { "description": "Allow elements nesting", @@ -1001,13 +1005,16 @@ { "metadata": { "name": "correlations", - "resourceVersion": "1753448760331", - "creationTimestamp": "2022-09-16T13:14:27Z" + "resourceVersion": "1762442825881", + "creationTimestamp": "2022-09-16T13:14:27Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" + } }, "spec": { "description": "Correlations page", "stage": "GA", - "codeowner": "@grafana/dataviz-squad", + "codeowner": "@grafana/datapro", "allowSelfServe": true, "expression": "true" } @@ -1343,8 +1350,11 @@ { "metadata": { "name": "editPanelCSVDragAndDrop", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-01-24T09:43:44Z" + "resourceVersion": "1762783224740", + "creationTimestamp": "2023-01-24T09:43:44Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-10 14:00:24.740459 +0000 UTC" + } }, "spec": { "description": "Enables drag and drop for CSV and Excel files", @@ -1606,13 +1616,16 @@ { "metadata": { "name": "extractFieldsNameDeduplication", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-11-02T15:47:42Z" + "resourceVersion": "1762442825881", + "creationTimestamp": "2023-11-02T15:47:42Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" + } }, "spec": { "description": "Make sure extracted field names are unique in the dataframe", "stage": "experimental", - "codeowner": "@grafana/dataviz-squad", + "codeowner": "@grafana/datapro", "frontend": true } }, @@ -1735,13 +1748,16 @@ { "metadata": { "name": "formatString", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-10-13T18:17:12Z" + "resourceVersion": "1762442825881", + "creationTimestamp": "2023-10-13T18:17:12Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" + } }, "spec": { "description": "Enable format string transformer", "stage": "GA", - "codeowner": "@grafana/dataviz-squad", + "codeowner": "@grafana/datapro", "frontend": true, "expression": "true" } @@ -1907,13 +1923,16 @@ { "metadata": { "name": "groupToNestedTableTransformation", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-02-07T14:28:26Z" + "resourceVersion": "1762442825881", + "creationTimestamp": "2024-02-07T14:28:26Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" + } }, "spec": { "description": "Enables the group to nested table transformation", "stage": "GA", - "codeowner": "@grafana/dataviz-squad", + "codeowner": "@grafana/datapro", "frontend": true, "expression": "true" } @@ -3435,14 +3454,17 @@ { "metadata": { "name": "regressionTransformation", - "resourceVersion": "1753448760331", + "resourceVersion": "1762442825881", "creationTimestamp": "2023-11-24T14:49:16Z", - "deletionTimestamp": "2025-07-01T13:59:22Z" + "deletionTimestamp": "2025-07-01T13:59:22Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" + } }, "spec": { "description": "Enables regression analysis transformation", "stage": "preview", - "codeowner": "@grafana/dataviz-squad", + "codeowner": "@grafana/datapro", "frontend": true } }, From 70e30df6ce4d264e73a24561a28ddd41ccc4b460 Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Mon, 10 Nov 2025 11:43:38 -0500 Subject: [PATCH 124/209] Alerting: Fix support for converted Prometheus rules in app-platform apis (#113648) * Alerting: Fix support for converted Prometheus rules in app-platform apis Retrieving converted Prometheus retrieval rules was not supported in the app-platform apis and was throwing a 500 error due to the provenance not being handled properly. Also adds a test to cover converted Prometheus rules when getting rules. Closes https://github.com/grafana/alerting-squad/issues/1200 * add test to confirm provenance compatibility --- .../rules/pkg/apis/alerting/v0alpha1/ext.go | 10 +- .../convertprometheus_retrieval_test.go | 227 ++++++++++++++++++ 2 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go diff --git a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go index 73606736a3f..f4efc73e014 100644 --- a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go +++ b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go @@ -19,13 +19,17 @@ const ( FolderLabelKey = FolderAnnotationKey ) +// NOTE: This is a copy of the constants from the alertrule package to avoid circular imports. +// Keep in sync with pkg/services/ngalert/models/provisioning.go const ( - ProvenanceStatusNone = "" - ProvenanceStatusAPI = "api" + ProvenanceStatusNone = "" + ProvenanceStatusAPI = "api" + ProvenanceStatusFile = "file" + ProvenanceStatusConvertedPrometheus = "converted_prometheus" ) var ( - AcceptedProvenanceStatuses = []string{ProvenanceStatusNone, ProvenanceStatusAPI} + AcceptedProvenanceStatuses = []string{ProvenanceStatusNone, ProvenanceStatusAPI, ProvenanceStatusFile, ProvenanceStatusConvertedPrometheus} ) func ToDuration(s string) (time.Duration, error) { diff --git a/pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go b/pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go new file mode 100644 index 00000000000..21a89180209 --- /dev/null +++ b/pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go @@ -0,0 +1,227 @@ +package compat + +import ( + "context" + "encoding/json" + "testing" + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + prom_model "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tests/api/alerting" + "github.com/grafana/grafana/pkg/tests/apis/alerting/rules/common" + "github.com/grafana/grafana/pkg/util/testutil" +) + +// TestIntegrationConvertPrometheusAlertRuleRetrieval verifies that an alert rule created +// through the Prometheus (mimirtool compatible) conversion API can be retrieved via the +// new Kubernetes rules API and retains expected fields (title, expressions, interval, +// folder annotation, group labels, and provenance converted_prometheus). +func TestIntegrationConvertPrometheusAlertRuleRetrieval(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := common.GetTestHelper(t) + + // K8s client for new rules API + k8sAlertClient := common.NewAlertRuleClient(t, helper.Org1.Admin) + + // Legacy + conversion API client + legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin") + + // Ensure legacy API is enabled (sanity) + allRules, status, _ := legacyClient.GetAllRulesWithStatus(t) + require.Equal(t, 200, status) + require.NotNil(t, allRules) + + // Create folder that will act as namespace title + folderUID := "test-folder-convert" + common.CreateTestFolder(t, helper, folderUID) + + // Build a Prometheus-compatible rule group payload (minimal) for conversion API + // We simulate a simple alert rule with expr and for duration; conversion API will set provenance. + // We pick static values; Grafana rule title will mirror Prometheus 'alert' name. + forDuration := prom_model.Duration(10 * time.Second) + interval20, err := prom_model.ParseDuration("20s") + require.NoError(t, err) + promGroup := apimodels.PrometheusRuleGroup{ + Name: "test-group", + Interval: interval20, + Rules: []apimodels.PrometheusRule{ + { + Alert: "ConvertedAlertTest", + Expr: "vector(1)", // simple always firing expression + For: &forDuration, + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "Converted alert rule test", + }, + }, + }, + } + + // Create a real Prometheus datasource; conversion API requires a Prometheus-compatible datasource (cannot use __expr__). + ds := legacyClient.CreateDatasource(t, "prometheus") + dsUID := ds.Body.Datasource.UID + require.NotEmpty(t, dsUID, "prometheus datasource UID must not be empty") + defer legacyClient.DeleteDatasource(t, dsUID) + headers := map[string]string{} + + // Post conversion (client signature: namespaceTitle, datasourceUID, promGroup, headers) + // Use the folder title as the namespace title for conversion API lookup. + // Our helper created a folder with title "Test Folder" and UID folderUID. + resp := legacyClient.ConvertPrometheusPostRuleGroup(t, "Test Folder", dsUID, promGroup, headers) + require.Equal(t, "success", resp.Status) + + // Retrieve the converted rule via new K8s API immediately after the API call. + ruleList, err := k8sAlertClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + var found *v0alpha1.AlertRule + for _, item := range ruleList.Items { + if item.Spec.Title == "ConvertedAlertTest" { // Title should match the Prometheus alert name + copy := item.DeepCopy() + found = copy + break + } + } + require.NotNil(t, found, "expected to find converted alert rule via K8s API") + + // Assertions on converted rule fields + require.Equal(t, folderUID, found.Annotations["grafana.app/folder"], "folder annotation must match folder UID") + require.Equal(t, "test-group", found.Labels[v0alpha1.GroupLabelKey], "group label must match group name") + require.NotEmpty(t, found.Labels[v0alpha1.GroupIndexLabelKey], "group index label must be populated") + + // Interval: parse prom group interval and compare with spec trigger interval + require.Equal(t, promGroup.Interval.String(), string(found.Spec.Trigger.Interval), "interval mismatch") + + // Expression/model checks: Alert rule conversion produces query + math + threshold nodes. + require.Equal(t, 3, len(found.Spec.Expressions), "expected three expressions (query, math, threshold) in converted alert rule") + for ref, exp := range found.Spec.Expressions { + require.NotNil(t, exp.Model, "expression model %s should not be nil", ref) + // Only query expression carries a datasource UID; math/threshold may omit it. If present, must match dsUID. + if exp.DatasourceUID != nil { + require.EqualValues(t, dsUID, *exp.DatasourceUID) + } + } + + // Provenance should be converted_prometheus + require.Equal(t, ngmodels.ProvenanceConvertedPrometheus, ngmodels.Provenance(found.GetProvenanceStatus()), "provenance mismatch") + + // Basic JSON model sanity (non-empty if map) + // Basic JSON model sanity for at least one expression + var sanityChecked bool + for _, exp := range found.Spec.Expressions { + if m, ok := exp.Model.(map[string]interface{}); ok { + require.NotEmpty(t, m) + sanityChecked = true + break + } + } + require.True(t, sanityChecked, "expected at least one expression model to be a non-empty map") +} + +// TestIntegrationConvertPrometheusRecordingRuleRetrieval verifies recording rule conversion retrieval via K8s API. +func TestIntegrationConvertPrometheusRecordingRuleRetrieval(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := common.GetTestHelper(t) + + k8sRecordingClient := common.NewRecordingRuleClient(t, helper.Org1.Admin) + legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin") + + allRules, status, _ := legacyClient.GetAllRulesWithStatus(t) + require.Equal(t, 200, status) + require.NotNil(t, allRules) + + folderUID := "test-folder-convert-record" + common.CreateTestFolder(t, helper, folderUID) + + forDuration := prom_model.Duration(5 * time.Second) + interval20, err := prom_model.ParseDuration("20s") + require.NoError(t, err) + promGroup := apimodels.PrometheusRuleGroup{ + Name: "test-group-rec", + Interval: interval20, + Rules: []apimodels.PrometheusRule{ + { + Record: "converted_metric_total", + Expr: "vector(2)", + For: &forDuration, // For is ignored for recording rules but included for consistency + Labels: map[string]string{"job": "demo"}, + Annotations: map[string]string{"summary": "Converted recording rule test"}, + }, + }, + } + // Create a real Prometheus datasource for recording rule conversion. + ds := legacyClient.CreateDatasource(t, "prometheus") + dsUID := ds.Body.Datasource.UID + require.NotEmpty(t, dsUID, "prometheus datasource UID must not be empty") + defer legacyClient.DeleteDatasource(t, dsUID) + headers := map[string]string{ + "X-Grafana-Alerting-Target-Datasource-UID": dsUID, + } + + resp := legacyClient.ConvertPrometheusPostRuleGroup(t, "Test Folder", dsUID, promGroup, headers) + require.Equal(t, "success", resp.Status) + + // Retrieve the converted recording rule immediately + list, err := k8sRecordingClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + var found *v0alpha1.RecordingRule + for _, item := range list.Items { + if item.Spec.Metric == "converted_metric_total" { + copy := item.DeepCopy() + found = copy + break + } + } + require.NotNil(t, found, "expected to find converted recording rule via K8s API") + + require.Equal(t, folderUID, found.Annotations["grafana.app/folder"], "folder annotation must match") + require.Equal(t, "test-group-rec", found.Labels[v0alpha1.GroupLabelKey]) + require.NotEmpty(t, found.Labels[v0alpha1.GroupIndexLabelKey]) + + require.Equal(t, promGroup.Interval.String(), string(found.Spec.Trigger.Interval)) + + // Verify expressions map non-empty + require.Equal(t, 1, len(found.Spec.Expressions)) + var exprSpec v0alpha1.RecordingRuleExpression + for _, v := range found.Spec.Expressions { + exprSpec = v + break + } + require.NotNil(t, exprSpec.Model) + + // Datasource should match provided header + require.EqualValues(t, dsUID, *exprSpec.DatasourceUID) + + require.Equal(t, ngmodels.ProvenanceConvertedPrometheus, ngmodels.Provenance(found.GetProvenanceStatus())) + + if m, ok := exprSpec.Model.(map[string]interface{}); ok { + require.NotEmpty(t, m) + } else if b, ok := exprSpec.Model.([]byte); ok { + tmp := map[string]interface{}{} + _ = json.Unmarshal(b, &tmp) + require.NotEmpty(t, tmp) + } +} + +// If this test fails, it indicates that the accepted provenance statuses +// in v0alpha1/ext.go are out of sync with the known provenances in ngalert models. +// Make sure to keep them in sync. +func TestProvenancesMatch(t *testing.T) { + modelProvenances := make([]string, 0, len(ngmodels.KnownProvenances)) + for _, p := range ngmodels.KnownProvenances { + modelProvenances = append(modelProvenances, string(p)) + } + require.ElementsMatch(t, v0alpha1.AcceptedProvenanceStatuses, modelProvenances) +} From ac9259d6a4de3f43ca164e29bc4f06bb01239b6b Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Mon, 10 Nov 2025 17:47:29 +0100 Subject: [PATCH 125/209] Chore: Update pyroscope error sources logging (#113175) * remove loggers * more downstream errors * more downstream errors 2 * uber nit --- .../grafana-pyroscope-datasource/instance.go | 61 ++++++------------- .../pyroscopeClient.go | 9 +-- .../grafana-pyroscope-datasource/service.go | 36 +++++------ 3 files changed, 37 insertions(+), 69 deletions(-) diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index e2fe25c94ef..244481aacbd 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -3,7 +3,6 @@ package pyroscope import ( "context" "encoding/json" - "fmt" "net/http" "net/url" "slices" @@ -46,16 +45,13 @@ type PyroscopeDatasource struct { // NewPyroscopeDatasource creates a new datasource instance. func NewPyroscopeDatasource(ctx context.Context, httpClientProvider httpclient.Provider, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - ctxLogger := logger.FromContext(ctx) opt, err := settings.HTTPClientOptions(ctx) if err != nil { - ctxLogger.Error("Failed to get HTTP client options", "error", err, "function", logEntrypoint()) - return nil, err + return nil, backend.DownstreamErrorf("failed to get HTTP client options: %w. function: %s", err, logEntrypoint()) } httpClient, err := httpClientProvider.New(opt) if err != nil { - ctxLogger.Error("Failed to create HTTP client", "error", err, "function", logEntrypoint()) - return nil, err + return nil, backend.DownstreamErrorf("failed to create HTTP client: %w. function: %s", err, logEntrypoint()) } return &PyroscopeDatasource{ @@ -88,12 +84,9 @@ func (d *PyroscopeDatasource) CallResource(ctx context.Context, req *backend.Cal } func (d *PyroscopeDatasource) profileTypes(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - ctxLogger := logger.FromContext(ctx) - u, err := url.Parse(req.URL) if err != nil { - ctxLogger.Error("Failed to parse URL", "error", err, "function", logEntrypoint()) - return backend.DownstreamErrorf("URL could not be parsed: %w", err) + return backend.DownstreamErrorf("URL could not be parsed: %w. function: %s", err, logEntrypoint()) } query := u.Query() @@ -101,14 +94,12 @@ func (d *PyroscopeDatasource) profileTypes(ctx context.Context, req *backend.Cal if query.Has("start") && query.Has("end") { start, err = strconv.ParseInt(query.Get("start"), 10, 64) if err != nil { - ctxLogger.Error("Failed to parse start as int", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("failed to parse start as int: %w", err)) + return backend.DownstreamErrorf("failed to parse start as int: %w. function: %s", err, logEntrypoint()) } end, err = strconv.ParseInt(query.Get("end"), 10, 64) if err != nil { - ctxLogger.Error("Failed to parse end as int", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("failed to parse end as int: %w", err)) + return backend.DownstreamErrorf("failed to parse end as int: %w. function: %s", err, logEntrypoint()) } } else { // Make sure to pass a valid time range to the client as v2 will not work without it. @@ -118,29 +109,23 @@ func (d *PyroscopeDatasource) profileTypes(ctx context.Context, req *backend.Cal types, err := d.client.ProfileTypes(ctx, start, end) if err != nil { - ctxLogger.Error("Received error from client", "error", err, "function", logEntrypoint()) - return err + return backend.DownstreamErrorf("received error from client: %w. function: %s", err, logEntrypoint()) } bodyData, err := json.Marshal(types) if err != nil { - ctxLogger.Error("Failed to marshal response", "error", err, "function", logEntrypoint()) - return err + return backend.DownstreamErrorf("failed to marshal response: %w. function: %s", err, logEntrypoint()) } err = sender.Send(&backend.CallResourceResponse{Body: bodyData, Status: 200}) if err != nil { - ctxLogger.Error("Failed to send response", "error", err, "function", logEntrypoint()) - return err + return backend.DownstreamErrorf("failed to send response: %w. function: %s", err, logEntrypoint()) } return nil } func (d *PyroscopeDatasource) labelNames(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - ctxLogger := logger.FromContext(ctx) - u, err := url.Parse(req.URL) if err != nil { - ctxLogger.Error("Failed to parse URL", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("URL could not be parsed: %w", err)) + return backend.DownstreamErrorf("URL could not be parsed: %w. function: %s", err, logEntrypoint()) } query := u.Query() @@ -149,14 +134,12 @@ func (d *PyroscopeDatasource) labelNames(ctx context.Context, req *backend.CallR labelSelector := query.Get("query") matchers, err := parser.ParseMetricSelector(labelSelector) if err != nil { - ctxLogger.Error("Could not parse label selector", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("failed parsing label selector: %v", err)) + return backend.DownstreamErrorf("failed parsing label selector: %w. function: %s", err, logEntrypoint()) } labelNames, err := d.client.LabelNames(ctx, labelSelector, start, end) if err != nil { - ctxLogger.Error("Received error from client", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("error calling LabelNames: %v", err)) + return backend.DownstreamErrorf("error calling LabelNames: %w. function: %s", err, logEntrypoint()) } finalLabels := make([]string, 0) @@ -171,13 +154,11 @@ func (d *PyroscopeDatasource) labelNames(ctx context.Context, req *backend.CallR jsonResponse, err := json.Marshal(finalLabels) if err != nil { - ctxLogger.Error("Failed to marshal response", "error", err, "function", logEntrypoint()) - return err + return backend.DownstreamErrorf("failed to marshal response: %w. function: %s", err, logEntrypoint()) } err = sender.Send(&backend.CallResourceResponse{Body: jsonResponse, Status: 200}) if err != nil { - ctxLogger.Error("Failed to send response", "error", err, "function", logEntrypoint()) - return err + return backend.DownstreamErrorf("failed to send response: %w. function: %s", err, logEntrypoint()) } return nil } @@ -190,11 +171,9 @@ type LabelValuesPayload struct { } func (d *PyroscopeDatasource) labelValues(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - ctxLogger := logger.FromContext(ctx) u, err := url.Parse(req.URL) if err != nil { - ctxLogger.Error("Failed to parse URL", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("URL could not be parsed: %w", err)) + return backend.DownstreamErrorf("URL could not be parsed: %w. function: %s", err, logEntrypoint()) } query := u.Query() @@ -204,20 +183,17 @@ func (d *PyroscopeDatasource) labelValues(ctx context.Context, req *backend.Call res, err := d.client.LabelValues(ctx, label, query.Get("query"), start, end) if err != nil { - ctxLogger.Error("Received error from client", "error", err, "function", logEntrypoint()) - return backend.DownstreamError(fmt.Errorf("error calling LabelValues: %v", err)) + return backend.DownstreamErrorf("error calling LabelValues: %w. function: %s", err, logEntrypoint()) } data, err := json.Marshal(res) if err != nil { - ctxLogger.Error("Failed to marshal response", "error", err, "function", logEntrypoint()) - return backend.DownstreamErrorf("failed to marshall response: %w", err) + return backend.DownstreamErrorf("failed to marshall response: %w. function: %s", err, logEntrypoint()) } err = sender.Send(&backend.CallResourceResponse{Body: data, Status: 200}) if err != nil { - ctxLogger.Error("Failed to send response", "error", err, "function", logEntrypoint()) - return err + return backend.DownstreamErrorf("failed to send response: %w. function: %s", err, logEntrypoint()) } return nil @@ -227,13 +203,10 @@ func (d *PyroscopeDatasource) labelValues(ctx context.Context, req *backend.Call // for all known profile types, including their aggregation type (cumulative/instant), // units, descriptions, and grouping information. func (d *PyroscopeDatasource) profileMetadata(ctx context.Context, _ *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - ctxLogger := logger.FromContext(ctx) - registry := GetProfileMetadataRegistry() jsonData, err := json.Marshal(registry.profiles) if err != nil { - ctxLogger.Error("Failed to marshal profile metadata", "error", err, "function", logEntrypoint()) return sender.Send(&backend.CallResourceResponse{ Status: 500, Body: []byte(`{"error": "Failed to marshal profile metadata"}`), diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go index da5d9309130..aa6af21d6c3 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go @@ -80,7 +80,6 @@ func (c *PyroscopeClient) ProfileTypes(ctx context.Context, start int64, end int End: end, })) if err != nil { - logger.Error("Received error from client", "error", err, "function", logEntrypoint()) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return nil, backend.DownstreamError(fmt.Errorf("received error from client while getting profile types: %w", err)) @@ -115,10 +114,9 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l resp, err := c.connectClient.SelectSeries(ctx, req) if err != nil { - logger.Error("Received error from client", "error", err, "function", logEntrypoint()) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) - return nil, backend.DownstreamError(fmt.Errorf("received error from client while getting series: %w", err)) + return nil, backend.DownstreamErrorf("received error from client while getting series: %w", err) } series := make([]*Series, len(resp.Msg.Series)) @@ -171,7 +169,6 @@ func (c *PyroscopeClient) GetProfile(ctx context.Context, profileTypeID, labelSe resp, err := c.connectClient.SelectMergeStacktraces(ctx, req) if err != nil { - logger.Error("Received error from client", "error", err, "function", logEntrypoint()) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return nil, backend.DownstreamError(fmt.Errorf("received error from client while getting profile: %w", err)) @@ -207,7 +204,7 @@ func (c *PyroscopeClient) GetSpanProfile(ctx context.Context, profileTypeID, lab } if resp.Msg.Flamegraph == nil { - // Not an error, can happen when querying data oout of range. + // Not an error, can happen when querying data out of range. return nil, nil } @@ -254,7 +251,6 @@ func (c *PyroscopeClient) LabelNames(ctx context.Context, labelSelector string, End: end, })) if err != nil { - logger.Error("Received error from client", "error", err, "function", logEntrypoint()) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return nil, backend.DownstreamError(fmt.Errorf("error sending LabelNames request %v", err)) @@ -284,7 +280,6 @@ func (c *PyroscopeClient) LabelValues(ctx context.Context, label string, labelSe End: end, })) if err != nil { - logger.Error("Received error from client", "error", err, "function", logEntrypoint()) span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return nil, backend.DownstreamError(fmt.Errorf("received error from client while getting label values: %w", err)) diff --git a/pkg/tsdb/grafana-pyroscope-datasource/service.go b/pkg/tsdb/grafana-pyroscope-datasource/service.go index bad52d48984..04a4c5240fc 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/service.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/service.go @@ -87,10 +87,10 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) response, err := i.QueryData(ctx, req) if err != nil { - ctxLogger.Error("Received error from Pyroscope", "error", err, "function", logEntrypoint()) - } else { - ctxLogger.Debug("All queries processed", "function", logEntrypoint()) + return nil, backend.DownstreamErrorf("received error from Pyroscope while querying data: %s", err) } + + ctxLogger.Debug("All queries processed", "function", logEntrypoint()) return response, err } @@ -105,10 +105,10 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq err = i.CallResource(ctx, req, sender) if err != nil { - loggerWithContext.Error("Received error from Pyroscope", "error", err, "function", logEntrypoint()) - } else { - loggerWithContext.Debug("Health check succeeded", "function", logEntrypoint()) + return backend.DownstreamErrorf("received error from Pyroscope while calling resource: %w", err) } + + loggerWithContext.Debug("Health check succeeded", "function", logEntrypoint()) return err } @@ -123,10 +123,10 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque response, err := i.CheckHealth(ctx, req) if err != nil { - loggerWithContext.Error("Received error from Pyroscope", "error", err, "function", logEntrypoint()) - } else { - loggerWithContext.Debug("Health check succeeded", "function", logEntrypoint()) + return nil, backend.DownstreamErrorf("received error from Pyroscope while health checking: %w", err) } + + loggerWithContext.Debug("Health check succeeded", "function", logEntrypoint()) return response, err } @@ -141,10 +141,10 @@ func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStr response, err := i.SubscribeStream(ctx, req) if err != nil { - loggerWithContext.Error("Received error from Pyroscope", "error", err, "function", logEntrypoint()) - } else { - loggerWithContext.Debug("Stream subscribed", "function", logEntrypoint()) + return nil, backend.DownstreamErrorf("received error from Pyroscope while subscribing the stream: %w", err) } + + loggerWithContext.Debug("Stream subscribed", "function", logEntrypoint()) return response, err } @@ -159,10 +159,10 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest, err = i.RunStream(ctx, req, sender) if err != nil { - loggerWithContext.Error("Received error from Pyroscope", "error", err, "function", logEntrypoint()) - } else { - loggerWithContext.Debug("Stream run", "function", logEntrypoint()) + return backend.DownstreamErrorf("received error from Pyroscope while trying to run the stream: %w", err) } + + loggerWithContext.Debug("Stream run", "function", logEntrypoint()) return err } @@ -178,9 +178,9 @@ func (s *Service) PublishStream(ctx context.Context, req *backend.PublishStreamR response, err := i.PublishStream(ctx, req) if err != nil { - loggerWithContext.Error("Received error from Pyroscope", "error", err, "function", logEntrypoint()) - } else { - loggerWithContext.Debug("Stream published", "function", logEntrypoint()) + return nil, backend.DownstreamErrorf("received error from Pyroscope while publishing the stream: %w", err) } + + loggerWithContext.Debug("Stream published", "function", logEntrypoint()) return response, err } From c49caead2566dbf12f0cbd23d3725f382b8dfb14 Mon Sep 17 00:00:00 2001 From: Sam Jewell <2903904+samjewell@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:17:49 +0000 Subject: [PATCH 126/209] [--Dashboard-- data source] AdHoc filtering: Remove feature toggle (#113674) * Remove dashboardDsAdHocFiltering feature toggle The dashboardDsAdHocFiltering feature toggle has been enabled by default and is now in General Availability stage. This commit removes the feature toggle and makes the AdHoc filtering functionality for the dashboard datasource permanently available. Changes: - Remove feature toggle from registry.go - Regenerate feature toggle files - Remove conditional checks in frontend code - Update tests to reflect permanent enablement - Always show AdHoc Filters toggle in dashboard query editor - Always enable dashboard datasource in DataSourcePicker for variables * Remove unused imports * Fix Prettier formatting issues --- .../feature-toggles/index.md | 1 - .../src/types/featureToggles.gen.ts | 5 -- pkg/services/featuremgmt/registry.go | 8 --- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 -- pkg/services/featuremgmt/toggles_gen.json | 1 + .../components/AdHocVariableForm.tsx | 3 +- .../dashboard/DashboardQueryEditor.test.tsx | 37 +--------- .../dashboard/DashboardQueryEditor.tsx | 16 ++--- .../datasource/dashboard/datasource.test.ts | 71 +------------------ .../datasource/dashboard/datasource.ts | 24 +++---- 11 files changed, 20 insertions(+), 151 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index dea03f07484..2653da9f879 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -79,7 +79,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes | | `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | -| `dashboardDsAdHocFiltering` | Enables adhoc filtering support for the dashboard datasource | Yes | | `adhocFiltersInTooltips` | Enable adhoc filter buttons in visualization tooltips | Yes | | `tempoSearchBackendMigration` | Run search queries through the tempo backend | | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 89e1f8e9aed..d77c6821339 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1119,11 +1119,6 @@ export interface FeatureToggles { */ unifiedStorageSearchDualReaderEnabled?: boolean; /** - * Enables adhoc filtering support for the dashboard datasource - * @default true - */ - dashboardDsAdHocFiltering?: boolean; - /** * Supports __from and __to macros that always use the dashboard level time range */ dashboardLevelTimeMacros?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 6bc730b8d50..0b4f483e502 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1942,14 +1942,6 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, - { - Name: "dashboardDsAdHocFiltering", - Description: "Enables adhoc filtering support for the dashboard datasource", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaDataProSquad, - FrontendOnly: true, - Expression: "true", - }, { Name: "dashboardLevelTimeMacros", Description: "Supports __from and __to macros that always use the dashboard level time range", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8695ec64f57..3822dfc8c8d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -250,7 +250,6 @@ foldersAppPlatformAPI,experimental,@grafana/grafana-search-navigate-organise,fal otelLogsFormatting,experimental,@grafana/observability-logs,false,false,true alertingNotificationHistory,experimental,@grafana/alerting-squad,false,false,false unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false -dashboardDsAdHocFiltering,GA,@grafana/datapro,false,false,true dashboardLevelTimeMacros,experimental,@grafana/dashboards-squad,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false restrictedPluginApis,experimental,@grafana/plugins-platform-backend,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index fe407b9066f..a1a1be66f54 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1010,10 +1010,6 @@ const ( // Enable dual reader for unified storage search FlagUnifiedStorageSearchDualReaderEnabled = "unifiedStorageSearchDualReaderEnabled" - // FlagDashboardDsAdHocFiltering - // Enables adhoc filtering support for the dashboard datasource - FlagDashboardDsAdHocFiltering = "dashboardDsAdHocFiltering" - // FlagDashboardLevelTimeMacros // Supports __from and __to macros that always use the dashboard level time range FlagDashboardLevelTimeMacros = "dashboardLevelTimeMacros" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index dbf4d982629..05c83353ac6 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1061,6 +1061,7 @@ "name": "dashboardDsAdHocFiltering", "resourceVersion": "1756814786992", "creationTimestamp": "2025-07-23T08:12:25Z", + "deletionTimestamp": "2025-09-27T19:59:33Z", "annotations": { "grafana.app/updatedTimestamp": "2025-09-02 12:06:26.992384 +0000 UTC" } diff --git a/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx index 29183c79531..5434068f21e 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx @@ -4,7 +4,6 @@ import { DataSourceInstanceSettings, MetricFindValue, readCSV } from '@grafana/d import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { EditorField } from '@grafana/plugin-ui'; -import { config } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; import { Alert, CodeEditor, Field, Switch, Box } from '@grafana/ui'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; @@ -67,7 +66,7 @@ export function AdHocVariableForm({ onChange={onDataSourceChange} width={30} variables={true} - dashboard={config.featureToggles.dashboardDsAdHocFiltering} + dashboard={true} noDefault /> diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx index 2cbfa311d7b..876e996dbe8 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx @@ -1,8 +1,7 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getDefaultTimeRange, LoadingState } from '@grafana/data'; -import config from 'app/core/config'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; @@ -30,9 +29,6 @@ jest.mock('app/core/config', () => ({ }, }, }, - featureToggles: { - dashboardDsAdHocFiltering: false, // Default to false, can be overridden in tests - }, })); setupDataSources(mockDataSource({ isDefault: true })); @@ -179,11 +175,7 @@ describe('DashboardQueryEditor', () => { jest.spyOn(getDashboardSrv(), 'getCurrent').mockImplementation(() => mockDashboard); }); - it('shows the AdHoc Filters toggle when feature toggle is enabled', async () => { - await act(async () => { - config.featureToggles.dashboardDsAdHocFiltering = true; - }); - + it('shows the AdHoc Filters toggle', async () => { const query: DashboardQuery = { refId: 'A', panelId: 1, adHocFiltersEnabled: false }; await act(async () => { @@ -201,30 +193,5 @@ describe('DashboardQueryEditor', () => { const adhocFiltersToggle = await screen.findByText('AdHoc Filters'); expect(adhocFiltersToggle).toBeInTheDocument(); }); - - it('does not show the AdHoc Filters toggle when feature toggle is disabled', async () => { - await act(async () => { - config.featureToggles.dashboardDsAdHocFiltering = false; - }); - - const query: DashboardQuery = { refId: 'A', panelId: 1, adHocFiltersEnabled: false }; - - await act(async () => { - render( - - ); - }); - - // Wait for any async operations to complete - await waitFor(() => { - expect(screen.queryByText('AdHoc Filters')).not.toBeInTheDocument(); - }); - }); }); }); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index e137069219d..d8e9b952281 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -205,15 +205,13 @@ export function DashboardQueryEditor({ data, query, onChange, onRunQuery }: Prop )} - {config.featureToggles.dashboardDsAdHocFiltering && ( - - - - )} + + + {loadingResults ? ( diff --git a/public/app/plugins/datasource/dashboard/datasource.test.ts b/public/app/plugins/datasource/dashboard/datasource.test.ts index 55d7a12968d..2dcb17c63db 100644 --- a/public/app/plugins/datasource/dashboard/datasource.test.ts +++ b/public/app/plugins/datasource/dashboard/datasource.test.ts @@ -12,7 +12,7 @@ import { AdHocVariableFilter, } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; -import { setPluginImportUtils, config } from '@grafana/runtime'; +import { setPluginImportUtils } from '@grafana/runtime'; import { SafeSerializableSceneObject, SceneDataNode, @@ -178,16 +178,6 @@ describe('DashboardDatasource', () => { // Test AdHoc filtering via the Public API first, to ensure Integration describe('Integration (Public API)', () => { - const originalToggleValue = config.featureToggles.dashboardDsAdHocFiltering; - - beforeEach(() => { - config.featureToggles.dashboardDsAdHocFiltering = true; - }); - - afterEach(() => { - config.featureToggles.dashboardDsAdHocFiltering = originalToggleValue; - }); - it('should apply basic filtering end-to-end through public query method', async () => { const testFrame = createTestFrame([ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] }, @@ -224,46 +214,6 @@ describe('DashboardDatasource', () => { expect(result?.data[0].length).toBe(1); }); - it('should respect feature toggle and not filter when disabled', async () => { - // Temporarily disable the feature toggle for this test - config.featureToggles.dashboardDsAdHocFiltering = false; - - const testFrame = createTestFrame([ - { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] }, - { name: 'age', type: FieldType.number, values: [25, 30, 35] }, - ]); - - const scene = new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new VizPanel({ - key: getVizPanelKeyForPanelId(1), - $data: new SceneDataNode({ - data: { - series: [testFrame], - state: LoadingState.Done, - timeRange: getDefaultTimeRange(), - }, - }), - }), - }), - ], - }); - - const ds = new DashboardDatasource({} as DataSourceInstanceSettings); - const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }]; - - const observable = ds.query(createQueryRequest(filters, scene)); - - let result: DataQueryResponse | undefined; - observable.subscribe({ next: (data) => (result = data) }); - - // Should return unfiltered data since feature toggle is disabled - expect(result?.data[0].fields[0].values).toEqual(['John', 'Jane', 'Bob']); - expect(result?.data[0].fields[1].values).toEqual([25, 30, 35]); - expect(result?.data[0].length).toBe(3); - }); - it('should respect per-panel adHocFiltersEnabled setting and not filter when disabled', async () => { const testFrame = createTestFrame([ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] }, @@ -723,27 +673,8 @@ describe('DashboardDatasource', () => { }); describe('getDrilldownsApplicability', () => { - const originalToggleValue = config.featureToggles.dashboardDsAdHocFiltering; const ds = new DashboardDatasource({} as DataSourceInstanceSettings); - beforeEach(() => { - config.featureToggles.dashboardDsAdHocFiltering = true; - }); - - afterEach(() => { - config.featureToggles.dashboardDsAdHocFiltering = originalToggleValue; - }); - - it('should return empty array when feature toggle is disabled', async () => { - config.featureToggles.dashboardDsAdHocFiltering = false; - - const result = await ds.getDrilldownsApplicability({ - filters: [{ key: 'name', operator: '=', value: 'test' }], - }); - - expect(result).toEqual([]); - }); - it('should mark supported operators as applicable', async () => { const result = await ds.getDrilldownsApplicability({ filters: [ diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts index 9969c9afe7f..34a1781623f 100644 --- a/public/app/plugins/datasource/dashboard/datasource.ts +++ b/public/app/plugins/datasource/dashboard/datasource.ts @@ -20,7 +20,6 @@ import { DataSourceGetDrilldownsApplicabilityOptions, DrilldownsApplicability, } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { isSceneObject, SceneDataProvider, SceneDataTransformer, SceneObject } from '@grafana/scenes'; import { activateSceneObjectAndParentTree, @@ -140,11 +139,10 @@ export class DashboardDatasource extends DataSourceApi { ...field, config: { ...field.config, - // Enable AdHoc filtering for string and numeric fields only when feature toggle and per-panel setting are enabled - filterable: - config.featureToggles.dashboardDsAdHocFiltering && query.adHocFiltersEnabled - ? field.type === FieldType.string || field.type === FieldType.number - : field.config.filterable, + // Enable AdHoc filtering for string and numeric fields only when per-panel setting is enabled + filterable: query.adHocFiltersEnabled + ? field.type === FieldType.string || field.type === FieldType.number + : field.config.filterable, }, state: { ...field.state, @@ -153,7 +151,7 @@ export class DashboardDatasource extends DataSourceApi { }; }); - if (!config.featureToggles.dashboardDsAdHocFiltering || !query.adHocFiltersEnabled || filters.length === 0) { + if (!query.adHocFiltersEnabled || filters.length === 0) { return [...series, ...annotations]; } @@ -247,11 +245,9 @@ export class DashboardDatasource extends DataSourceApi { const field = frame.fields[fieldIndex]; - // Only support string and numeric fields when feature toggle is enabled - if (config.featureToggles.dashboardDsAdHocFiltering) { - if (field.type !== FieldType.string && field.type !== FieldType.number) { - return null; - } + // Only support string and numeric fields + if (field.type !== FieldType.string && field.type !== FieldType.number) { + return null; } // Map operator to matcher ID @@ -357,10 +353,6 @@ export class DashboardDatasource extends DataSourceApi { async getDrilldownsApplicability( options?: DataSourceGetDrilldownsApplicabilityOptions ): Promise { - if (!config.featureToggles.dashboardDsAdHocFiltering) { - return []; - } - // Check if any query has adhoc filters enabled const hasAdHocFiltersEnabled = options?.queries?.some((query) => query.adHocFiltersEnabled); From 142340e0ff035c01c66143c44b457928ba153195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Mon, 10 Nov 2025 20:37:02 +0100 Subject: [PATCH 127/209] refactor(folders): use set to detect circular references (#113665) --- .../folder/folderimpl/unifiedstore.go | 9 +- .../folder/folderimpl/unifiedstore_test.go | 135 ++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 8c30024eefb..4c76b219873 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -568,14 +568,13 @@ func buildFolderFullPaths(f *folder.Folder, relations map[string]string, folderM titles = append(titles, f.Title) uids = append(uids, f.UID) - i := 0 + seen := make(map[string]bool) currentUID := f.UID for currentUID != "" { - // This is just a circuit breaker to prevent infinite loops. We should never reach this limit. - if i > 1000 { - return fmt.Errorf("folder depth exceeds the maximum allowed depth, You might have a circular reference") + if seen[currentUID] { + return folder.ErrCircularReference.Errorf("circular reference detected for folder %s", currentUID) } - i++ + seen[currentUID] = true parentUID, exists := relations[currentUID] if !exists { break diff --git a/pkg/services/folder/folderimpl/unifiedstore_test.go b/pkg/services/folder/folderimpl/unifiedstore_test.go index 5ffc1d12947..b31db85227e 100644 --- a/pkg/services/folder/folderimpl/unifiedstore_test.go +++ b/pkg/services/folder/folderimpl/unifiedstore_test.go @@ -891,6 +891,141 @@ func TestBuildFolderFullPaths(t *testing.T) { } } +func TestBuildFolderFullPaths_CircularReference(t *testing.T) { + type args struct { + f *folder.Folder + relations map[string]string + folderMap map[string]*folder.Folder + } + tests := []struct { + name string + args args + expectedErr string + }{ + { + name: "should detect direct circular reference (A -> B -> A)", + args: args{ + f: &folder.Folder{ + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-b", + }, + relations: map[string]string{ + "folder-a": "folder-b", + "folder-b": "folder-a", // circular: B points back to A + }, + folderMap: map[string]*folder.Folder{ + "folder-a": { + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-b", + }, + "folder-b": { + Title: "FolderB", + UID: "folder-b", + ParentUID: "folder-a", + }, + }, + }, + expectedErr: "circular reference detected", + }, + { + name: "should detect self-reference (A -> A)", + args: args{ + f: &folder.Folder{ + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-a", // points to itself + }, + relations: map[string]string{ + "folder-a": "folder-a", + }, + folderMap: map[string]*folder.Folder{ + "folder-a": { + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-a", + }, + }, + }, + expectedErr: "circular reference detected", + }, + { + name: "should detect longer circular reference (A -> B -> C -> A)", + args: args{ + f: &folder.Folder{ + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-b", + }, + relations: map[string]string{ + "folder-a": "folder-b", + "folder-b": "folder-c", + "folder-c": "folder-a", // circular: C points back to A + }, + folderMap: map[string]*folder.Folder{ + "folder-a": { + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-b", + }, + "folder-b": { + Title: "FolderB", + UID: "folder-b", + ParentUID: "folder-c", + }, + "folder-c": { + Title: "FolderC", + UID: "folder-c", + ParentUID: "folder-a", + }, + }, + }, + expectedErr: "circular reference detected", + }, + { + name: "should detect circular reference starting from middle (B in A -> B -> C -> A)", + args: args{ + f: &folder.Folder{ + Title: "FolderB", + UID: "folder-b", + ParentUID: "folder-c", + }, + relations: map[string]string{ + "folder-a": "folder-b", + "folder-b": "folder-c", + "folder-c": "folder-a", + }, + folderMap: map[string]*folder.Folder{ + "folder-a": { + Title: "FolderA", + UID: "folder-a", + ParentUID: "folder-b", + }, + "folder-b": { + Title: "FolderB", + UID: "folder-b", + ParentUID: "folder-c", + }, + "folder-c": { + Title: "FolderC", + UID: "folder-c", + ParentUID: "folder-a", + }, + }, + }, + expectedErr: "circular reference detected", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := buildFolderFullPaths(tt.args.f, tt.args.relations, tt.args.folderMap) + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + }) + } +} + func TestList(t *testing.T) { type args struct { ctx context.Context From dd0a2d4cff212798e08156bebd88279ba80c9907 Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Mon, 10 Nov 2025 14:40:35 -0500 Subject: [PATCH 128/209] Alerting: Add validation to check updates on rule groups (#113669) This moves some of the validation logic for rule groups from the legacy storage layer to the validator. --- .../rules/pkg/app/alertrule/validator.go | 31 +++-- .../rules/pkg/app/recordingrule/validator.go | 30 ++--- .../rules/pkg/app/util/group_validation.go | 59 ++++++++++ .../pkg/app/util/group_validation_test.go | 109 ++++++++++++++++++ .../rules/alertrule/legacy_storage.go | 14 --- .../rules/recordingrule/legacy_storage.go | 9 -- 6 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 apps/alerting/rules/pkg/app/util/group_validation.go create mode 100644 apps/alerting/rules/pkg/app/util/group_validation_test.go diff --git a/apps/alerting/rules/pkg/app/alertrule/validator.go b/apps/alerting/rules/pkg/app/alertrule/validator.go index e86d17cab48..636c1b4a323 100644 --- a/apps/alerting/rules/pkg/app/alertrule/validator.go +++ b/apps/alerting/rules/pkg/app/alertrule/validator.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "slices" - "strconv" "time" "github.com/grafana/grafana-app-sdk/app" @@ -16,6 +15,19 @@ import ( prom_model "github.com/prometheus/common/model" ) +// validateGroupLabels now delegates to util.ValidateGroupLabels for shared logic. +func validateGroupLabels(r *model.AlertRule, oldObject resource.Object, action resource.AdmissionAction) error { + var oldLabels map[string]string + if oldObject != nil { + if oldRule, ok := oldObject.(*model.AlertRule); ok { + oldLabels = oldRule.Labels + } else { + return fmt.Errorf("old object is not of type *v0alpha1.AlertRule") + } + } + return util.ValidateGroupLabels(r.Labels, oldLabels, action) +} + func NewValidator(cfg config.RuntimeConfig) *simple.Validator { return &simple.Validator{ ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error { @@ -24,7 +36,6 @@ func NewValidator(cfg config.RuntimeConfig) *simple.Validator { if !ok { return fmt.Errorf("object is not of type *v0alpha1.AlertRule") } - // 1) Validate provenance status annotation sourceProv := r.GetProvenanceStatus() if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) { @@ -32,20 +43,8 @@ func NewValidator(cfg config.RuntimeConfig) *simple.Validator { } // 2) Validate group labels rules - group := r.Labels[model.GroupLabelKey] - groupIndexStr := r.Labels[model.GroupIndexLabelKey] - if req.Action == resource.AdmissionActionCreate { - if group != "" || groupIndexStr != "" { - return fmt.Errorf("cannot set group when creating alert rule") - } - } - if group != "" { // if group is set, group-index must be set and numeric - if groupIndexStr == "" { - return fmt.Errorf("%s must be set when %s is set", model.GroupIndexLabelKey, model.GroupLabelKey) - } - if _, err := strconv.Atoi(groupIndexStr); err != nil { - return fmt.Errorf("invalid %s: %w", model.GroupIndexLabelKey, err) - } + if err := validateGroupLabels(r, req.OldObject, req.Action); err != nil { + return err } // 3) Validate folder is set and exists diff --git a/apps/alerting/rules/pkg/app/recordingrule/validator.go b/apps/alerting/rules/pkg/app/recordingrule/validator.go index 8354b9d7501..ea9e8f207d6 100644 --- a/apps/alerting/rules/pkg/app/recordingrule/validator.go +++ b/apps/alerting/rules/pkg/app/recordingrule/validator.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "slices" - "strconv" "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/resource" @@ -15,6 +14,19 @@ import ( prom_model "github.com/prometheus/common/model" ) +// validateGroupLabels now delegates to util.ValidateGroupLabels for shared logic. +func validateGroupLabels(r *model.RecordingRule, oldObject resource.Object, action resource.AdmissionAction) error { + var oldLabels map[string]string + if oldObject != nil { + if oldRule, ok := oldObject.(*model.RecordingRule); ok { + oldLabels = oldRule.Labels + } else { + return fmt.Errorf("old object is not of type *v0alpha1.RecordingRule") + } + } + return util.ValidateGroupLabels(r.Labels, oldLabels, action) +} + func NewValidator(cfg config.RuntimeConfig) *simple.Validator { return &simple.Validator{ ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error { @@ -29,20 +41,8 @@ func NewValidator(cfg config.RuntimeConfig) *simple.Validator { return fmt.Errorf("invalid provenance status: %s", sourceProv) } - group := r.Labels[model.GroupLabelKey] - groupIndexStr := r.Labels[model.GroupIndexLabelKey] - if req.Action == resource.AdmissionActionCreate { - if group != "" || groupIndexStr != "" { - return fmt.Errorf("cannot set group when creating recording rule") - } - } - if group != "" { - if groupIndexStr == "" { - return fmt.Errorf("%s must be set when %s is set", model.GroupIndexLabelKey, model.GroupLabelKey) - } - if _, err := strconv.Atoi(groupIndexStr); err != nil { - return fmt.Errorf("invalid %s: %w", model.GroupIndexLabelKey, err) - } + if err := validateGroupLabels(r, req.OldObject, req.Action); err != nil { + return err } folderUID := "" diff --git a/apps/alerting/rules/pkg/app/util/group_validation.go b/apps/alerting/rules/pkg/app/util/group_validation.go new file mode 100644 index 00000000000..8624ef816e8 --- /dev/null +++ b/apps/alerting/rules/pkg/app/util/group_validation.go @@ -0,0 +1,59 @@ +package util + +import ( + "fmt" + "strconv" + + "github.com/grafana/grafana-app-sdk/resource" + model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" +) + +// ValidateGroupLabels enforces the cross-field rules for group-related labels. +// +// Rules enforced: +// - On create, group-related labels must not be set. +// - If one of group or group-index is set, the other must also be set. +// - group-index must be an integer. +// - On update, group/group-index can only be present if they were present on the old object. +// +// Pass current labels and, when available, the previous object's labels. The previous labels +// may be nil when there is no old object (e.g., create operations). +func ValidateGroupLabels(labels map[string]string, oldLabels map[string]string, action resource.AdmissionAction) error { + groupStr, groupExists := labels[model.GroupLabelKey] + groupIndexStr, groupIndexStrExists := labels[model.GroupIndexLabelKey] + + if groupExists || groupIndexStrExists { + if action == resource.AdmissionActionCreate { + return fmt.Errorf("cannot set group when creating a new rule") + } + if groupExists && !groupIndexStrExists { + return fmt.Errorf("%s must be set when %s is set", model.GroupIndexLabelKey, model.GroupLabelKey) + } + if groupIndexStrExists && !groupExists { + return fmt.Errorf("%s must be set when %s is set", model.GroupLabelKey, model.GroupIndexLabelKey) + } + // Disallow empty values when labels are present + if groupExists && groupStr == "" { + return fmt.Errorf("%s cannot be empty", model.GroupLabelKey) + } + if groupIndexStrExists && groupIndexStr == "" { + return fmt.Errorf("%s cannot be empty", model.GroupIndexLabelKey) + } + if _, err := strconv.Atoi(groupIndexStr); err != nil { + return fmt.Errorf("invalid %s: %w", model.GroupIndexLabelKey, err) + } + + // On updates, ensure that group and group-index are only set if the old object had them set + if oldLabels != nil { + _, oldGroupExists := oldLabels[model.GroupLabelKey] + _, oldGroupIndexExists := oldLabels[model.GroupIndexLabelKey] + if groupExists && !oldGroupExists { + return fmt.Errorf("cannot set group when updating un-grouped rule") + } + if groupIndexStrExists && !oldGroupIndexExists { + return fmt.Errorf("cannot set group-index when updating un-grouped rule") + } + } + } + return nil +} diff --git a/apps/alerting/rules/pkg/app/util/group_validation_test.go b/apps/alerting/rules/pkg/app/util/group_validation_test.go new file mode 100644 index 00000000000..c2b44980b93 --- /dev/null +++ b/apps/alerting/rules/pkg/app/util/group_validation_test.go @@ -0,0 +1,109 @@ +package util + +import ( + "testing" + + "github.com/grafana/grafana-app-sdk/resource" + model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" +) + +func TestValidateGroupLabels(t *testing.T) { + group := model.GroupLabelKey + groupIdx := model.GroupIndexLabelKey + + tests := []struct { + name string + labels map[string]string + oldLabels map[string]string + action resource.AdmissionAction + wantErr bool + }{ + { + name: "update empty group value", + labels: map[string]string{group: "", groupIdx: "1"}, + action: resource.AdmissionActionUpdate, + wantErr: true, + }, + { + name: "update empty group-index value", + labels: map[string]string{group: "g1", groupIdx: ""}, + action: resource.AdmissionActionUpdate, + wantErr: true, + }, + { + name: "create empty group value disallowed", + labels: map[string]string{group: ""}, + action: resource.AdmissionActionCreate, + wantErr: true, + }, + { + name: "create no labels allowed", + labels: nil, + action: resource.AdmissionActionCreate, + wantErr: false, + }, + { + name: "create with group disallowed", + labels: map[string]string{group: "g1"}, + action: resource.AdmissionActionCreate, + wantErr: true, + }, + { + name: "create with group-index disallowed", + labels: map[string]string{groupIdx: "1"}, + action: resource.AdmissionActionCreate, + wantErr: true, + }, + { + name: "update missing paired index", + labels: map[string]string{group: "g1"}, + action: resource.AdmissionActionUpdate, + wantErr: true, + }, + { + name: "update missing paired group", + labels: map[string]string{groupIdx: "1"}, + action: resource.AdmissionActionUpdate, + wantErr: true, + }, + { + name: "update invalid index format", + labels: map[string]string{group: "g1", groupIdx: "x"}, + oldLabels: map[string]string{group: "g1", groupIdx: "0"}, + action: resource.AdmissionActionUpdate, + wantErr: true, + }, + { + name: "update cannot add group when previously ungrouped", + labels: map[string]string{group: "g1", groupIdx: "0"}, + oldLabels: map[string]string{}, + action: resource.AdmissionActionUpdate, + wantErr: true, + }, + { + name: "update allowed when previously grouped", + labels: map[string]string{group: "g1", groupIdx: "2"}, + oldLabels: map[string]string{group: "g1", groupIdx: "1"}, + action: resource.AdmissionActionUpdate, + wantErr: false, + }, + { + name: "update no labels remains allowed", + labels: nil, + action: resource.AdmissionActionUpdate, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateGroupLabels(tc.labels, tc.oldLabels, tc.action) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/pkg/registry/apps/alerting/rules/alertrule/legacy_storage.go b/pkg/registry/apps/alerting/rules/alertrule/legacy_storage.go index 1e2de84b784..13a7d0b01f7 100644 --- a/pkg/registry/apps/alerting/rules/alertrule/legacy_storage.go +++ b/pkg/registry/apps/alerting/rules/alertrule/legacy_storage.go @@ -126,11 +126,6 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa if p.GenerateName != "" { return nil, fmt.Errorf("generate-name is not supported in legacy storage mode") } - // TODO: move this to the validation function - if p.Labels[model.GroupLabelKey] != "" || p.Labels[model.GroupIndexLabelKey] != "" { - return nil, k8serrors.NewBadRequest("cannot set group when creating alert rule") - } - model, provenance, err := convertToDomainModel(info.OrgID, p) if err != nil { return nil, err @@ -160,12 +155,6 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up return old, false, err } - current, ok := old.(*model.AlertRule) - if !ok { - // this shouldn't really be possible - return nil, false, k8serrors.NewBadRequest("expected valid alert rule object") - } - obj, err := objInfo.UpdatedObject(ctx, old) if err != nil { return old, false, err @@ -180,9 +169,6 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up if !ok { return nil, false, k8serrors.NewBadRequest("expected valid alert rule object") } - if current.Labels[model.GroupLabelKey] == "" && new.Labels[model.GroupLabelKey] != "" { - return nil, false, k8serrors.NewBadRequest("cannot set group label when updating un-grouped alert rule") - } model, provenance, err := convertToDomainModel(info.OrgID, new) if err != nil { diff --git a/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go b/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go index df1b57db523..cb69a51ce19 100644 --- a/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go +++ b/pkg/registry/apps/alerting/rules/recordingrule/legacy_storage.go @@ -162,11 +162,6 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up return nil, false, err } - current, ok := old.(*model.RecordingRule) - if !ok { - return nil, false, k8serrors.NewBadRequest("expected valid recording rule object") - } - obj, err := objInfo.UpdatedObject(ctx, old) if err != nil { return old, false, err @@ -185,10 +180,6 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up if new.Name != "" { new.UID = types.UID(new.Name) } - // TODO: move to validation function - if current.Labels[model.GroupLabelKey] == "" && new.Labels[model.GroupLabelKey] != "" { - return nil, false, k8serrors.NewBadRequest("cannot set group label when updating un-grouped recording rule") - } model, provenance, err := convertToDomainModel(info.OrgID, new) if err != nil { From b6bd31a4aaea5ace2694fbf29e8983c7fb0e57e4 Mon Sep 17 00:00:00 2001 From: Luminessa Starlight Date: Mon, 10 Nov 2025 14:41:39 -0500 Subject: [PATCH 129/209] Slider: Add support for decimal values (#113473) * UI: Fix Slider component to handle decimal inputs correctly * format code and run eslint fix * fix Slider to have: - "single" source of truth for state - state synchronization for controlled values - clamp values to step - disallow decimal values in input when min+step are integers - tests for the new functionality - design decision included in docs - behavior notes in docs * allow non-numeric characters all the time always parse decimal numbers, stripping non-numerics integer coercion is implicitly handled in clamping --------- Co-authored-by: Harshada Gawas --- .../src/components/Slider/Slider.mdx | 12 ++ .../src/components/Slider/Slider.test.tsx | 132 ++++++++++++++ .../src/components/Slider/Slider.tsx | 169 ++++++++++++------ 3 files changed, 260 insertions(+), 53 deletions(-) diff --git a/packages/grafana-ui/src/components/Slider/Slider.mdx b/packages/grafana-ui/src/components/Slider/Slider.mdx index 014c0eab00c..3edb8d43bfc 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.mdx +++ b/packages/grafana-ui/src/components/Slider/Slider.mdx @@ -9,4 +9,16 @@ The `Slider` component is an input element where users can manipulate one value `Slider` can be implemented in horizontal or vertical orientation. You can set the default starting value(s) for the slider with the `value` prop. +## Behavior + +The slider input itself will only allow values from `min` to `max` in steps of `step`. + +The text input behavior depends on whether the step value or min value are integers or decimals/fractional. When the min and step are integers, the text input will only allow numeric characters and leading `-`. When the min or step are decimal/fractional, the input will allow a leading `-`, leading `.`, and valid combinations of those, as well as `.` within the number. + +`onChange` will only be called with valid values, clamped to the min, max, and step. If `min - max` isn't evenly divisible by `step`, behavior may be unexpected near the maximum. + + +## Design decisions + +The slider state deals with two sources of truth: the value that's passed in, and the text input value. The text input value needs to be synchronized with external value changes, but it can also have its own temporarily invalid values. diff --git a/packages/grafana-ui/src/components/Slider/Slider.test.tsx b/packages/grafana-ui/src/components/Slider/Slider.test.tsx index 1fb4ab1c03a..4028ab1b750 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.test.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.test.tsx @@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event'; import { Slider } from './Slider'; import { SliderProps } from './types'; +import '@testing-library/jest-dom'; + const sliderProps: SliderProps = { min: 10, max: 20, @@ -17,6 +19,37 @@ describe('Slider', () => { user = userEvent.setup(); }); + it('respects min/max bounds after decimal input blur', async () => { + render(); + + const sliderInput = screen.getByRole('textbox'); + + // Above max + await user.clear(sliderInput); + await user.type(sliderInput, '15.2'); + await user.click(document.body); + expect(sliderInput).toHaveValue('10'); // max enforced + + // Below min + await user.clear(sliderInput); + await user.type(sliderInput, '-2.7'); + await user.click(document.body); + expect(sliderInput).toHaveValue('0'); // min enforced + }); + + it('updates slider value correctly when decimal input is typed', async () => { + render(); + + const slider = screen.getByRole('slider'); + const sliderInput = screen.getByRole('textbox'); + + await user.clear(sliderInput); + await user.type(sliderInput, '7.3'); + await user.click(document.body); + + expect(slider).toHaveAttribute('aria-valuenow', '7.4'); + }); + it('renders without error', () => { expect(() => render()).not.toThrow(); }); @@ -74,6 +107,66 @@ describe('Slider', () => { expect(sliderInput).toHaveValue('50'); }); + it('allows decimal numbers in input', async () => { + render(); + const sliderInput = screen.getByRole('textbox'); + const slider = screen.getByRole('slider'); + + await user.clear(sliderInput); + await user.type(sliderInput, '3.5'); + + expect(sliderInput).toHaveValue('3.5'); + + // numeric value clamped + expect(slider).toHaveAttribute('aria-valuenow', '4'); + }); + + it('number parsing ignores non-numeric characters typed in the text input', async () => { + render(); + const sliderInput = screen.getByRole('textbox'); + const slider = screen.getByRole('slider'); + + await user.clear(sliderInput); + + // the characters other than numbers and the first `-` and `.` are stripped as you type + await user.type(sliderInput, 'ab-cd1ef.gh.1'); + + expect(sliderInput).toHaveValue('ab-cd1ef.gh.1'); + expect(slider).toHaveAttribute('aria-valuenow', '-1.1'); + }); + + it('number parsing allows but ignores non-numeric characters typed in the text input when step and min are integers', async () => { + render(); + const sliderInput = screen.getByRole('textbox'); + const slider = screen.getByRole('slider'); + + await user.clear(sliderInput); + + // the characters other than numbers and the first `-` and `.` are stripped as you type + await user.type(sliderInput, 'ab-cd1ef1gh6ij.5kl'); + + expect(sliderInput).toHaveValue('ab-cd1ef1gh6ij.5kl'); + + // value is clamped from 116 to 115 + expect(slider).toHaveAttribute('aria-valuenow', '-115'); + }); + + // this is because it's a bit confusing when the value is zeroed out and you click the input that you + // can't type "-" immediately and it's an easy case to handle + it('allows you to type "-" when the value is "0"', async () => { + render(); + const sliderInput = screen.getByRole('textbox'); + const slider = screen.getByRole('slider'); + + await user.clear(sliderInput); + + // the zero is stripped + await user.type(sliderInput, '0-1'); + + expect(sliderInput).toHaveValue('-1'); + expect(slider).toHaveAttribute('aria-valuenow', '-1'); + }); + it('sets value to the closest available one after blur if input value is outside of range', async () => { render(); @@ -98,4 +191,43 @@ describe('Slider', () => { expect(sliderInput).toHaveValue('10'); expect(slider).toHaveAttribute('aria-valuenow', '10'); }); + + // the rest of the tests are uncontrolled already, don't need to separately test that + it('can be a controlled input', async () => { + const mockOnChange = jest.fn(); + const props: SliderProps = { + ...sliderProps, + onChange: mockOnChange, + min: -10, + max: 100, + }; + + const { rerender } = render(); + const slider = screen.getByRole('slider'); + const sliderInput = screen.getByRole('textbox'); + + await user.type(sliderInput, '-1'); + // click outside the input field to blur + await user.click(document.body); + + expect(slider).toHaveAttribute('aria-valuenow', '-1'); + expect(sliderInput).toHaveValue('-1'); + + // Called once while typing "-1" (initial is "0", then "-", which is NaN and doesn't call + // onChange) and once more on blur + expect(mockOnChange).toHaveBeenCalledTimes(2); + expect(mockOnChange).toHaveBeenCalledWith(-1); + + rerender(); + + rerender(); + + // onChange should not be called when slider is re-rendered with a new value + // this check ensure the state synchronization is working properly, since accidentally + // causing onChange calls is a easy failure mode if that code is modified + expect(mockOnChange).toHaveBeenCalledTimes(2); + + expect(slider).toHaveAttribute('aria-valuenow', '45'); + expect(sliderInput).toHaveValue('45'); + }); }); diff --git a/packages/grafana-ui/src/components/Slider/Slider.tsx b/packages/grafana-ui/src/components/Slider/Slider.tsx index d8d310fa92b..9458b500532 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.tsx @@ -1,7 +1,8 @@ import { cx } from '@emotion/css'; import { Global } from '@emotion/react'; import SliderComponent from 'rc-slider'; -import { useState, useCallback, ChangeEvent, FocusEvent } from 'react'; +import { useState, useCallback, ChangeEvent, FocusEvent, useEffect } from 'react'; +import { usePrevious } from 'react-use'; import { t } from '@grafana/i18n'; @@ -11,6 +12,63 @@ import { Input } from '../Input/Input'; import { getStyles } from './styles'; import { SliderProps } from './types'; +function stripAndParseNumber(raw: string): number { + const str = raw.replace(/^0+/, ''); + let decimal = false; + let numericBody = ''; + for (let i = 0; i < str.length; i += 1) { + const char = str.charAt(i); + + // take digits + if (/\d/.test(char)) { + numericBody += char; + } + // take the first period + if (char === '.' && !decimal) { + decimal = true; + numericBody += '.'; + } + // take only a leading negative sign + if (char === '-' && numericBody.length === 0) { + numericBody = '-'; + } + + // anything else is thrown away + } + const value = Number(numericBody); + return value; +} + +// gets rid of pesky things like 1.20000000000000002 and such, since this needs to be printed +// nicely for people. +function roundFloatingPointError(n: number) { + return parseFloat(n.toPrecision(12)); +} + +function clampToAllowedValue(min: number, max: number, step: number, n: number): number { + // default to min + if (Number.isNaN(n)) { + return min; + } + + // clamp to max and min + if (n > max) { + return max; + } + if (n < min) { + return min; + } + + // ensure the value is exactly one of the allowed steps + // find the closest step + const closestStep = roundFloatingPointError(Math.round((n - min) / step) * step + min); + + // clamp the closest found step to min/max + // this should never be needed unless the step isn't divisible by max-min, but it's a + // quick and easy check to include. + return Math.min(max, Math.max(min, closestStep)); +} + /** * @public * @@ -23,7 +81,7 @@ export const Slider = ({ onAfterChange, orientation = 'horizontal', reverse, - step, + step = 1, value, ariaLabelForHandle, marks, @@ -34,78 +92,83 @@ export const Slider = ({ const isHorizontal = orientation === 'horizontal'; const styles = useStyles2(getStyles, isHorizontal, Boolean(marks)); const SliderWithTooltip = SliderComponent; - const [sliderValue, setSliderValue] = useState(value ?? min); + + const [inputValue, setInputValue] = useState((value ?? min).toString()); + const numericValue = clampToAllowedValue(min, max, step, stripAndParseNumber(inputValue)); + + // State synchronization. This is a hack since we have to maintain our own source of truth for the text input + const previousValue = usePrevious(value); + const externalValueChanged = value !== previousValue && value !== numericValue; + useEffect(() => { + if (externalValueChanged && value !== undefined) { + // This only causes a re-render if the value is actually different, which should + // only happen if the value is externally changed + setInputValue(String(value)); + } + }, [externalValueChanged, value]); + const dragHandleAriaLabel = ariaLabelForHandle ?? t('grafana-ui.slider.drag-handle-aria-label', 'Use arrow keys to change the value'); const onSliderChange = useCallback( (v: number | number[]) => { - const value = typeof v === 'number' ? v : v[0]; - - setSliderValue(value); - onChange?.(value); + const num = typeof v === 'number' ? v : v[0]; + setInputValue(num.toString()); + onChange?.(num); }, - [setSliderValue, onChange] - ); - - const onSliderInputChange = useCallback( - (e: ChangeEvent) => { - let v = +e.target.value; - - if (Number.isNaN(v)) { - v = 0; - } - - setSliderValue(v); - - if (onChange) { - onChange(v); - } - - if (onAfterChange) { - onAfterChange(v); - } - }, - [onChange, onAfterChange] - ); - - // Check for min/max on input blur so user is able to enter - // custom values that might seem above/below min/max on first keystroke - const onSliderInputBlur = useCallback( - (e: FocusEvent) => { - const v = +e.target.value; - - if (v > max) { - setSliderValue(max); - } else if (v < min) { - setSliderValue(min); - } - }, - [max, min] + [onChange] ); const handleChangeComplete = useCallback( (v: number | number[]) => { - const value = typeof v === 'number' ? v : v[0]; - onAfterChange?.(value); + const num = typeof v === 'number' ? v : v[0]; + onAfterChange?.(num); }, [onAfterChange] ); + const onTextInputChange = useCallback( + (e: ChangeEvent) => { + const raw = e.target.value; + + // Update the raw input string to show what user typed, except the special case of `0-`, which + // should result in just `-` as a user convenience. + setInputValue(raw === '0-' ? '-' : raw); + + // Parse and validate the number + const parsed = stripAndParseNumber(raw); + if (onChange && !Number.isNaN(parsed)) { + // Clamp the output value + onChange(clampToAllowedValue(min, max, step, parsed)); + } + }, + [onChange, min, max, step] + ); + + const onTextInputBlur = useCallback( + (e: FocusEvent) => { + const parsed = clampToAllowedValue(min, max, step, stripAndParseNumber(e.target.value)); + + // Update both numeric and string values with the clamped result + setInputValue(parsed.toString()); + onChange?.(parsed); + onAfterChange?.(parsed); + }, + [min, max, step, onChange, onAfterChange] + ); + const sliderInputClassNames = !isHorizontal ? [styles.sliderInputVertical] : []; const sliderInputFieldClassNames = !isHorizontal ? [styles.sliderInputFieldVertical] : []; return (
- {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */}
Date: Mon, 10 Nov 2025 14:31:43 -0600 Subject: [PATCH 130/209] Feature toggles: remove unused recordedQueriesMulti feature toggle (#113616) remove recordedQueriesMulti feature toggle Co-authored-by: Tania B. <10127682+undef1nd@users.noreply.github.com> --- .../configure-grafana/feature-toggles/index.md | 1 - packages/grafana-data/src/types/featureToggles.gen.ts | 5 ----- pkg/services/featuremgmt/registry.go | 8 -------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- 6 files changed, 2 insertions(+), 20 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 2653da9f879..0a6e16e697d 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -34,7 +34,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes | | `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes | | `unifiedRequestLog` | Writes error logs to the request logger | Yes | -| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes | | `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes | | `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | Yes | | `transformationsRedesign` | Enables the transformations redesign | Yes | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d77c6821339..eadc831370f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -165,11 +165,6 @@ export interface FeatureToggles { */ extraThemes?: boolean; /** - * Enables writing multiple items from a single query within Recorded Queries - * @default true - */ - recordedQueriesMulti?: boolean; - /** * A table visualisation for logs in Explore * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 0b4f483e502..b37a2b4066b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -264,14 +264,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, }, - { - Name: "recordedQueriesMulti", - Description: "Enables writing multiple items from a single query within Recorded Queries", - Stage: FeatureStageGeneralAvailability, - Expression: "true", - Owner: grafanaObservabilityMetricsSquad, - AllowSelfServe: false, - }, { Name: "logsExploreTableVisualisation", Description: "A table visualisation for logs in Explore", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 3822dfc8c8d..b5087ecf2a0 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -33,7 +33,6 @@ refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true -recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true awsDatasourcesTempCredentials,GA,@grafana/aws-datasources,false,false,false transformationsRedesign,GA,@grafana/observability-metrics,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a1a1be66f54..574b0480bac 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -143,10 +143,6 @@ const ( // Enables extra themes FlagExtraThemes = "extraThemes" - // FlagRecordedQueriesMulti - // Enables writing multiple items from a single query within Recorded Queries - FlagRecordedQueriesMulti = "recordedQueriesMulti" - // FlagLogsExploreTableVisualisation // A table visualisation for logs in Explore FlagLogsExploreTableVisualisation = "logsExploreTableVisualisation" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 05c83353ac6..73958dd44f0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3430,7 +3430,8 @@ "metadata": { "name": "recordedQueriesMulti", "resourceVersion": "1753448760331", - "creationTimestamp": "2023-06-14T12:34:22Z" + "creationTimestamp": "2023-06-14T12:34:22Z", + "deletionTimestamp": "2025-11-07T17:31:39Z" }, "spec": { "description": "Enables writing multiple items from a single query within Recorded Queries", From fcc243886c9be28c80e180e66f1e40ac6ba144aa Mon Sep 17 00:00:00 2001 From: Adam Simpson Date: Mon, 10 Nov 2025 15:50:29 -0500 Subject: [PATCH 131/209] chore: change ownership of disableSSEDataplane and sseGroupByDatasource (#113640) --- pkg/services/featuremgmt/registry.go | 4 ++-- pkg/services/featuremgmt/toggles_gen.csv | 4 ++-- pkg/services/featuremgmt/toggles_gen.json | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b37a2b4066b..fdfc6cfd7c6 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -219,7 +219,7 @@ var ( Name: "disableSSEDataplane", Description: "Disables dataplane specific processing in server side expressions.", Stage: FeatureStageExperimental, - Owner: grafanaObservabilityMetricsSquad, + Owner: grafanaDatasourcesCoreServicesSquad, }, { Name: "unifiedRequestLog", @@ -381,7 +381,7 @@ var ( Name: "sseGroupByDatasource", Description: "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", Stage: FeatureStageExperimental, - Owner: grafanaObservabilityMetricsSquad, + Owner: grafanaDatasourcesCoreServicesSquad, }, { Name: "lokiRunQueriesInParallel", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b5087ecf2a0..dff17f73355 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -26,7 +26,7 @@ influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,fa influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false dataplaneFrontendFallback,GA,@grafana/observability-metrics,false,false,true -disableSSEDataplane,experimental,@grafana/observability-metrics,false,false,false +disableSSEDataplane,experimental,@grafana/grafana-datasources-core-services,false,false,false unifiedRequestLog,GA,@grafana/grafana-backend-group,false,false,false renderAuthJWT,preview,@grafana/grafana-operator-experience-squad,false,false,false refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false @@ -48,7 +48,7 @@ configurableSchedulerTick,experimental,@grafana/alerting-squad,false,true,false dashgpt,GA,@grafana/dashboards-squad,false,false,true aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,true reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,false -sseGroupByDatasource,experimental,@grafana/observability-metrics,false,false,false +sseGroupByDatasource,experimental,@grafana/grafana-datasources-core-services,false,false,false lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 73958dd44f0..67af5cd1dc1 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1319,13 +1319,16 @@ { "metadata": { "name": "disableSSEDataplane", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-04-12T16:24:34Z" + "resourceVersion": "1762552416963", + "creationTimestamp": "2023-04-12T16:24:34Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-07 21:53:36.963146843 +0000 UTC" + } }, "spec": { "description": "Disables dataplane specific processing in server side expressions.", "stage": "experimental", - "codeowner": "@grafana/observability-metrics" + "codeowner": "@grafana/grafana-datasources-core-services" } }, { @@ -3755,13 +3758,16 @@ { "metadata": { "name": "sseGroupByDatasource", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-09-07T20:02:07Z" + "resourceVersion": "1762552416963", + "creationTimestamp": "2023-09-07T20:02:07Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-07 21:53:36.963146843 +0000 UTC" + } }, "spec": { "description": "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", "stage": "experimental", - "codeowner": "@grafana/observability-metrics" + "codeowner": "@grafana/grafana-datasources-core-services" } }, { From 2d4e432239eab152928d3c4f2ff22189d5a39702 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 10 Nov 2025 16:45:37 -0700 Subject: [PATCH 132/209] Dashboard provisioning: Add support for v2 schema (#113620) --- .../administration/provisioning/index.md | 52 +++++ pkg/services/dashboards/models.go | 68 ++++++ pkg/services/dashboards/models_test.go | 47 +++++ .../dashboards/service/dashboard_service.go | 23 ++- pkg/services/provisioning/dashboards/types.go | 6 + .../provisioning/dashboards/types_test.go | 193 ++++++++++++++++++ 6 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 pkg/services/provisioning/dashboards/types_test.go diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 51fd1f4c604..fadb936e5a0 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -375,6 +375,58 @@ providers: ``` When Grafana starts, it updates or creates all dashboards found in the configured path. +These files can define the dashboard using the dashboard JSON: + +```json +{ + "dashboard": { + "id": null, + "uid": "example-dashboard", + "title": "Production Overview", + "tags": ["production", "monitoring"], + "timezone": "browser", + "schemaVersion": 16, + "version": 0, + "refresh": "30s" + }, + "folderUid": "monitoring-folder", + "overwrite": true +} +``` + +Or using a Kubernetes format, for example `kubernetes-dashboard.json`: + +```json +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "dashboard-uid" + }, + "spec": { + "title": "Dashboard title", + "panels": [ + { + "gridPos": { + "h": 13, + "w": 24, + "x": 0, + "y": 0 + }, + "options": { + "content": "

Example panel

", + "mode": "html" + }, + "transparent": true, + "type": "text" + } + ] + } +} +``` + +You _must_ use the Kubernetes resource format to provision dashboards v2 / dynamic dashboards. + It later polls that path every `updateIntervalSeconds` for updates to the dashboard files and updates its database. {{< admonition type="note" >}} diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 270e449ac07..3c661ee6b9c 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -4,6 +4,9 @@ import ( "fmt" "time" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" @@ -95,6 +98,11 @@ func (d *Dashboard) GetTags() []string { } func NewDashboardFromJson(data *simplejson.Json) *Dashboard { + // if apiVersion is set in the json - use it as an indicator that it is in the k8s format + if apiVersion, err := data.Get("apiVersion").String(); err == nil && apiVersion != "" { + return parseK8sDashboard(data) + } + dash := &Dashboard{} dash.Data = data dash.Title = dash.Data.Get("title").MustString() @@ -127,6 +135,66 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard { return dash } +// parses json in the k8s format +// i.e: +// +// { +// "apiVersion": "dashboard.grafana.app/v1", +// "kind": "Dashboard", +// "metadata": {...}, +// "spec": {...} +// } +func parseK8sDashboard(data *simplejson.Json) *Dashboard { + dash := &Dashboard{} + + dataMap, ok := data.Interface().(map[string]interface{}) + if !ok { + return dash + } + + item := &unstructured.Unstructured{Object: dataMap} + obj, err := utils.MetaAccessor(item) + if err != nil { + return dash + } + dash.APIVersion = item.GetAPIVersion() + info, err := types.ParseNamespace(obj.GetNamespace()) + if err == nil && info.OrgID > 0 { + dash.OrgID = info.OrgID + } + dash.UID = obj.GetName() + + spec, ok := item.Object["spec"].(map[string]any) + if !ok { + return dash + } + dash.Data = simplejson.NewFromAny(spec) + + dash.Title = obj.FindTitle("") + dash.UpdateSlug() + + dash.FolderUID = obj.GetFolder() + dash.Data.Set("uid", dash.UID) + + generation := obj.GetGeneration() + if generation > 0 { + dash.Data.Set("version", generation) + dash.Updated = time.Now() + } else { + dash.Data.Set("version", 0) + dash.Created = time.Now() + dash.Updated = time.Now() + } + + dash.Data.Set("id", obj.GetDeprecatedInternalID()) // nolint:staticcheck + + if gnetId, err := dash.Data.Get("gnetId").Float64(); err == nil { + dash.GnetID = int64(gnetId) + } + + return dash +} + // GetDashboardModel turns the command into the saveable model func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash := NewDashboardFromJson(cmd.Dashboard) diff --git a/pkg/services/dashboards/models_test.go b/pkg/services/dashboards/models_test.go index 06aeb1eba60..0a3035cbecc 100644 --- a/pkg/services/dashboards/models_test.go +++ b/pkg/services/dashboards/models_test.go @@ -86,3 +86,50 @@ func TestSlugifyTitle(t *testing.T) { }) } } + +func TestParseK8sDashboard(t *testing.T) { + t.Run("should parse valid K8s dashboard with all fields", func(t *testing.T) { + data := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "org-123", + "generation": int64(5), + "labels": map[string]interface{}{ + "grafana.app/deprecatedInternalID": "456", + }, + "annotations": map[string]interface{}{ + "grafana.app/folder": "test-folder-uid", + }, + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + "gnetId": float64(12345), + }, + }) + + dash := parseK8sDashboard(data) + assert.Equal(t, "dashboard.grafana.app/v2alpha1", dash.APIVersion) + assert.Equal(t, int64(123), dash.OrgID) + assert.Equal(t, "test-dashboard-uid", dash.UID) + assert.Equal(t, "Test Dashboard", dash.Title) + assert.Equal(t, "test-dashboard", dash.Slug) + assert.Equal(t, "test-folder-uid", dash.FolderUID) + assert.Equal(t, int64(12345), dash.GnetID) + assert.Equal(t, "test-dashboard-uid", dash.Data.Get("uid").MustString()) + assert.Equal(t, int64(5), dash.Data.Get("version").MustInt64()) + assert.Equal(t, int64(456), dash.Data.Get("id").MustInt64()) + assert.False(t, dash.Updated.IsZero()) + assert.True(t, dash.Created.IsZero()) + }) + + t.Run("should handle invalid input (not a map)", func(t *testing.T) { + data := simplejson.NewFromAny("invalid string") + dash := parseK8sDashboard(data) + assert.Empty(t, dash.UID) + assert.Empty(t, dash.Title) // this will fail later in the provisioning chain because its empty + assert.Empty(t, dash.APIVersion) + assert.Nil(t, dash.Data) + }) +} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 7e4dd89dc7d..9e6baf6b8aa 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -745,15 +745,16 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() cmd := &dashboards.SaveDashboardCommand{ - Dashboard: dash.Data, - Message: dto.Message, - OrgID: dto.OrgID, - Overwrite: dto.Overwrite, - UserID: userID, - FolderID: dash.FolderID, // nolint:staticcheck - FolderUID: dash.FolderUID, - IsFolder: dash.IsFolder, - PluginID: dash.PluginID, + Dashboard: dash.Data, + Message: dto.Message, + OrgID: dto.OrgID, + Overwrite: dto.Overwrite, + UserID: userID, + FolderID: dash.FolderID, // nolint:staticcheck + FolderUID: dash.FolderUID, + IsFolder: dash.IsFolder, + PluginID: dash.PluginID, + APIVersion: dash.APIVersion, } if !dto.UpdatedAt.IsZero() { @@ -2288,6 +2289,10 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names meta.SetMessage(cmd.Message) } + if cmd.APIVersion != "" { + finalObj.SetAPIVersion(cmd.APIVersion) + } + return finalObj, nil } diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 717326305ae..db67f0dccd9 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -69,11 +69,17 @@ func createDashboardJSON(data *simplejson.Json, lastModified time.Time, cfg *con dash.Dashboard = dashboards.NewDashboardFromJson(data) dash.UpdatedAt = lastModified dash.Overwrite = true + if dash.Dashboard.OrgID > 0 && dash.Dashboard.OrgID != cfg.OrgID { + return nil, fmt.Errorf("dashboard orgID (%d) does not match provisioning provider orgID (%d)", dash.Dashboard.OrgID, cfg.OrgID) + } dash.OrgID = cfg.OrgID dash.Dashboard.OrgID = cfg.OrgID metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc() // nolint:staticcheck dash.Dashboard.FolderID = folderID + if dash.Dashboard.FolderUID != "" && folderUID != dash.Dashboard.FolderUID { + return nil, fmt.Errorf("dashboard folderUID (%q) does not match provisioning provider folderUID (%q)", dash.Dashboard.FolderUID, folderUID) + } dash.Dashboard.FolderUID = folderUID if dash.Dashboard.Title == "" { diff --git a/pkg/services/provisioning/dashboards/types_test.go b/pkg/services/provisioning/dashboards/types_test.go new file mode 100644 index 00000000000..49382d3561a --- /dev/null +++ b/pkg/services/provisioning/dashboards/types_test.go @@ -0,0 +1,193 @@ +package dashboards + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDashboardJSON(t *testing.T) { + lastModified := time.Now() + folderID := int64(123) + folderUID := "folder-uid-123" + + t.Run("orgID check", func(t *testing.T) { + t.Run("matching is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Test Dashboard", result.Dashboard.Title) + assert.Equal(t, int64(1), result.OrgID) + assert.Equal(t, int64(1), result.Dashboard.OrgID) + assert.Equal(t, folderID, result.Dashboard.FolderID) // nolint:staticcheck + assert.Equal(t, folderUID, result.Dashboard.FolderUID) + assert.True(t, result.Overwrite) + assert.Equal(t, lastModified, result.UpdatedAt) + }) + + t.Run("not set is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, int64(1), result.OrgID) + assert.Equal(t, int64(1), result.Dashboard.OrgID) + }) + + t.Run("not matching is an error", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "org-123", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dashboard orgID") + }) + }) + + t.Run("folderUID check", func(t *testing.T) { + t.Run("matching is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + "annotations": map[string]interface{}{ + "grafana.app/folder": folderUID, + }, + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, folderUID, result.Dashboard.FolderUID) + }) + + t.Run("not matching is an error", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + "annotations": map[string]interface{}{ + "grafana.app/folder": "different-folder-uid", + }, + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dashboard folderUID") + }) + + t.Run("not set is OK", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v2alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "test-dashboard-uid", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "title": "Test Dashboard", + }, + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, folderUID, result.Dashboard.FolderUID) + }) + }) + + t.Run("empty title is an error", func(t *testing.T) { + cfg := &config{ + OrgID: 1, + } + + dashboardJSON := simplejson.NewFromAny(map[string]any{ + "title": "", + }) + + result, err := createDashboardJSON(dashboardJSON, lastModified, cfg, folderID, folderUID) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, dashboards.ErrDashboardTitleEmpty, err) + }) +} From 34e85113d2664c198e0fd62a80978c0b80c45863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Tue, 11 Nov 2025 09:25:22 +0100 Subject: [PATCH 133/209] datasources: apiserver: removed unnecessary code (#113384) * datasources: apiserver: removed unnecessary code * workspace fixes --- apps/advisor/go.sum | 15 ------ pkg/registry/apis/datasource/sub_query.go | 3 +- .../apis/datasource/sub_query_test.go | 47 ------------------- 3 files changed, 1 insertion(+), 64 deletions(-) diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 3ec40f156ef..8d52f655960 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -131,7 +131,6 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4S2OByM= -github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90= github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= @@ -211,8 +210,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= -github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= @@ -269,14 +266,6 @@ github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk= github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= -github.com/blugelabs/bluge v0.2.2 h1:gat8CqE6P6tOgeX30XGLOVNTC26cpM2RWVcreXWtYcM= -github.com/blugelabs/bluge v0.2.2/go.mod h1:am1LU9jS8dZgWkRzkGLQN3757EgMs3upWrU2fdN9foE= -github.com/blugelabs/bluge_segment_api v0.2.0 h1:cCX1Y2y8v0LZ7+EEJ6gH7dW6TtVTW4RhG0vp3R+N2Lo= -github.com/blugelabs/bluge_segment_api v0.2.0/go.mod h1:95XA+ZXfRj/IXADm7gZ+iTcWOJPg5jQTY1EReIzl3LA= -github.com/blugelabs/ice v1.0.0 h1:um7wf9e6jbkTVCrOyQq3tKK43fBMOvLUYxbj3Qtc4eo= -github.com/blugelabs/ice v1.0.0/go.mod h1:gNfFPk5zM+yxJROhthxhVQYjpBO9amuxWXJQ2Lo+IbQ= -github.com/blugelabs/ice/v2 v2.0.1 h1:mzHbntLjk2v7eDRgoXCgzOsPKN1Tenu9Svo6l9cTLS4= -github.com/blugelabs/ice/v2 v2.0.1/go.mod h1:QxAWSPNwZwsIqS25c3lbIPFQrVvT1sphf5x5DfMLH5M= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= @@ -290,8 +279,6 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= -github.com/caio/go-tdigest v3.1.0+incompatible h1:uoVMJ3Q5lXmVLCCqaMGHLBWnbGoN6Lpu7OAUPR60cds= -github.com/caio/go-tdigest v3.1.0+incompatible/go.mod h1:sHQM/ubZStBUmF1WbB8FAm8q9GjDajLC5T7ydxE3JHI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -357,8 +344,6 @@ github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9j github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= diff --git a/pkg/registry/apis/datasource/sub_query.go b/pkg/registry/apis/datasource/sub_query.go index ce0c8cfd78b..a1bdddb6a30 100644 --- a/pkg/registry/apis/datasource/sub_query.go +++ b/pkg/registry/apis/datasource/sub_query.go @@ -13,7 +13,6 @@ import ( data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/errutil" query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" - query_headers "github.com/grafana/grafana/pkg/registry/apis/query" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/web" @@ -91,7 +90,7 @@ func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Ob rsp, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{ Queries: queries, PluginContext: pluginCtx, - Headers: query_headers.ExtractKnownHeaders(req.Header), + Headers: map[string]string{}, }) // all errors get converted into k8 errors when sent in responder.Error and lose important context like downstream info diff --git a/pkg/registry/apis/datasource/sub_query_test.go b/pkg/registry/apis/datasource/sub_query_test.go index a3871ae954f..eeb1d9c87b4 100644 --- a/pkg/registry/apis/datasource/sub_query_test.go +++ b/pkg/registry/apis/datasource/sub_query_test.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -16,53 +14,8 @@ import ( "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/ngalert/models" ) -func TestSubQueryConnect(t *testing.T) { - sqr := subQueryREST{ - builder: &DataSourceAPIBuilder{ - client: mockClient{ - lastCalledWithHeaders: &map[string]string{}, - }, - datasources: mockDatasources{}, - contextProvider: mockContextProvider{}, - }, - } - - mr := mockResponder{} - handler, err := sqr.Connect(context.Background(), "dsname", nil, mr) - require.NoError(t, err) - - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/some-path", nil) - req.Header.Set(models.FromAlertHeaderName, "true") - req.Header.Set(models.CacheSkipHeaderName, "true") - req.Header.Set("X-Rule-Name", "name-1") - req.Header.Set("X-Rule-Uid", "abc") - req.Header.Set("X-Rule-Folder", "folder-1") - req.Header.Set("X-Rule-Source", "grafana-ruler") - req.Header.Set("X-Rule-Type", "type-1") - req.Header.Set("X-Rule-Version", "version-1") - req.Header.Set("X-Grafana-Org-Id", "1") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("some-unexpected-header", "some-value") - handler.ServeHTTP(rr, req) - - // test that headers are forwarded and cased appropriately - require.Equal(t, map[string]string{ - models.FromAlertHeaderName: "true", - models.CacheSkipHeaderName: "true", - "X-Rule-Name": "name-1", - "X-Rule-Uid": "abc", - "X-Rule-Folder": "folder-1", - "X-Rule-Source": "grafana-ruler", - "X-Rule-Type": "type-1", - "X-Rule-Version": "version-1", - "X-Grafana-Org-Id": "1", - }, *sqr.builder.client.(mockClient).lastCalledWithHeaders) -} - func TestSubQueryConnectWhenDatasourceNotFound(t *testing.T) { sqr := subQueryREST{ builder: &DataSourceAPIBuilder{ From 62eef87208568580d58fb2a4c79a86cd1f945b05 Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:40:39 +0100 Subject: [PATCH 134/209] Dashboard Library: Integrate community dashboards on Suggested Dashboards Flow (#112808) * Extend interpolate endpoint to support community dashboard json interpolation Added unit tests * Implement Frontend Side - Show tabs - Fetch Community dashboads - Use DashboardCard component - Search grafana dashboard in the community tab - Make Tabs and pagination sticky - Adjust titles to be scoped by datasource name/type - Add skeleton loading for community tabs and pagination - Add dashboard details tooltip - Bring old datasource-provisioned box back and rely on new feature toggle for community dashboards - update i18n - update swagger --------- Co-authored-by: Juan Cabanas Co-authored-by: nmarrs --- pkg/services/dashboardimport/api/api.go | 8 +- pkg/services/dashboardimport/api/api_test.go | 228 ++++++++++- .../dashboardimport/service/service.go | 5 +- .../dashboardimport/service/service_test.go | 153 ++++++++ public/api-merged.json | 2 +- .../pages/DashboardScenePageStateManager.ts | 81 +++- .../DashboardEmpty/DashboardEmpty.test.tsx | 56 ++- .../DashboardEmpty/DashboardEmpty.tsx | 170 ++++---- .../BasicProvisionedDashboardsEmptyPage.tsx | 174 ++++++++ .../CommunityDashboardMappingForm.tsx | 187 +++++++++ .../CommunityDashboardSection.tsx | 277 +++++++++++++ .../DashboardLibrary/DashboardCard.tsx | 263 +++++++++++++ .../DashboardLibrarySection.tsx | 255 ++++++------ .../DashboardLibrary/SuggestedDashboards.tsx | 370 ++++++++++++++++++ .../SuggestedDashboardsModal.tsx | 196 ++++++++++ .../api/dashboardLibraryApi.ts | 94 +++++ .../dashgrid/DashboardLibrary/interactions.ts | 13 +- .../dashgrid/DashboardLibrary/types.ts | 48 +++ .../utils/autoMapDatasources.ts | 152 +++++++ .../utils/communityDashboardHelpers.ts | 177 +++++++++ .../utils/provisionedDashboardHelpers.ts | 26 ++ public/app/routes/routes.tsx | 2 +- public/locales/en-US/grafana.json | 55 +++ public/openapi3.json | 2 +- 24 files changed, 2739 insertions(+), 255 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index 27697602383..b0dabcaf881 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -47,7 +47,7 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR routing.Wrap(api.ImportDashboard), ) //nolint:staticcheck // not yet migrated to OpenFeature - if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) { + if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) || api.features.IsEnabledGlobally(featuremgmt.FlagSuggestedDashboards) { route.Post( "/interpolate", authorize(accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)), @@ -59,7 +59,7 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR // swagger:route POST /dashboards/interpolate dashboards interpolateDashboard // -// Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change. +// Interpolate dashboard. This is an experimental endpoint under dashboardLibrary or suggestedDashboards feature flags and is subject to change. // // Responses: // 200: interpolateDashboardResponse @@ -73,8 +73,8 @@ func (api *ImportDashboardAPI) InterpolateDashboard(c *contextmodel.ReqContext) return response.Error(http.StatusBadRequest, "bad request data", err) } - if req.PluginId == "" { - return response.Error(http.StatusUnprocessableEntity, "pluginId must be set", nil) + if req.PluginId == "" && req.Dashboard == nil { + return response.Error(http.StatusUnprocessableEntity, "pluginId or dashboard must be set", nil) } resp, err := api.dashboardImportService.InterpolateDashboard(c.Req.Context(), &req) diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 17e0e5d1685..461e370352e 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -189,7 +189,7 @@ func TestInterpolateDashboardFeatureFlag(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.StatusCode) }) - t.Run("Feature flag enabled - interpolate endpoint should work", func(t *testing.T) { + t.Run("dashboardLibrary feature flag enabled - interpolate endpoint should work", func(t *testing.T) { interpolateDashboardServiceCalled := false service := &serviceMock{ interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { @@ -223,6 +223,232 @@ func TestInterpolateDashboardFeatureFlag(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) require.True(t, interpolateDashboardServiceCalled) }) + + t.Run("suggestedDashboards feature flag enabled - interpolate endpoint should work", func(t *testing.T) { + interpolateDashboardServiceCalled := false + service := &serviceMock{ + interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { + interpolateDashboardServiceCalled = true + return simplejson.New(), nil + }, + } + // Create features with suggestedDashboards enabled + features := featuremgmt.WithFeatures(featuremgmt.FlagSuggestedDashboards) + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + cmd := &dashboardimport.ImportDashboardRequest{ + PluginId: "test-plugin", + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + UserID: 1, + Permissions: map[int64]map[string][]string{ + 1: {dashboards.ActionDashboardsCreate: {}}, + }, + }) + resp, err := s.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, interpolateDashboardServiceCalled) + }) +} + +func TestInterpolateDashboardAPI(t *testing.T) { + features := featuremgmt.WithFeatures(featuremgmt.FlagDashboardLibrary) + + t.Run("Backward compatibility - plugin-based flow still works", func(t *testing.T) { + var capturedReq *dashboardimport.ImportDashboardRequest + interpolateDashboardServiceCalled := false + service := &serviceMock{ + interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { + interpolateDashboardServiceCalled = true + capturedReq = req + result := simplejson.New() + result.Set("title", "Test Dashboard") + return result, nil + }, + } + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + cmd := &dashboardimport.ImportDashboardRequest{ + PluginId: "test-plugin", + Path: "dashboards/test.json", + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + UserID: 1, + Permissions: map[int64]map[string][]string{ + 1: {dashboards.ActionDashboardsCreate: {}}, + }, + }) + resp, err := s.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, interpolateDashboardServiceCalled) + require.NotNil(t, capturedReq) + require.Equal(t, "test-plugin", capturedReq.PluginId) + require.Equal(t, "dashboards/test.json", capturedReq.Path) + }) + + t.Run("New flow with dashboard JSON - should call service with dashboard", func(t *testing.T) { + var capturedReq *dashboardimport.ImportDashboardRequest + interpolateDashboardServiceCalled := false + service := &serviceMock{ + interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { + interpolateDashboardServiceCalled = true + capturedReq = req + result := simplejson.New() + result.Set("title", "Community Dashboard") + result.Set("panels", []interface{}{}) + return result, nil + }, + } + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + // Create a test dashboard with datasource that needs interpolation + testDashboard := simplejson.New() + testDashboard.Set("title", "Test Community Dashboard") + testDashboard.Set("panels", []interface{}{ + map[string]interface{}{ + "datasource": "${DS_PROMETHEUS}", + }, + }) + + cmd := &dashboardimport.ImportDashboardRequest{ + Dashboard: testDashboard, + Inputs: []dashboardimport.ImportDashboardInput{ + {Name: "DS_PROMETHEUS", Type: "datasource", PluginId: "prometheus", Value: "my-prometheus"}, + }, + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + UserID: 1, + Permissions: map[int64]map[string][]string{ + 1: {dashboards.ActionDashboardsCreate: {}}, + }, + }) + resp, err := s.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, interpolateDashboardServiceCalled) + require.NotNil(t, capturedReq) + require.NotNil(t, capturedReq.Dashboard) + require.Len(t, capturedReq.Inputs, 1) + require.Equal(t, "DS_PROMETHEUS", capturedReq.Inputs[0].Name) + }) + + t.Run("Validation - both pluginId and dashboard missing should return error", func(t *testing.T) { + service := &serviceMock{} + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + cmd := &dashboardimport.ImportDashboardRequest{ + PluginId: "", + Dashboard: nil, + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + UserID: 1, + Permissions: map[int64]map[string][]string{ + 1: {dashboards.ActionDashboardsCreate: {}}, + }, + }) + resp, err := s.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + }) + + t.Run("Response should not include internal fields", func(t *testing.T) { + service := &serviceMock{ + interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { + result := simplejson.New() + result.Set("title", "Test Dashboard") + result.Set("__elements", map[string]interface{}{"test": "value"}) + result.Set("__inputs", []interface{}{}) + result.Set("__requires", []interface{}{}) + result.Set("panels", []interface{}{}) + return result, nil + }, + } + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + cmd := &dashboardimport.ImportDashboardRequest{ + PluginId: "test-plugin", + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &user.SignedInUser{ + UserID: 1, + Permissions: map[int64]map[string][]string{ + 1: {dashboards.ActionDashboardsCreate: {}}, + }, + }) + resp, err := s.SendJSON(req) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Parse response body and verify internal fields are removed + var responseBody map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&responseBody) + require.NoError(t, err) + require.Equal(t, "Test Dashboard", responseBody["title"]) + require.NotContains(t, responseBody, "__elements") + require.NotContains(t, responseBody, "__inputs") + require.NotContains(t, responseBody, "__requires") + }) + + t.Run("Not signed in should return 401", func(t *testing.T) { + service := &serviceMock{} + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + cmd := &dashboardimport.ImportDashboardRequest{ + Dashboard: simplejson.New(), + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes)) + resp, err := s.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) } type serviceMock struct { diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index 8e78bdcac3d..ca006d0b98b 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -60,8 +61,10 @@ func (s *ImportDashboardService) InterpolateDashboard(ctx context.Context, req * } else { draftDashboard = resp.Dashboard } - } else { + } else if req.Dashboard != nil { draftDashboard = dashboards.NewDashboardFromJson(req.Dashboard) + } else { + return nil, fmt.Errorf("either PluginId or Dashboard must be provided") } evaluator := utils.NewDashTemplateEvaluator(draftDashboard.Data, req.Inputs) diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index 16ee483fc1b..76c02f424bc 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -160,6 +160,159 @@ func TestImportDashboardService(t *testing.T) { }) } +func TestInterpolateDashboardService(t *testing.T) { + t.Run("InterpolateDashboard with plugin ID should load from plugin", func(t *testing.T) { + pluginDashboardService := &pluginDashboardServiceMock{ + loadPluginDashboardFunc: loadTestDashboard, + } + + s := &ImportDashboardService{ + pluginDashboardService: pluginDashboardService, + features: featuremgmt.WithFeatures(), + } + + req := &dashboardimport.ImportDashboardRequest{ + PluginId: "prometheus", + Path: "dashboard.json", + Inputs: []dashboardimport.ImportDashboardInput{ + {Name: "*", Type: "datasource", Value: "prom"}, + }, + } + + result, err := s.InterpolateDashboard(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify datasource was interpolated + panel := result.Get("panels").GetIndex(0) + require.Equal(t, "prom", panel.Get("datasource").MustString()) + }) + + t.Run("InterpolateDashboard with dashboard JSON should apply interpolation", func(t *testing.T) { + s := &ImportDashboardService{ + features: featuremgmt.WithFeatures(), + } + + // Create test dashboard with template variables + testDashboard := simplejson.New() + testDashboard.Set("title", "Test Community Dashboard") + testDashboard.Set("uid", "test-uid") + + // Add __inputs section (required by template evaluator) + inputs := []interface{}{ + map[string]interface{}{ + "name": "DS_PROMETHEUS", + "type": "datasource", + "pluginId": "prometheus", + }, + map[string]interface{}{ + "name": "DS_LOKI", + "type": "datasource", + "pluginId": "loki", + }, + } + testDashboard.Set("__inputs", inputs) + + panels := []interface{}{ + map[string]interface{}{ + "id": 1, + "datasource": map[string]interface{}{ + "uid": "${DS_PROMETHEUS}", + }, + }, + map[string]interface{}{ + "id": 2, + "datasource": map[string]interface{}{ + "uid": "${DS_LOKI}", + }, + }, + } + testDashboard.Set("panels", panels) + + req := &dashboardimport.ImportDashboardRequest{ + Dashboard: testDashboard, + Inputs: []dashboardimport.ImportDashboardInput{ + {Name: "DS_PROMETHEUS", Type: "datasource", PluginId: "prometheus", Value: "my-prometheus"}, + {Name: "DS_LOKI", Type: "datasource", PluginId: "loki", Value: "my-loki"}, + }, + } + + result, err := s.InterpolateDashboard(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify datasources were interpolated correctly + panel1 := result.Get("panels").GetIndex(0) + require.Equal(t, "my-prometheus", panel1.Get("datasource").Get("uid").MustString()) + + panel2 := result.Get("panels").GetIndex(1) + require.Equal(t, "my-loki", panel2.Get("datasource").Get("uid").MustString()) + }) + + t.Run("InterpolateDashboard with dashboard JSON and wildcard datasource", func(t *testing.T) { + s := &ImportDashboardService{ + features: featuremgmt.WithFeatures(), + } + + // Create test dashboard with simple datasource reference + testDashboard := simplejson.New() + testDashboard.Set("title", "Test Dashboard") + + // Add __inputs section for wildcard matching + inputs := []interface{}{ + map[string]interface{}{ + "name": "DS_TEST", + "type": "datasource", + "pluginId": "testdata", + }, + } + testDashboard.Set("__inputs", inputs) + + panels := []interface{}{ + map[string]interface{}{ + "id": 1, + "datasource": "${DS_TEST}", + }, + } + testDashboard.Set("panels", panels) + + req := &dashboardimport.ImportDashboardRequest{ + Dashboard: testDashboard, + Inputs: []dashboardimport.ImportDashboardInput{ + {Name: "*", Type: "datasource", Value: "default-datasource"}, + }, + } + + result, err := s.InterpolateDashboard(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + + // With wildcard, it should replace any datasource template + panel := result.Get("panels").GetIndex(0) + datasource := panel.Get("datasource").MustString() + // The wildcard matcher should have replaced the template + require.NotEqual(t, "${DS_TEST}", datasource) + }) + + t.Run("InterpolateDashboard without plugin ID or dashboard should fail", func(t *testing.T) { + s := &ImportDashboardService{ + features: featuremgmt.WithFeatures(), + } + + req := &dashboardimport.ImportDashboardRequest{ + PluginId: "", + Dashboard: nil, + Inputs: []dashboardimport.ImportDashboardInput{}, + } + + // This should fail with validation error + result, err := s.InterpolateDashboard(context.Background(), req) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "either PluginId or Dashboard must be provided") + }) +} + func loadTestDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) { // It's safe to ignore gosec warning G304 since this is a test and arguments comes from test configuration. // nolint:gosec diff --git a/public/api-merged.json b/public/api-merged.json index 87a8bcb9d35..a168fed65c0 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -3684,7 +3684,7 @@ "tags": [ "dashboards" ], - "summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.", + "summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary or suggestedDashboards feature flags and is subject to change.", "operationId": "interpolateDashboard", "responses": { "200": { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index e17f7e36cff..595a51d80e9 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -5,6 +5,7 @@ import { sceneGraph } from '@grafana/scenes'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { GetRepositoryFilesWithPathApiResponse, provisioningAPIv0alpha1 } from 'app/api/clients/provisioning/v0alpha1'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; +import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { @@ -474,13 +475,40 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag throw new Error('Snapshot not found'); } - private async loadTemplateDashboard(): Promise { + private buildDashboardDTOFromInterpolated(interpolatedDashboard: DashboardDataDTO): DashboardDTO { + return { + dashboard: { + ...interpolatedDashboard, + uid: '', + version: 0, + id: null, + }, + meta: { + canSave: contextSrv.hasEditPermissionInFolders, + canEdit: contextSrv.hasEditPermissionInFolders, + canStar: false, + canShare: false, + canDelete: false, + isNew: true, + folderUid: '', + }, + }; + } + + private async loadSuggestedDashboard(): Promise { // Extract template parameters from URL const searchParams = new URLSearchParams(window.location.search); const datasource = searchParams.get('datasource'); + const gnetId = searchParams.get('gnetId'); const pluginId = searchParams.get('pluginId'); const path = searchParams.get('path'); + // Check if this is a community dashboard (has gnetId) or plugin dashboard + if (gnetId) { + return this.loadCommunityTemplateDashboard(gnetId); + } + + // Original plugin dashboard flow if (!datasource || !pluginId || !path) { throw new Error('Missing required parameters for template dashboard'); } @@ -505,24 +533,41 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag }; const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data); + return this.buildDashboardDTOFromInterpolated(interpolatedDashboard); + } - return { - dashboard: { - ...interpolatedDashboard, - uid: '', - version: 0, - id: null, - }, - meta: { - canSave: true, - canEdit: true, - canStar: false, - canShare: false, - canDelete: false, - isNew: true, - folderUid: '', - }, + private async loadCommunityTemplateDashboard(gnetId: string): Promise { + // Extract mappings from URL params + const location = locationService.getLocation(); + const searchParams = new URLSearchParams(location.search); + const mappingsJson = searchParams.get('mappings'); + + if (!mappingsJson) { + throw new Error('Missing mappings parameter for community dashboard'); + } + + let mappings; + try { + mappings = JSON.parse(mappingsJson); + } catch (err) { + throw new Error('Invalid mappings parameter: ' + err); + } + + // Fetch the community dashboard from grafana.com + const gnetDashboard = await getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`); + + // The dashboard JSON is in the 'json' property + const dashboardJson = gnetDashboard.json; + + // Call interpolate endpoint with the dashboard JSON and mappings + const data = { + dashboard: dashboardJson, + overwrite: true, + inputs: mappings, }; + + const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data); + return this.buildDashboardDTOFromInterpolated(interpolatedDashboard); } public async fetchDashboard({ @@ -559,7 +604,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag rsp = await buildNewDashboardSaveModel(urlFolderUid); break; case DashboardRoutes.Template: - rsp = await this.loadTemplateDashboard(); + rsp = await this.loadSuggestedDashboard(); break; case DashboardRoutes.Provisioning: return this.loadProvisioningDashboard(slug || '', uid); diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx index 61cc488109c..58d69d5ac92 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx @@ -24,6 +24,13 @@ jest.mock('@grafana/runtime', () => ({ })), }, reportInteraction: jest.fn(), + getDataSourceSrv: jest.fn(() => ({ + getInstanceSettings: jest.fn((uid: string) => ({ + uid, + name: 'Test Datasource', + type: 'prometheus', + })), + })), })); jest.mock('app/features/dashboard/utils/dashboard', () => ({ @@ -40,10 +47,16 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => })), })); -jest.mock('../DashboardLibrary/DashboardLibrarySection', () => ({ - DashboardLibrarySection: () =>
Dashboard Library Section
, +jest.mock('../DashboardLibrary/api/dashboardLibraryApi', () => ({ + fetchProvisionedDashboards: jest.fn(() => Promise.resolve([])), + fetchCommunityDashboards: jest.fn(() => Promise.resolve({ page: 1, pages: 1, dashboards: [] })), + fetchCommunityDashboard: jest.fn(() => Promise.resolve({ json: {} })), })); +const mockFetchProvisionedDashboards = jest.mocked( + require('../DashboardLibrary/api/dashboardLibraryApi').fetchProvisionedDashboards +); + const mockUseGetResourceRepositoryView = jest.mocked( require('app/features/provisioning/hooks/useGetResourceRepositoryView').useGetResourceRepositoryView ); @@ -162,7 +175,7 @@ it('renders with buttons disabled when repository is read-only', () => { expect(screen.getByRole('button', { name: 'Add library panel' })).toBeDisabled(); }); -describe('DashboardLibrarySection feature toggle', () => { +describe('ProvisionedDashboardsEmptyPage feature toggle', () => { beforeEach(() => { jest.clearAllMocks(); mockUseGetResourceRepositoryView.mockReturnValue({ @@ -172,31 +185,41 @@ describe('DashboardLibrarySection feature toggle', () => { }); }); - it('renders DashboardLibrarySection when feature toggle is enabled and dashboardLibraryDatasourceUid param exists', () => { + it('renders ProvisionedDashboardsEmptyPage when feature toggle is enabled and dashboardLibraryDatasourceUid param exists', async () => { config.featureToggles.dashboardLibrary = true; mockSearchParams.set('dashboardLibraryDatasourceUid', 'test-uid'); + // Mock provisioned dashboards to return at least one dashboard so component renders + mockFetchProvisionedDashboards.mockResolvedValueOnce([ + { + uid: 'test-dashboard-1', + title: 'Test Dashboard', + pluginId: 'prometheus', + path: '/test/path', + }, + ]); + setup(); - expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument(); + expect(await screen.findByTestId('provisioned-dashboards-empty-page')).toBeInTheDocument(); }); - it('does not render DashboardLibrarySection when feature toggle is disabled', () => { + it('does not render ProvisionedDashboardsEmptyPage when feature toggle is disabled', () => { config.featureToggles.dashboardLibrary = false; mockSearchParams.delete('dashboardLibraryDatasourceUid'); setup(); - expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('provisioned-dashboards-empty-page')).not.toBeInTheDocument(); }); - it('does not render DashboardLibrarySection when feature toggle is enabled but no dashboardLibraryDatasourceUid param', () => { + it('does not render ProvisionedDashboardsEmptyPage when feature toggle is enabled but no dashboardLibraryDatasourceUid param', () => { config.featureToggles.dashboardLibrary = true; mockSearchParams.delete('dashboardLibraryDatasourceUid'); setup(); - expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('provisioned-dashboards-empty-page')).not.toBeInTheDocument(); }); }); @@ -233,15 +256,28 @@ describe('wrapperMaxWidth CSS class', () => { expect(wrapperElement).toHaveStyle('max-width: 890px'); }); - it('does not apply wrapperMaxWidth class when dashboardLibrary feature is enabled and dashboardLibraryDatasourceUid param exists', () => { + it('does not apply wrapperMaxWidth class when dashboardLibrary feature is enabled and dashboardLibraryDatasourceUid param exists', async () => { config.featureToggles.dashboardLibrary = true; mockSearchParams.set('dashboardLibraryDatasourceUid', 'test-uid'); + // Mock provisioned dashboards to return at least one dashboard so component renders + mockFetchProvisionedDashboards.mockResolvedValueOnce([ + { + uid: 'test-dashboard-1', + title: 'Test Dashboard', + pluginId: 'prometheus', + path: '/test/path', + }, + ]); + const { container } = render( ); + // Wait for ProvisionedDashboardsEmptyPage to render and complete async operations + await screen.findByTestId('provisioned-dashboards-empty-page'); + const wrapperElement = container.querySelector('[class*="dashboard-empty-wrapper"]'); expect(wrapperElement).toBeInTheDocument(); expect(wrapperElement).not.toHaveStyle('max-width: 890px'); diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx index 8e473124434..bef4318e339 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx @@ -10,7 +10,8 @@ import { Button, useStyles2, Text, Box, Stack, TextLink } from '@grafana/ui'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; -import { DashboardLibrarySection } from '../DashboardLibrary/DashboardLibrarySection'; +import { BasicProvisionedDashboardsEmptyPage } from '../DashboardLibrary/BasicProvisionedDashboardsEmptyPage'; +import { SuggestedDashboards } from '../DashboardLibrary/SuggestedDashboards'; import { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint'; import { @@ -28,100 +29,115 @@ interface InternalProps { const InternalDashboardEmpty = ({ onAddVisualization, onAddLibraryPanel, onImportDashboard }: InternalProps) => { const styles = useStyles2(getStyles); - const [searchParams] = useSearchParams(); const dashboardLibraryDatasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); return ( - -
- - - - - - Start your new dashboard by adding a visualization - - - - - - Select a data source and then query and visualize your data with charts, stats and tables or create - lists, markdowns and other widgets. + <> + +
+ + + + + + Start your new dashboard by adding a visualization - - - - - {config.featureToggles.dashboardLibrary && dashboardLibraryDatasourceUid && } - - - - - Import panel - - + - - Add visualizations that are shared with other dashboards. + + Select a data source and then query and visualize your data with charts, stats and tables or + create lists, markdowns and other widgets. - - - - Import a dashboard - - - - - Import dashboards from files or{' '} - - grafana.com - - . - + + {/* Suggested Dashboards Section */} + {config.featureToggles.suggestedDashboards && + config.featureToggles.dashboardLibrary && + dashboardLibraryDatasourceUid && } + + {/* Basic Provisioned Dashboards Section that don't include community dashboards */} + {config.featureToggles.dashboardLibrary && + !config.featureToggles.suggestedDashboards && + dashboardLibraryDatasourceUid && ( + + )} + + + + + + Import panel - - - - + + + + Add visualizations that are shared with other dashboards. + + + + + + + + + + Import a dashboard + + + + + Import dashboards from files or{' '} + + grafana.com + + . + + + + + + + - -
-
+
+
+ ); }; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx new file mode 100644 index 00000000000..de2145c2750 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx @@ -0,0 +1,174 @@ +import { css } from '@emotion/css'; +import { useState } from 'react'; +import { useAsync } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { getDataSourceSrv, locationService } from '@grafana/runtime'; +import { Button, useStyles2, Text, Box, Stack, Grid } from '@grafana/ui'; +import { PluginDashboard } from 'app/types/plugins'; + +import { DASHBOARD_LIBRARY_ROUTES } from '../types'; + +import { fetchProvisionedDashboards } from './api/dashboardLibraryApi'; +import { DashboardLibraryInteractions } from './interactions'; +import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; + +interface Props { + datasourceUid?: string; +} + +export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) => { + const [showAll, setShowAll] = useState(false); + + const { value: templateDashboards } = useAsync(async (): Promise => { + if (!datasourceUid) { + return []; + } + + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return []; + } + + const dashboards = await fetchProvisionedDashboards(ds.type); + return dashboards; + }, [datasourceUid]); + + const hasMoreThanThree = templateDashboards && templateDashboards.length > 3; + const dashboardsToShow = showAll ? templateDashboards : templateDashboards?.slice(0, 3); + + const styles = useStyles2(getStyles); + + const onImportDashboardClick = async (dashboard: PluginDashboard) => { + DashboardLibraryInteractions.itemClicked({ + contentKind: 'datasource_dashboard', + datasourceTypes: [dashboard.pluginId], + libraryItemId: dashboard.uid, + libraryItemTitle: dashboard.title, + sourceEntryPoint: 'datasource_page', + eventLocation: 'empty_dashboard', + }); + + const params = new URLSearchParams({ + datasource: datasourceUid || '', + title: dashboard.title || 'Template', + pluginId: dashboard.pluginId, + path: dashboard.path, + // tracking event purpose values + sourceEntryPoint: 'datasource_page', + libraryItemId: dashboard.uid, + creationOrigin: 'dashboard_library_datasource_dashboard', + }); + + const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`; + locationService.push(templateUrl); + }; + + if (!templateDashboards?.length) { + return null; + } + + return ( + + + + + Start with a pre-made dashboard from your data source + + + + = 2 ? 2 : 1, + lg: (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1, + }} + > + {dashboardsToShow?.map((dashboard, index) => { + // Use global index for consistent image assignment across pages + const imageUrl = getProvisionedDashboardImageUrl(index); + + return ( + + ); + }) || []} + + + {hasMoreThanThree && ( + + )} + + + ); +}; + +const TemplateDashboardBox = ({ + dashboard, + onImportClick, + index, + imageUrl, +}: { + dashboard: PluginDashboard; + onImportClick: (d: PluginDashboard) => void; + index: number; + imageUrl: string; +}) => { + const styles = useStyles2(getStyles); + return ( +
+ {dashboard.title} +
+ + {dashboard.title} + +
+ +
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + templateDashboardBox: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + alignItems: 'center', + }), + templateDashboardTitle: css({ + flex: 1, + }), + templateDashboardImage: css({ + borderRadius: theme.shape.radius.default, + borderColor: theme.colors.text.primary, + borderWidth: 1, + borderStyle: 'solid', + objectFit: 'cover', + }), + showMoreButton: css({ + marginTop: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx new file mode 100644 index 00000000000..27b0a352ec1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Stack, Text, Button, Alert, Field, Input, Box } from '@grafana/ui'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; + +import { InputMapping, mapConstantInputs, mapUserSelectedDatasources } from './utils/autoMapDatasources'; + +interface Props { + unmappedInputs: DataSourceInput[]; + constantInputs: DashboardInput[]; + existingMappings: InputMapping[]; + onBack: () => void; + onPreview: (allMappings: InputMapping[]) => void; +} + +interface UserSelectedDatasourceMappings { + name: string; + pluginId: string; + datasource: DataSourceInstanceSettings | undefined; +} + +export const CommunityDashboardMappingForm = ({ + unmappedInputs, + constantInputs, + existingMappings, + onBack, + onPreview, +}: Props) => { + const [userSelectedDsMappings, setUserSelectedDsMappings] = useState>( + () => { + // Initialize with existing unmapped inputs + return unmappedInputs.reduce>((acc, input) => { + const unmappedInput = { + name: input.name, + pluginId: input.pluginId, + datasource: undefined, + }; + acc[input.name] = unmappedInput; + return acc; + }, {}); + } + ); + + const [constantValues, setConstantValues] = useState>(() => { + // Initialize with default values from constantInputs + return constantInputs.reduce>((acc, input) => { + acc[input.name] = input.value; + return acc; + }, {}); + }); + + const onDatasourceSelect = (inputName: string, datasource: DataSourceInstanceSettings) => { + setUserSelectedDsMappings((prev) => ({ + ...prev, + [inputName]: { + ...prev[inputName], + datasource, + }, + })); + }; + + const onConstantChange = (inputName: string, value: string) => { + setConstantValues((prev) => ({ + ...prev, + [inputName]: value, + })); + }; + + const onPreviewClick = () => { + // Combine all mappings: + // 1. Existing auto-mapped datasources + // 2. User-selected datasources + // 3. Constant values (user-edited or defaults) + + const userSelectedDatasources = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings); + const constantMappings = mapConstantInputs(constantInputs, constantValues); + + const allMappings = [...existingMappings, ...userSelectedDatasources, ...constantMappings]; + onPreview(allMappings); + }; + + // Check if all unmapped datasource inputs have been mapped by user + // Constants are optional (have default values) + const allDatasourcesMapped = unmappedInputs.every((input) => userSelectedDsMappings[input.name]?.datasource); + + return ( + + + + + This dashboard requires datasource configuration. Select datasources for each input below. + + + + {existingMappings.length > 0 && ( + + + + + {{ count: existingMappings.length }} datasources were automatically configured: + + + + {existingMappings + .map((mapping) => { + const ds = getDataSourceSrv().getInstanceSettings(mapping.value); + return `${mapping.pluginId} → ${ds?.name || mapping.value}`; + }) + .join(' | ')} + + + + )} + + {unmappedInputs.length > 0 && ( + + + + Datasource Configuration + + + {unmappedInputs.map((input) => { + const selectedDatasource = userSelectedDsMappings[input.name]?.datasource; + + return ( + + onDatasourceSelect(input.name, ds)} + current={selectedDatasource?.uid} + noDefault={true} + placeholder={ + input.info || t('dashboard-library.community-mapping-select-datasource', 'Select a datasource') + } + pluginId={input.pluginId} + /> + + ); + })} + + )} + + {constantInputs.length > 0 && ( + + + Dashboard Variables + + {constantInputs.map((input) => ( + + onConstantChange(input.name, e.currentTarget.value)} + placeholder={input.value} + /> + + ))} + + )} + + + + + + + + + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx new file mode 100644 index 00000000000..791add79d32 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx @@ -0,0 +1,277 @@ +import { css } from '@emotion/css'; +import { useEffect, useState, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { useAsync, useDebounce } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterInput } from '@grafana/ui'; + +import { DashboardCard } from './DashboardCard'; +import { MappingContext } from './SuggestedDashboardsModal'; +import { fetchCommunityDashboards } from './api/dashboardLibraryApi'; +import { DashboardLibraryInteractions } from './interactions'; +import { GnetDashboard } from './types'; +import { + getThumbnailUrl, + getLogoUrl, + buildDashboardDetails, + onUseCommunityDashboard, +} from './utils/communityDashboardHelpers'; + +interface Props { + onShowMapping: (context: MappingContext) => void; + datasourceType?: string; +} + +// Constants for community dashboard pagination and API params +const COMMUNITY_PAGE_SIZE = 9; +const SEARCH_DEBOUNCE_MS = 500; +const DEFAULT_SORT_ORDER = 'downloads'; +const DEFAULT_SORT_DIRECTION = 'desc'; +const INCLUDE_LOGO = true; +const INCLUDE_SCREENSHOTS = true; + +export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Props) => { + const [searchParams] = useSearchParams(); + const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); + const [currentPage, setCurrentPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(''); + + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + useDebounce( + () => { + setDebouncedSearchQuery(searchQuery); + }, + SEARCH_DEBOUNCE_MS, + [searchQuery] + ); + + // Reset to page 1 when debounced search query changes + useEffect(() => { + if (debouncedSearchQuery) { + setCurrentPage(1); + } + }, [debouncedSearchQuery]); + + const { + value: response, + loading, + error, + } = useAsync(async () => { + if (!datasourceUid) { + return null; + } + + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return null; + } + + try { + const apiResponse = await fetchCommunityDashboards({ + orderBy: DEFAULT_SORT_ORDER, + direction: DEFAULT_SORT_DIRECTION, + page: currentPage, + pageSize: COMMUNITY_PAGE_SIZE, + includeLogo: INCLUDE_LOGO, + includeScreenshots: INCLUDE_SCREENSHOTS, + dataSourceSlugIn: ds.type, + filter: debouncedSearchQuery.trim() || undefined, + }); + + return { + dashboards: apiResponse.dashboards, + pages: apiResponse.pages, + datasourceType: ds.type, + }; + } catch (err) { + console.error('Error loading community dashboards', err); + throw err; + } + }, [datasourceUid, currentPage, debouncedSearchQuery]); + + // Track analytics only once on first successful load + const hasTrackedRef = useRef(false); + useEffect(() => { + if ( + !loading && + !hasTrackedRef.current && + currentPage === 1 && + response?.dashboards && + response.dashboards.length > 0 + ) { + DashboardLibraryInteractions.loaded({ + numberOfItems: response.dashboards.length, + contentKinds: ['community_dashboard'], + datasourceTypes: [response.datasourceType], + sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_community_tab', + }); + hasTrackedRef.current = true; + } + }, [loading, currentPage, response]); + + const styles = useStyles2(getStyles); + + // Determine what to show in results area + const dashboards = Array.isArray(response?.dashboards) ? response.dashboards : []; + const totalPages = response?.pages || 1; + const showEmptyState = !loading && (!response?.dashboards || response.dashboards.length === 0); + const showError = !loading && error; + + const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => { + if (!response) { + return; + } + + onUseCommunityDashboard({ + dashboard, + datasourceUid: datasourceUid || '', + datasourceType: response.datasourceType, + eventLocation: 'suggested_dashboards_modal_community_tab', + onShowMapping, + }); + }; + + return ( + + + +
+ {loading ? ( + + {Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => ( + + ))} + + ) : showError ? ( + + + + Failed to load community dashboards. Please try again. + + + + + ) : showEmptyState ? ( + window.open('https://grafana.com/grafana/dashboards/', '_blank')} + > + Browse Grafana.com + + } + > + {searchQuery && !datasourceType ? ( + + Try a different search term or browse more dashboards on Grafana.com. + + ) : ( + + Try a different search term or browse dashboards for different datasource types on Grafana.com. + + )} + + ) : ( + = 2 ? 2 : 1, + lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1, + }} + > + {dashboards.map((dashboard) => { + const thumbnailUrl = getThumbnailUrl(dashboard); + const logoUrl = getLogoUrl(dashboard); + const imageUrl = thumbnailUrl || logoUrl; + const isLogo = !thumbnailUrl; + const details = buildDashboardDetails(dashboard); + + return ( + onPreviewCommunityDashboard(dashboard)} + isLogo={isLogo} + details={details} + buttonText={Use dashboard} + /> + ); + })} + + )} +
+ {totalPages > 1 && ( +
+ +
+ )} +
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + resultsContainer: css({ + width: '100%', + position: 'relative', + flex: 1, + overflow: 'auto', + }), + paginationWrapper: css({ + position: 'sticky', + bottom: 0, + backgroundColor: theme.colors.background.primary, + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'flex-end', + zIndex: 2, + }), + searchInput: css({ + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx new file mode 100644 index 00000000000..8d038e0e5c3 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx @@ -0,0 +1,263 @@ +import { css, cx } from '@emotion/css'; +import Skeleton from 'react-loading-skeleton'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { Badge, Box, Button, Card, IconButton, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; +import { attachSkeleton, SkeletonComponent } from '@grafana/ui/unstable'; +import { PluginDashboard } from 'app/types/plugins'; + +import { GnetDashboard } from './types'; + +interface Details { + id: string; + datasource: string; + dependencies: string[]; + publishedBy: string; + lastUpdate: string; + grafanaComUrl?: string; +} + +interface Props { + title: string; + imageUrl?: string; + dashboard: PluginDashboard | GnetDashboard; + details?: Details; + onClick: () => void; + isLogo?: boolean; // Indicates if imageUrl is a small logo vs full screenshot + showDatasourceProvidedBadge?: boolean; + dimThumbnail?: boolean; // Apply 50% opacity to thumbnail when badge is shown + buttonText?: React.ReactNode; // Optional custom button text, defaults to "Use template" +} + +function DashboardCardComponent({ + title, + imageUrl, + onClick, + dashboard, + details, + isLogo, + showDatasourceProvidedBadge, + dimThumbnail, + buttonText, +}: Props) { + const styles = useStyles2(getStyles); + + return ( + + {title} +
+ {imageUrl ? ( + {title} { + console.error('Failed to load image for:', title, 'URL:', imageUrl); + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( +
+ No preview available +
+ )} + {showDatasourceProvidedBadge && ( +
+ +
+ )} +
+
+ {dashboard.description && ( + {dashboard.description} + )} +
+ + + {details && ( + } placement="right"> + + + )} + +
+ ); +} + +function DetailsTooltipContent({ details }: { details: Details }) { + const Section = ({ label, value }: { label: string; value: string }) => { + return ( + + {label} + + {value} + + + ); + }; + + return ( + + +
+
+
+
+
+ {details.grafanaComUrl && ( + + + {t('dashboard-library.dashboard-card.details.view-on-grafana-com', 'View on Grafana.com')} + + + )} + + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + card: css({ + gridTemplateAreas: ` + "Heading Heading" + "Thumbnail Thumbnail" + "Description Description" + "Actions Secondary"`, + gridTemplateRows: 'auto auto auto auto', + gridTemplateColumns: '1fr auto', + height: 'auto', + width: '350px', + background: 'transparent', + gridGap: theme.spacing(1), + }), + thumbnailContainer: css({ + gridArea: 'Thumbnail', + overflow: 'hidden', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: theme.shape.radius.default, + borderColor: theme.colors.border.strong, + borderWidth: 1, + borderStyle: 'solid', + width: '100%', + maxWidth: '350px', + height: '180px', + backgroundColor: theme.colors.background.canvas, + position: 'relative', + }), + thumbnail: css({ + width: '100%', + height: '100%', + objectFit: 'cover', + }), + logoContainer: css({ + gridArea: 'Thumbnail', + overflow: 'hidden', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: theme.shape.radius.default, + width: '100%', + height: '180px', + backgroundColor: theme.colors.background.secondary, + position: 'relative', + }), + logo: css({ + objectFit: 'fill', + }), + noImage: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + height: '100%', + width: '100%', + }), + descriptionWrapper: css({ + gridArea: 'Description', + wordBreak: 'break-word', + minHeight: `calc(${theme.typography.body.lineHeight} * 1em)`, // Preserve space even when empty + }), + title: css({ + display: '-webkit-box', + WebkitLineClamp: 1, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + textOverflow: 'ellipsis', + }), + description: css({ + display: '-webkit-box', + WebkitLineClamp: 1, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + textOverflow: 'ellipsis', + margin: 0, + height: `calc(${theme.typography.body.lineHeight} * 1em)`, // Fixed height for 1 line + }), + actionsContainer: css({ + marginTop: 0, + alignItems: 'stretch', + }), + detailsContainer: css({ + width: '340px', + }), + detailValue: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + badgeContainer: css({ + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + zIndex: 1, + }), + dimmedImage: css({ + opacity: 0.3, + }), + placeholderText: css({ + color: theme.colors.text.disabled, + fontStyle: 'italic', + }), + }; +} + +const DashboardCardSkeleton: SkeletonComponent = ({ rootProps }) => { + const styles = useStyles2(getSkeletonStyles); + return ; +}; + +const getSkeletonStyles = () => ({ + container: css({ + lineHeight: 1, + }), +}); + +export const DashboardCard = attachSkeleton(DashboardCardComponent, DashboardCardSkeleton); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx index 266e671de03..6c31bb7bc83 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx @@ -1,31 +1,40 @@ import { css } from '@emotion/css'; -import { useState } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { useAsync } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; -import { getBackendSrv, getDataSourceSrv, locationService } from '@grafana/runtime'; -import { Button, useStyles2, Text, Box, Stack, Grid } from '@grafana/ui'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv, locationService } from '@grafana/runtime'; +import { useStyles2, Stack, Grid, Pagination, EmptyState, Button } from '@grafana/ui'; import { PluginDashboard } from 'app/types/plugins'; -import dashboardLibrary1 from 'img/dashboard-library/dashboard_library_1.jpg'; -import dashboardLibrary2 from 'img/dashboard-library/dashboard_library_2.jpg'; -import dashboardLibrary3 from 'img/dashboard-library/dashboard_library_3.jpg'; -import dashboardLibrary4 from 'img/dashboard-library/dashboard_library_4.jpg'; -import dashboardLibrary5 from 'img/dashboard-library/dashboard_library_5.jpg'; -import dashboardLibrary6 from 'img/dashboard-library/dashboard_library_6.jpg'; import { DASHBOARD_LIBRARY_ROUTES } from '../types'; +import { DashboardCard } from './DashboardCard'; +import { fetchProvisionedDashboards } from './api/dashboardLibraryApi'; import { DashboardLibraryInteractions } from './interactions'; +import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; + +// Constants for datasource-provided dashboards pagination +const PAGE_SIZE = 9; export const DashboardLibrarySection = () => { const [searchParams] = useSearchParams(); const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); - const [showAll, setShowAll] = useState(false); + const [currentPage, setCurrentPage] = useState(1); - const { value: templateDashboards } = useAsync(async (): Promise => { + // Get datasource info for empty state + const datasourceType = useMemo(() => { + if (!datasourceUid) { + return ''; + } + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + return ds?.type || ''; + }, [datasourceUid]); + + const { value: templateDashboards, loading } = useAsync(async (): Promise => { if (!datasourceUid) { return []; } @@ -35,38 +44,45 @@ export const DashboardLibrarySection = () => { return []; } - try { - const dashboards = await getBackendSrv().get(`api/plugins/${ds.type}/dashboards`, undefined, undefined, { - showErrorAlert: false, - }); - - if (dashboards.length > 0) { - DashboardLibraryInteractions.loaded({ - numberOfItems: dashboards.length, - contentKinds: ['datasource_dashboard'], - datasourceTypes: [ds.type], - sourceEntryPoint: 'datasource_page', - }); - } - return dashboards; - } catch (error) { - console.error('Error loading template dashboards', error); - return []; - } + const dashboards = await fetchProvisionedDashboards(ds.type); + return dashboards; }, [datasourceUid]); - const hasMoreThanThree = templateDashboards && templateDashboards.length > 3; - const dashboardsToShow = showAll ? templateDashboards : templateDashboards?.slice(0, 3); + // Track analytics only once on first successful load + const hasTrackedRef = useRef(false); + useEffect(() => { + if (!loading && !hasTrackedRef.current && templateDashboards && templateDashboards.length > 0) { + DashboardLibraryInteractions.loaded({ + numberOfItems: templateDashboards.length, + contentKinds: ['datasource_dashboard'], + datasourceTypes: [datasourceType], + sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_provisioned_tab', + }); + hasTrackedRef.current = true; + } + }, [loading, templateDashboards, datasourceType]); - const styles = useStyles2(getStyles, dashboardsToShow?.length); + // Calculate pagination + const totalDashboards = templateDashboards?.length || 0; + const totalPages = Math.ceil(totalDashboards / PAGE_SIZE); + const startIndex = (currentPage - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + const dashboardsToShow = templateDashboards?.slice(startIndex, endIndex); - const onImportDashboardClick = async (dashboard: PluginDashboard) => { + const styles = useStyles2(getStyles); + + // Determine what to show + const showEmptyState = !loading && (!templateDashboards || templateDashboards.length === 0); + + const onUseProvisionedDashboard = async (dashboard: PluginDashboard) => { DashboardLibraryInteractions.itemClicked({ contentKind: 'datasource_dashboard', datasourceTypes: [dashboard.pluginId], libraryItemId: dashboard.uid, libraryItemTitle: dashboard.title, sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_provisioned_tab', }); const params = new URLSearchParams({ @@ -84,117 +100,80 @@ export const DashboardLibrarySection = () => { locationService.push(templateUrl); }; - if (!templateDashboards?.length) { - return null; - } - return ( - - - - - Start with a pre-made dashboard from your data source + + {showEmptyState ? ( + window.open('https://grafana.com/grafana/plugins/', '_blank')}> + Browse plugins + + } + > + + Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com. - - - = 2 ? 2 : 1, - lg: (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1, - }} - > - {dashboardsToShow?.map((dashboard, index) => ( - - )) || []} - - - {hasMoreThanThree && ( - - )} - - + + ) : ( + = 2 ? 2 : 1, + lg: loading ? 3 : (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1, + }} + > + {loading && !templateDashboards + ? Array.from({ length: 9 }).map((_, i) => ) + : dashboardsToShow?.map((dashboard, index) => { + // Use global index for consistent image assignment across pages + const globalIndex = startIndex + index; + const imageUrl = getProvisionedDashboardImageUrl(globalIndex); + + return ( + onUseProvisionedDashboard(dashboard)} + buttonText={Use dashboard} + /> + ); + }) || []} + + )} + {!showEmptyState && totalPages > 1 && ( + setCurrentPage(page)} + className={styles.pagination} + /> + )} + ); }; -const TemplateDashboardBox = ({ - dashboard, - onImportClick, - index, -}: { - dashboard: PluginDashboard; - onImportClick: (d: PluginDashboard) => void; - index: number; -}) => { - const dashboardLibraryImages = [ - dashboardLibrary1, - dashboardLibrary2, - dashboardLibrary3, - dashboardLibrary4, - dashboardLibrary5, - dashboardLibrary6, - ]; - - const styles = useStyles2(getStyles); - return ( -
- {dashboard.title} -
- - {dashboard.title} - -
- -
- ); -}; - -function getStyles(theme: GrafanaTheme2, dashboardsLength?: number) { +function getStyles(theme: GrafanaTheme2) { return { - templateDashboardBox: css({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), + pagination: css({ + position: 'sticky', + bottom: 0, + backgroundColor: theme.colors.background.primary, + padding: theme.spacing(2), alignItems: 'center', - }), - templateDashboardTitle: css({ - flex: 1, - }), - templateDashboardImage: css({ - borderRadius: theme.shape.radius.default, - borderColor: theme.colors.text.primary, - borderWidth: 1, - borderStyle: 'solid', - objectFit: 'cover', - }), - showMoreButton: css({ - marginTop: theme.spacing(2), + zIndex: 2, }), }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx new file mode 100644 index 00000000000..ecd5e258cf7 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx @@ -0,0 +1,370 @@ +import { css } from '@emotion/css'; +import { useEffect, useMemo, useState, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { useAsync } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv, locationService } from '@grafana/runtime'; +import { Button, useStyles2, Grid } from '@grafana/ui'; +import { PluginDashboard } from 'app/types/plugins'; + +import { DashboardCard } from './DashboardCard'; +import { MappingContext, SuggestedDashboardsModal } from './SuggestedDashboardsModal'; +import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi'; +import { DashboardLibraryInteractions } from './interactions'; +import { GnetDashboard } from './types'; +import { + getThumbnailUrl, + getLogoUrl, + buildDashboardDetails, + onUseCommunityDashboard, +} from './utils/communityDashboardHelpers'; +import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; + +interface Props { + datasourceUid?: string; +} + +type MixedDashboard = + | { type: 'provisioned'; dashboard: PluginDashboard; index: number } + | { type: 'community'; dashboard: GnetDashboard }; + +type SuggestedDashboardsResult = { + dashboards: MixedDashboard[]; + hasMoreDashboards: boolean; +}; + +// Constants for suggested dashboards API params +const SUGGESTED_COMMUNITY_PAGE_SIZE = 2; +const DEFAULT_SORT_ORDER = 'downloads'; +const DEFAULT_SORT_DIRECTION = 'desc'; +const INCLUDE_SCREENSHOTS = true; +const INCLUDE_LOGO = true; + +export const SuggestedDashboards = ({ datasourceUid }: Props) => { + const styles = useStyles2(getStyles); + const [searchParams, setSearchParams] = useSearchParams(); + const showLibraryModal = searchParams.get('dashboardLibraryModal') === 'open'; + + // Validate and get default tab from URL params + const tabParam = searchParams.get('dashboardLibraryTab'); + const defaultTab: 'datasource' | 'community' = tabParam === 'community' ? 'community' : 'datasource'; + + const [mappingContext, setMappingContext] = useState(null); + + // Get datasource type for dynamic title + const datasourceType = useMemo(() => { + if (!datasourceUid) { + return ''; + } + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + return ds?.type || ''; + }, [datasourceUid]); + + const { value: result, loading } = useAsync(async (): Promise => { + if (!datasourceUid) { + return { dashboards: [], hasMoreDashboards: false }; + } + + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return { dashboards: [], hasMoreDashboards: false }; + } + + try { + // Fetch both provisioned and community dashboards in parallel + const [provisioned, communityResponse] = await Promise.all([ + // Fetch provisioned dashboards + fetchProvisionedDashboards(ds.type), + + // Fetch community dashboards + fetchCommunityDashboards({ + orderBy: DEFAULT_SORT_ORDER, + direction: DEFAULT_SORT_DIRECTION, + page: 1, + pageSize: SUGGESTED_COMMUNITY_PAGE_SIZE, + includeScreenshots: INCLUDE_SCREENSHOTS, + dataSourceSlugIn: ds.type, + includeLogo: INCLUDE_LOGO, + }), + ]); + + const community = communityResponse.dashboards; + + // Mix: 1 provisioned + 2 community + const mixed: MixedDashboard[] = []; + + // Take 1 provisioned if available + if (provisioned.length > 0) { + mixed.push({ type: 'provisioned', dashboard: provisioned[0], index: 0 }); + } + + // Take up to 2 community dashboards + const communityCount = Math.min(2, community.length); + for (let i = 0; i < communityCount; i++) { + mixed.push({ type: 'community', dashboard: community[i] }); + } + + // Fill remaining slots if we have less than 3 + while (mixed.length < 3) { + const provisionedUsed = mixed.filter((m) => m.type === 'provisioned').length; + const communityUsed = mixed.filter((m) => m.type === 'community').length; + + if (provisionedUsed < provisioned.length) { + mixed.push({ type: 'provisioned', dashboard: provisioned[provisionedUsed], index: provisionedUsed }); + } else if (communityUsed < community.length) { + mixed.push({ type: 'community', dashboard: community[communityUsed] }); + } else { + break; // Not enough dashboards + } + } + + // Determine if there are more dashboards available beyond what we're showing + // Show "View all" if: more than 1 provisioned exists OR we got the full page size of community dashboards + const hasMoreDashboards = provisioned.length > 1 || community.length >= SUGGESTED_COMMUNITY_PAGE_SIZE; + + return { dashboards: mixed, hasMoreDashboards }; + } catch (error) { + console.error('Error loading suggested dashboards', error); + return { dashboards: [], hasMoreDashboards: false }; + } + }, [datasourceUid]); + + // Determine which tab should be default based on available data + const computedDefaultTab = useMemo((): 'datasource' | 'community' => { + if (!result || loading) { + return 'datasource'; // Default while loading + } + + const hasProvisioned = result.dashboards.some((d) => d.type === 'provisioned'); + + // Prefer datasource tab if it has data, otherwise community + return hasProvisioned ? 'datasource' : 'community'; + }, [result, loading]); + + // Track analytics only once on first successful load + const hasTrackedRef = useRef(false); + useEffect(() => { + if (!loading && !hasTrackedRef.current && result && result.dashboards.length > 0) { + const contentKinds: Array<'datasource_dashboard' | 'community_dashboard'> = [ + ...new Set( + result.dashboards.map((m) => (m.type === 'provisioned' ? 'datasource_dashboard' : 'community_dashboard')) + ), + ]; + DashboardLibraryInteractions.loaded({ + numberOfItems: result.dashboards.length, + contentKinds, + datasourceTypes: [datasourceType], + sourceEntryPoint: 'datasource_page', + eventLocation: 'empty_dashboard', + }); + hasTrackedRef.current = true; + } + }, [loading, result, datasourceType]); + + const onModalDismiss = () => { + // Remove modal-related query params while keeping datasourceUid + setSearchParams((params) => { + params.delete('dashboardLibraryModal'); + params.delete('dashboardLibraryTab'); + return params; + }); + setMappingContext(null); + }; + + const onOpenModal = (tab: 'datasource' | 'community') => { + setSearchParams((params) => { + const newParams = new URLSearchParams(params); + newParams.set('dashboardLibraryModal', 'open'); + newParams.set('dashboardLibraryTab', tab); + return newParams; + }); + }; + + const onShowMapping = (context: MappingContext) => { + setMappingContext(context); + onOpenModal(computedDefaultTab); + }; + + const onUseProvisionedDashboard = (dashboard: PluginDashboard) => { + if (!datasourceUid) { + return; + } + + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return; + } + + DashboardLibraryInteractions.itemClicked({ + contentKind: 'datasource_dashboard', + datasourceTypes: [ds.type], + libraryItemId: dashboard.uid, + libraryItemTitle: dashboard.title, + sourceEntryPoint: 'datasource_page', + eventLocation: 'empty_dashboard', + }); + + // Navigate to template route (existing flow) + const params = new URLSearchParams({ + datasource: datasourceUid, + title: dashboard.title || 'Template', + pluginId: dashboard.pluginId, + path: dashboard.path, + sourceEntryPoint: 'datasource_page', + libraryItemId: dashboard.uid, + creationOrigin: 'dashboard_library_datasource_dashboard', + }); + + locationService.push(`/dashboard/template?${params.toString()}`); + }; + + const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => { + if (!datasourceUid) { + return; + } + + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return; + } + + onUseCommunityDashboard({ + dashboard, + datasourceUid, + datasourceType: ds.type, + eventLocation: 'empty_dashboard', + onShowMapping: onShowMapping, + }); + }; + + // Don't render if no dashboards or still loading + if (!loading && (!result || result.dashboards.length === 0)) { + return null; + } + + return ( + <> +
+
+
+

+ {datasourceType + ? t( + 'dashboard-library.suggested-dashboards-title-with-datasource', + 'Build a dashboard using suggested options for your {{datasourceType}} data source', + { datasourceType } + ) + : t( + 'dashboard-library.suggested-dashboards-title', + 'Build a dashboard using suggested options for your selected data source' + )} +

+

+ + Browse and select from data-source provided or community dashboards + +

+
+ {result?.hasMoreDashboards && ( + + )} +
+ + + {loading + ? Array.from({ length: 3 }).map((_, i) => ) + : result?.dashboards.map((item, idx) => { + if (item.type === 'provisioned') { + return ( + onUseProvisionedDashboard(item.dashboard)} + showDatasourceProvidedBadge={true} + dimThumbnail={true} + buttonText={Use dashboard} + /> + ); + } else { + const thumbnailUrl = getThumbnailUrl(item.dashboard); + const imageUrl = thumbnailUrl || getLogoUrl(item.dashboard); + const isLogo = !thumbnailUrl; + const details = buildDashboardDetails(item.dashboard); + + return ( + onPreviewCommunityDashboard(item.dashboard)} + isLogo={isLogo} + details={details} + buttonText={Use dashboard} + /> + ); + } + }) || []} + +
+ + + ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + borderRadius: theme.shape.radius.default, + borderColor: theme.colors.border.strong, + borderStyle: 'dashed', + borderWidth: 1, + padding: theme.spacing(4), + }), + header: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: theme.spacing(1), + gap: theme.spacing(2), + paddingRight: theme.spacing(2), + paddingLeft: theme.spacing(2), + }), + headerText: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + flex: 1, + }), + title: css({ + margin: 0, + fontSize: theme.typography.h2.fontSize, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: theme.typography.h2.lineHeight, + }), + subtitle: css({ + margin: 0, + fontSize: theme.typography.body.fontSize, + color: theme.colors.text.secondary, + lineHeight: theme.typography.body.lineHeight, + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx new file mode 100644 index 00000000000..e6b78d35e5f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx @@ -0,0 +1,196 @@ +import { css } from '@emotion/css'; +import { useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Modal, TabsBar, Tab, TabContent, useStyles2, Text } from '@grafana/ui'; +import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; + +import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm'; +import { CommunityDashboardSection } from './CommunityDashboardSection'; +import { DashboardLibrarySection } from './DashboardLibrarySection'; +import { InputMapping } from './utils/autoMapDatasources'; + +interface SuggestedDashboardsModalProps { + isOpen: boolean; + onDismiss: () => void; + initialMappingContext?: MappingContext | null; + defaultTab?: 'datasource' | 'community'; +} + +type ModalView = 'datasource' | 'community' | 'mapping'; + +export interface MappingContext { + dashboardName: string; + dashboardJson: DashboardJson; + unmappedInputs: DataSourceInput[]; + constantInputs: DashboardInput[]; + existingMappings: InputMapping[]; + onInterpolateAndNavigate: (mappings: InputMapping[]) => void; +} + +export const SuggestedDashboardsModal = ({ + isOpen, + onDismiss, + initialMappingContext, + defaultTab = 'datasource', +}: SuggestedDashboardsModalProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); + + const [activeView, setActiveView] = useState(initialMappingContext ? 'mapping' : defaultTab); + const [mappingContext, setMappingContext] = useState(initialMappingContext || null); + const styles = useStyles2(getStyles); + + // Get datasource info for modal title and search + const datasourceInfo = useMemo(() => { + if (!datasourceUid) { + return { type: '' }; + } + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + return { + type: ds?.type || '', + }; + }, [datasourceUid]); + + // Update state when initialMappingContext changes or modal opens/closes + useEffect(() => { + if (initialMappingContext) { + setMappingContext(initialMappingContext); + setActiveView('mapping'); + } else if (isOpen) { + // When modal opens, set to defaultTab + setActiveView(defaultTab); + } else { + // Reset when modal closes + setMappingContext(null); + } + }, [initialMappingContext, isOpen, defaultTab]); + + const onTabChange = (tab: 'datasource' | 'community') => { + setActiveView(tab); + // Update URL to reflect current tab + setSearchParams((params) => { + const newParams = new URLSearchParams(params); + newParams.set('dashboardLibraryTab', tab); + return newParams; + }); + }; + + const handleShowMapping = (context: MappingContext) => { + setMappingContext(context); + setActiveView('mapping'); + }; + + const handleBackToDashboards = () => { + setMappingContext(null); + setActiveView('community'); + }; + + return ( + + {activeView !== 'mapping' && ( +
+ + + Browse and select from data-source provided or community dashboards + + + + + onTabChange('datasource')} + /> + onTabChange('community')} + /> + +
+ )} + + + {activeView === 'datasource' && } + {activeView === 'community' && ( + + )} + {activeView === 'mapping' && mappingContext && ( + { + mappingContext.onInterpolateAndNavigate(allMappings); + }} + /> + )} + +
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + modal: css({ + width: '90%', + maxWidth: '1200px', + height: '80vh', + display: 'flex', + flexDirection: 'column', + }), + modalContent: css({ + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + padding: 0, + marginBottom: 0, + height: '100%', + }), + stickyHeader: css({ + position: 'sticky', + top: 0, + zIndex: 2, + backgroundColor: theme.colors.background.primary, + paddingTop: theme.spacing(3), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }), + tabContent: css({ + flex: 1, + overflow: 'auto', + paddingTop: theme.spacing(3), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts new file mode 100644 index 00000000000..d856fd996f0 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -0,0 +1,94 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; +import { PluginDashboard } from 'app/types/plugins'; + +import { GnetDashboardsResponse } from '../types'; + +/** + * Parameters for fetching community dashboards from Grafana.com + */ +export interface FetchCommunityDashboardsParams { + orderBy: string; + direction: 'asc' | 'desc'; + page: number; + pageSize: number; + includeLogo: boolean; + includeScreenshots: boolean; + dataSourceSlugIn?: string; + filter?: string; +} + +/** + * Response from the Gnet API when fetching a single dashboard + */ +export interface GnetDashboardResponse { + json: DashboardJson; + [key: string]: unknown; +} + +/** + * Fetch community dashboards from Grafana.com + */ +export async function fetchCommunityDashboards( + params: FetchCommunityDashboardsParams +): Promise { + const searchParams = new URLSearchParams({ + orderBy: params.orderBy, + direction: params.direction, + page: params.page.toString(), + pageSize: params.pageSize.toString(), + includeLogo: params.includeLogo ? '1' : '0', + includeScreenshots: params.includeScreenshots ? 'true' : 'false', + }); + + if (params.dataSourceSlugIn) { + searchParams.append('dataSourceSlugIn', params.dataSourceSlugIn); + } + if (params.filter) { + searchParams.append('filter', params.filter); + } + + const result = await getBackendSrv().get(`/api/gnet/dashboards?${searchParams}`, undefined, undefined, { + showErrorAlert: false, + }); + + // Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] } + // We normalize it to use "dashboards" instead of "items" for consistency + if (result && Array.isArray(result.items)) { + return { + page: result.page || params.page, + pages: result.pages || 1, + dashboards: result.items, + }; + } + + // Fallback for unexpected response format + console.warn('Unexpected API response format from Grafana.com:', result); + return { + page: params.page, + pages: 1, + dashboards: [], + }; +} + +/** + * Fetch a single community dashboard's full JSON from Grafana.com + */ +export async function fetchCommunityDashboard(gnetId: number): Promise { + return getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`); +} + +/** + * Fetch provisioned dashboards for a datasource type + */ +export async function fetchProvisionedDashboards(datasourceType: string): Promise { + try { + const dashboards = await getBackendSrv().get(`api/plugins/${datasourceType}/dashboards`, undefined, undefined, { + showErrorAlert: false, + }); + return Array.isArray(dashboards) ? dashboards : []; + } catch (error) { + console.error('Error loading provisioned dashboards', error); + return []; + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts index 1222007dd84..6b1a07c9e43 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -2,20 +2,26 @@ import { reportInteraction } from '@grafana/runtime'; const SCHEMA_VERSION = 1; -type ContentKind = 'datasource_dashboard'; -// in future this could be "template_dashboard" if/when items become templates or "community_dashboard" -// | 'template_dashboard' | 'community_dashboard'; +type ContentKind = 'datasource_dashboard' | 'community_dashboard'; +// in future this could also include "template_dashboard" if/when items become templates +// | 'template_dashboard'; type SourceEntryPoint = 'datasource_page'; // possible future flows onboarding, create-dashboard, empty states // | 'create_dashboard' | 'empty_state'; +type EventLocation = + | 'empty_dashboard' + | 'suggested_dashboards_modal_provisioned_tab' + | 'suggested_dashboards_modal_community_tab'; + export const DashboardLibraryInteractions = { loaded: (properties: { numberOfItems: number; contentKinds: ContentKind[]; datasourceTypes: string[]; sourceEntryPoint: SourceEntryPoint; + eventLocation: EventLocation; }) => { reportDashboardLibraryInteraction('loaded', properties); }, @@ -25,6 +31,7 @@ export const DashboardLibraryInteractions = { libraryItemId: string; libraryItemTitle: string; sourceEntryPoint: SourceEntryPoint; + eventLocation: EventLocation; }) => { reportDashboardLibraryInteraction('item_clicked', properties); }, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts new file mode 100644 index 00000000000..38267a4870f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts @@ -0,0 +1,48 @@ +import { DashboardJson } from 'app/features/manage-dashboards/types'; + +export interface Link { + rel: string; + href: string; +} + +export interface Screenshot { + links: Link[]; +} + +export interface LogoImage { + content: string; + filename: string; + type: string; +} + +export interface Logo { + small?: LogoImage; + large?: LogoImage; +} + +export interface GnetDashboard { + id: number; + uid: string; + name: string; + description: string; + downloads: number; + datasource: string; + screenshots?: Screenshot[]; + logos?: Logo; + json?: DashboardJson; // Full dashboard JSON from detail API + createdAt?: string; // ISO date string if available + updatedAt?: string; // ISO date string if available + publishedAt?: string; // ISO date string if available + // Author/organization information + orgId?: number; + orgName?: string; + orgSlug?: string; + userId?: number; + userName?: string; +} + +export interface GnetDashboardsResponse { + page: number; + pages: number; + dashboards: GnetDashboard[]; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts new file mode 100644 index 00000000000..856c99daefc --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts @@ -0,0 +1,152 @@ +import { getDataSourceSrv } from '@grafana/runtime'; +import { Input } from 'app/features/dashboard/components/DashExportModal/DashboardExporter'; +import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/state/reducers'; + +export interface InputMapping { + name: string; + type: 'datasource' | 'constant'; + pluginId?: string; + value: string; +} + +/** + * Type guard to check if an Input is a DataSourceInput. + * DataSourceInput requires both type='datasource' and a pluginId property. + */ +export function isDataSourceInput(input: Input): input is Input & DataSourceInput { + return input.type === 'datasource' && 'pluginId' in input; +} + +export interface AutoMapResult { + allMapped: boolean; + mappings: InputMapping[]; + unmappedInputs: DataSourceInput[]; +} + +/** + * Attempts to automatically map datasource inputs to available datasources. + * Uses two ways of mapping: + * 1. Prefer the current datasource if it matches the required type + * 2. Auto-select if only one compatible datasource exists + * + * @param inputs - Array of datasource inputs from dashboard __inputs + * @param currentDatasourceUid - UID of the datasource selected in "build dashboard" flow + * @returns Result containing mappings, unmapped inputs, and whether all inputs were mapped + */ +export function tryAutoMapDatasources(inputs: DataSourceInput[], currentDatasourceUid: string): AutoMapResult { + const mappings: InputMapping[] = []; + const unmappedInputs: DataSourceInput[] = []; + + for (const input of inputs) { + // Get all datasources compatible with this input's plugin type + const compatibleDs = getDataSourceSrv() + .getList({ type: input.pluginId }) + .filter((ds) => ds.uid); + + let selectedDs: string | undefined; + + // Option 1: Use current datasource if compatible + if (compatibleDs.some((ds) => ds.uid === currentDatasourceUid)) { + selectedDs = currentDatasourceUid; + } + // Option 2: Auto-select if only one option exists AND it's not the current datasource's type + // (example: only auto-select if we are confident it's the right choice) + else if (compatibleDs.length === 1) { + const currentDs = getDataSourceSrv().getInstanceSettings(currentDatasourceUid); + + // Only auto-select if: + // - The single option matches the input's plugin type exactly + // - OR we're coming from a datasource of the same type (e.g., Prometheus -> Prometheus) + if (currentDs && currentDs.type === input.pluginId) { + selectedDs = compatibleDs[0].uid; + } + } + + if (selectedDs) { + mappings.push({ + name: input.name, + type: 'datasource', + pluginId: input.pluginId, + value: selectedDs, + }); + } else { + unmappedInputs.push(input); + } + } + + return { + allMapped: unmappedInputs.length === 0, + mappings, + unmappedInputs, + }; +} + +/** + * Parses constant inputs from dashboard __inputs array. + * Constants need to be shown to the user for filling that information (the same as the import flow). + * + * @param allInputs - All inputs from dashboard.__inputs + * @returns Array of constant inputs with their default values + */ +export function parseConstantInputs(allInputs: Input[]): DashboardInput[] { + if (!allInputs || !Array.isArray(allInputs)) { + return []; + } + + return allInputs + .filter((input) => input.type === 'constant') + .map((input) => ({ + name: input.name, + label: input.label || input.name, + description: input.description, + info: input.description || 'Specify a string constant', + value: input.value || '', + type: InputType.Constant, + pluginId: undefined, + })); +} + +/** + * Converts constant inputs to InputMapping format for the interpolate API. + * Uses user-provided values or defaults from the dashboard. + * + * @param constantInputs - Array of constant inputs + * @param userValues - User-entered values (key: input name, value: user input) + * @returns Array of InputMapping for constants + */ +export function mapConstantInputs( + constantInputs: DashboardInput[], + userValues: Record +): InputMapping[] { + return constantInputs.map((input) => ({ + name: input.name, + type: 'constant', + value: userValues[input.name] !== undefined ? userValues[input.name] : input.value, + })); +} + +interface UserSelectedDatasourceMappings { + name: string; + pluginId: string; + datasource: { uid: string } | undefined; +} + +/** + * Maps user-selected datasources to InputMapping format. + * Used in the mapping form to convert user selections into the format required for dashboard interpolation. + * + * @param unmappedInputs - The datasource inputs that need mapping + * @param userSelectedDsMappings - Record of user selections keyed by input name + * @returns Array of InputMapping objects for user-selected datasources + */ +export function mapUserSelectedDatasources( + unmappedInputs: DataSourceInput[], + userSelectedDsMappings: Record +): InputMapping[] { + return unmappedInputs.map((input) => ({ + name: input.name, + type: 'datasource', + pluginId: input.pluginId, + value: userSelectedDsMappings[input.name]?.datasource?.uid || '', + })); +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts new file mode 100644 index 00000000000..83b7dd47d78 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -0,0 +1,177 @@ +import { locationService } from '@grafana/runtime'; +import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; + +import { DASHBOARD_LIBRARY_ROUTES } from '../../types'; +import { MappingContext } from '../SuggestedDashboardsModal'; +import { fetchCommunityDashboard } from '../api/dashboardLibraryApi'; +import { DashboardLibraryInteractions } from '../interactions'; +import { GnetDashboard, Link } from '../types'; + +import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources'; + +/** + * Extract thumbnail URL from dashboard screenshots + */ +export function getThumbnailUrl(dashboard: GnetDashboard): string { + const thumbnail = dashboard.screenshots?.[0]?.links.find((l: Link) => l.rel === 'image')?.href ?? ''; + return thumbnail ? `/api/gnet${thumbnail}` : ''; +} + +/** + * Extract logo URL from dashboard logos + */ +export function getLogoUrl(dashboard: GnetDashboard): string { + const logo = dashboard.logos?.large || dashboard.logos?.small; + if (logo?.content && logo?.type) { + return `data:${logo.type};base64,${logo.content}`; + } + return ''; +} + +/** + * Format date string for display + */ +export function formatDate(dateString?: string): string { + if (!dateString) { + return 'N/A'; + } + const date = new Date(dateString); + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +/** + * Create URL-friendly slug from dashboard name + */ +export function createSlug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Build Grafana.com URL for a dashboard + */ +export function buildGrafanaComUrl(dashboard: GnetDashboard): string { + return `https://grafana.com/grafana/dashboards/${dashboard.id}-${createSlug(dashboard.name)}/`; +} + +/** + * Build dashboard details object for display in card + */ +export interface DashboardDetails { + id: string; + datasource: string; + dependencies: string[]; + publishedBy: string; + lastUpdate: string; + grafanaComUrl: string; +} + +export function buildDashboardDetails(dashboard: GnetDashboard): DashboardDetails { + return { + id: String(dashboard.id), + datasource: dashboard.datasource || 'N/A', + dependencies: dashboard.datasource ? [dashboard.datasource] : [], + publishedBy: dashboard.orgName || dashboard.userName || 'Grafana Community', + lastUpdate: formatDate(dashboard.updatedAt || dashboard.publishedAt), + grafanaComUrl: buildGrafanaComUrl(dashboard), + }; +} + +/** + * Navigate to dashboard template route with mappings + */ +export function navigateToTemplate( + dashboardTitle: string, + gnetId: number, + datasourceUid: string, + mappings: InputMapping[] +): void { + const searchParams = new URLSearchParams({ + datasource: datasourceUid, + title: dashboardTitle, + gnetId: String(gnetId), + sourceEntryPoint: 'datasource_page', + creationOrigin: 'dashboard_library_community_dashboard', + mappings: JSON.stringify(mappings), + }); + + locationService.push({ + pathname: DASHBOARD_LIBRARY_ROUTES.Template, + search: searchParams.toString(), + }); +} + +interface UseCommunityDashboardParams { + dashboard: GnetDashboard; + datasourceUid: string; + datasourceType: string; + eventLocation: 'empty_dashboard' | 'suggested_dashboards_modal_community_tab'; + onShowMapping?: (context: MappingContext) => void; +} + +/** + * Handles the flow when a user selects a community dashboard: + * 1. Tracks analytics + * 2. Fetches full dashboard JSON with __inputs + * 3. Attempts auto-mapping of datasources + * 4. Either navigates directly or shows mapping form + */ +export async function onUseCommunityDashboard({ + dashboard, + datasourceUid, + datasourceType, + eventLocation, + onShowMapping, +}: UseCommunityDashboardParams): Promise { + // Track analytics + DashboardLibraryInteractions.itemClicked({ + contentKind: 'community_dashboard', + datasourceTypes: [datasourceType], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: 'datasource_page', + eventLocation, + }); + + try { + // Fetch full dashboard from Gcom, this is the JSON with __inputs + const fullDashboard = await fetchCommunityDashboard(dashboard.id); + const dashboardJson = fullDashboard.json; + + // Parse datasource requirements from __inputs + const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || []; + + // Parse constant inputs - these always need user review + const constantInputs = parseConstantInputs(dashboardJson.__inputs || []); + + // Try auto-mapping datasources + const mappingResult = tryAutoMapDatasources(dsInputs, datasourceUid); + + // Decide whether to show mapping form or navigate directly + // Show mapping form if: (a) there are unmapped datasources OR (b) there are constants + const needsMapping = mappingResult.unmappedInputs.length > 0 || constantInputs.length > 0; + + if (!needsMapping) { + // No mapping needed - all datasources auto-mapped, no constants + navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappingResult.mappings); + } else { + // Show mapping form for unmapped datasources and/or constants + if (onShowMapping) { + onShowMapping({ + dashboardName: dashboard.name, + dashboardJson, + unmappedInputs: mappingResult.unmappedInputs, + constantInputs, + existingMappings: mappingResult.mappings, + onInterpolateAndNavigate: (mappings) => + navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappings), + }); + } + } + } catch (err) { + console.error('Error loading community dashboard:', err); + // TODO: Show error notification + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts new file mode 100644 index 00000000000..30a7c5c0059 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts @@ -0,0 +1,26 @@ +import dashboardLibrary1 from 'img/dashboard-library/dashboard_library_1.jpg'; +import dashboardLibrary2 from 'img/dashboard-library/dashboard_library_2.jpg'; +import dashboardLibrary3 from 'img/dashboard-library/dashboard_library_3.jpg'; +import dashboardLibrary4 from 'img/dashboard-library/dashboard_library_4.jpg'; +import dashboardLibrary5 from 'img/dashboard-library/dashboard_library_5.jpg'; +import dashboardLibrary6 from 'img/dashboard-library/dashboard_library_6.jpg'; + +/** + * Collection of placeholder images for provisioned/plugin dashboards + */ +export const DASHBOARD_PLACEHOLDER_IMAGES = [ + dashboardLibrary1, + dashboardLibrary2, + dashboardLibrary3, + dashboardLibrary4, + dashboardLibrary5, + dashboardLibrary6, +]; + +/** + * Get a placeholder image URL for a provisioned dashboard by index. + * Cycles through available images if index exceeds collection size. + */ +export function getProvisionedDashboardImageUrl(index: number): string { + return DASHBOARD_PLACEHOLDER_IMAGES[index % DASHBOARD_PLACEHOLDER_IMAGES.length]; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index ae6452a1d99..3a688ea53dc 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -66,7 +66,7 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/NewDashboardWithDS') ), }, - { + (config.featureToggles.suggestedDashboards || config.featureToggles.dashboardLibrary) && { path: DASHBOARD_LIBRARY_ROUTES.Template, roles: () => contextSrv.evaluatePermission([AccessControlAction.DashboardsCreate]), pageClass: 'page-dashboard', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6c9cb73d689..63c36ebc16d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5661,6 +5661,61 @@ "validation-required": "Need a dashboard JSON model" } }, + "dashboard-library": { + "browse-grafana-com": "Browse Grafana.com", + "browse-plugins": "Browse plugins", + "card": { + "datasource-provided-badge": "Data source provided", + "details-tooltip": "Details", + "no-preview": "No preview available", + "use-dashboard-button": "Use dashboard", + "use-template-button": "Use template" + }, + "community-empty-title": "No community dashboards found", + "community-empty-title-with-datasource": "No {{datasourceType}} community dashboards found", + "community-error": "Failed to load community dashboards. Please try again.", + "community-error-title": "Error loading community dashboards", + "community-mapping-form": { + "auto-mapped_one": "{{count}} datasources were automatically configured:", + "auto-mapped_other": "{{count}} datasources were automatically configured:", + "back": "Back to dashboards", + "constants-title": "Dashboard Variables", + "datasources-title": "Datasource Configuration", + "description": "This dashboard requires datasource configuration. Select datasources for each input below.", + "preview": "Preview dashboard" + }, + "community-mapping-select-datasource": "Select a datasource", + "community-search-placeholder": "Search community dashboards...", + "community-search-placeholder-with-datasource": "Search {{datasourceType}} community dashboards...", + "dashboard-card": { + "details": { + "datasource": "Datasource", + "dependencies": "Dependencies", + "id": "ID", + "last-update": "Last Update", + "published-by": "Published By", + "view-on-grafana-com": "View on Grafana.com" + } + }, + "modal": { + "description": "Browse and select from data-source provided or community dashboards", + "tab-community": "Community", + "tab-datasource": "Data-source provided", + "title": "Suggested dashboards", + "title-mapping-with-name": "Configure datasources for {{dashboardName}}", + "title-with-datasource": "Suggested dashboards for your {{datasourceType}} datasource" + }, + "no-community-dashboards-datasource": "Try a different search term or browse dashboards for different datasource types on Grafana.com.", + "no-community-dashboards-search": "Try a different search term or browse more dashboards on Grafana.com.", + "no-provisioned-dashboards": "Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.", + "provisioned-empty-title": "No provisioned dashboards found", + "provisioned-empty-title-with-datasource": "No {{datasourceType}} provisioned dashboards found", + "retry": "Retry", + "suggested-dashboards-subtitle": "Browse and select from data-source provided or community dashboards", + "suggested-dashboards-title": "Build a dashboard using suggested options for your selected data source", + "suggested-dashboards-title-with-datasource": "Build a dashboard using suggested options for your {{datasourceType}} data source", + "view-all": "View all" + }, "dashboard-links": { "empty-state": { "button-title": "Add dashboard link", diff --git a/public/openapi3.json b/public/openapi3.json index f28b394ffb1..1a3473953d5 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -17838,7 +17838,7 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.", + "summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary or suggestedDashboards feature flags and is subject to change.", "tags": [ "dashboards" ] From f34f7579a2d031f0f2aa9c251b2f5dd19a8b615c Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Tue, 11 Nov 2025 09:42:27 +0000 Subject: [PATCH 135/209] Stars: Update star toolbar button to include name only in the label (#113678) --- .../app/features/stars/StarToolbarButton.tsx | 21 ++++++++++--------- public/locales/en-US/grafana.json | 6 ++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/public/app/features/stars/StarToolbarButton.tsx b/public/app/features/stars/StarToolbarButton.tsx index fe29cd9da0c..7a4b61afa0a 100644 --- a/public/app/features/stars/StarToolbarButton.tsx +++ b/public/app/features/stars/StarToolbarButton.tsx @@ -7,12 +7,10 @@ import { Icon, ToolbarButton } from '@grafana/ui'; import { useStarItem, useStarredItems } from './hooks'; const getStarTooltips = (title: string) => ({ - star: t('stars.mark-as-starred', 'Mark "{{title}}" as favorite', { - title, - }), - unstar: t('stars.unmark-as-starred', 'Unmark "{{title}}" as favorite', { - title, - }), + star: t('stars.mark-as-starred', 'Mark as favorite'), + starWithTitle: t('stars.mark-as-starred-with-title', 'Mark "{{title}}" as favorite', { title }), + unstar: t('stars.unmark-as-starred', 'Unmark as favorite'), + unstarWithTitle: t('stars.unmark-as-starred-with-title', 'Unmark "{{title}}" as favorite', { title }), }); type Props = { @@ -51,18 +49,21 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop return { name: 'star', type: 'default' } as const; })(); - const tooltip = (() => { + const tooltipAndLabel = (() => { if (isLoading) { - return undefined; + return {}; } - return isStarred ? tooltips.unstar : tooltips.star; + return isStarred + ? { tooltip: tooltips.unstar, label: tooltips.unstarWithTitle } + : { tooltip: tooltips.star, label: tooltips.starWithTitle }; })(); const icon = ; return ( Date: Tue, 11 Nov 2025 12:46:35 +0100 Subject: [PATCH 136/209] fix: detect circular references in GetDescendants (#113672) * fix: detect circular references in GetDescendants * chore: use map[string]bool and instantiate at the beginning of the function --- .../folder/folderimpl/unifiedstore.go | 29 +- .../folder/folderimpl/unifiedstore_test.go | 273 ++++++++++++++++++ 2 files changed, 297 insertions(+), 5 deletions(-) diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 4c76b219873..4e2aa02343e 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -439,7 +439,10 @@ func (ss *FolderUnifiedStoreImpl) GetDescendants(ctx context.Context, orgID int6 } descendantsMap := map[string]*folder.Folder{} - getDescendants(nodes, tree, ancestor_uid, descendantsMap) + err = getDescendants(nodes, tree, ancestor_uid, descendantsMap, nil) + if err != nil { + return nil, err + } descendants := []*folder.Folder{} for _, f := range descendantsMap { @@ -449,11 +452,27 @@ func (ss *FolderUnifiedStoreImpl) GetDescendants(ctx context.Context, orgID int6 return descendants, nil } -func getDescendants(nodes map[string]*folder.Folder, tree map[string]map[string]*folder.Folder, ancestor_uid string, descendantsMap map[string]*folder.Folder) { - for uid := range tree[ancestor_uid] { - descendantsMap[uid] = nodes[uid] - getDescendants(nodes, tree, uid, descendantsMap) +func getDescendants( + nodes map[string]*folder.Folder, + tree map[string]map[string]*folder.Folder, + ancestorUID string, + descendantsMap map[string]*folder.Folder, + seen map[string]bool, +) error { + if seen == nil { + seen = map[string]bool{} } + if seen[ancestorUID] { + return folder.ErrCircularReference.Errorf("circular reference detected at folder uid: %s", ancestorUID) + } + seen[ancestorUID] = true + for uid := range tree[ancestorUID] { + descendantsMap[uid] = nodes[uid] + if err := getDescendants(nodes, tree, uid, descendantsMap, seen); err != nil { + return err + } + } + return nil } func (ss *FolderUnifiedStoreImpl) CountFolderContent(ctx context.Context, orgID int64, ancestor_uid string) (folder.DescendantCounts, error) { diff --git a/pkg/services/folder/folderimpl/unifiedstore_test.go b/pkg/services/folder/folderimpl/unifiedstore_test.go index b31db85227e..6dc8379a1a8 100644 --- a/pkg/services/folder/folderimpl/unifiedstore_test.go +++ b/pkg/services/folder/folderimpl/unifiedstore_test.go @@ -765,6 +765,279 @@ func TestGetFolders(t *testing.T) { } } +func TestGetDescendants(t *testing.T) { + orgID := int64(1) + + type args struct { + ctx context.Context + orgID int64 + ancestorUID string + } + tests := []struct { + name string + args args + mock func(mockCli *client.MockK8sHandler) + want []*folder.Folder + wantErr bool + }{ + { + name: "should return all descendants in a tree structure", + args: args{ + ctx: context.Background(), + orgID: orgID, + ancestorUID: "root", + }, + mock: func(mockCli *client.MockK8sHandler) { + mockCli.On("List", mock.Anything, orgID, metav1.ListOptions{ + Limit: folderListLimit, + TypeMeta: metav1.TypeMeta{}, + }).Return(&unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "root", + "uid": "root", + }, + "spec": map[string]interface{}{ + "title": "Root", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "child1", + "uid": "child1", + "annotations": map[string]interface{}{"grafana.app/folder": "root"}, + }, + "spec": map[string]interface{}{ + "title": "Child1", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "child2", + "uid": "child2", + "annotations": map[string]interface{}{"grafana.app/folder": "child1"}, + }, + "spec": map[string]interface{}{ + "title": "Child2", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "child3", + "uid": "child3", + "annotations": map[string]interface{}{"grafana.app/folder": "root"}, + }, + "spec": map[string]interface{}{ + "title": "Child3", + }, + }, + }, + }, + }, nil).Once() + }, + want: []*folder.Folder{ + { + UID: "child1", + Title: "Child1", + OrgID: orgID, + }, + { + UID: "child2", + Title: "Child2", + OrgID: orgID, + }, + { + UID: "child3", + Title: "Child3", + OrgID: orgID, + }, + }, + wantErr: false, + }, + { + name: "should return empty list when ancestor has no descendants", + args: args{ + ctx: context.Background(), + orgID: orgID, + ancestorUID: "leaf", + }, + mock: func(mockCli *client.MockK8sHandler) { + mockCli.On("List", mock.Anything, orgID, metav1.ListOptions{ + Limit: folderListLimit, + TypeMeta: metav1.TypeMeta{}, + }).Return(&unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "leaf", + "uid": "leaf", + }, + "spec": map[string]interface{}{ + "title": "Leaf", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "other", + "uid": "other", + "annotations": map[string]interface{}{"grafana.app/folder": "parent"}, + }, + "spec": map[string]interface{}{ + "title": "Other", + }, + }, + }, + }, + }, nil).Once() + }, + want: []*folder.Folder{}, + wantErr: false, + }, + { + name: "should detect circular reference and return error", + args: args{ + ctx: context.Background(), + orgID: orgID, + ancestorUID: "a", + }, + mock: func(mockCli *client.MockK8sHandler) { + mockCli.On("List", mock.Anything, orgID, metav1.ListOptions{ + Limit: folderListLimit, + TypeMeta: metav1.TypeMeta{}, + }).Return(&unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "a", + "uid": "a", + "annotations": map[string]interface{}{"grafana.app/folder": "c"}, + }, + "spec": map[string]interface{}{ + "title": "A", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "b", + "uid": "b", + "annotations": map[string]interface{}{"grafana.app/folder": "a"}, + }, + "spec": map[string]interface{}{ + "title": "B", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "c", + "uid": "c", + "annotations": map[string]interface{}{"grafana.app/folder": "b"}, + }, + "spec": map[string]interface{}{ + "title": "C", + }, + }, + }, + }, + }, nil).Once() + }, + want: nil, + wantErr: true, + }, + { + name: "should detect self-referencing cycle and return error", + args: args{ + ctx: context.Background(), + orgID: orgID, + ancestorUID: "self", + }, + mock: func(mockCli *client.MockK8sHandler) { + mockCli.On("List", mock.Anything, orgID, metav1.ListOptions{ + Limit: folderListLimit, + TypeMeta: metav1.TypeMeta{}, + }).Return(&unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "self", + "uid": "self", + "annotations": map[string]interface{}{"grafana.app/folder": "child"}, + }, + "spec": map[string]interface{}{ + "title": "Self", + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "child", + "uid": "child", + "annotations": map[string]interface{}{"grafana.app/folder": "self"}, + }, + "spec": map[string]interface{}{ + "title": "Child", + }, + }, + }, + }, + }, nil).Once() + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCLI := new(client.MockK8sHandler) + tt.mock(mockCLI) + tracer := noop.NewTracerProvider().Tracer("TestGetDescendants") + ss := &FolderUnifiedStoreImpl{ + k8sclient: mockCLI, + userService: usertest.NewUserServiceFake(), + tracer: tracer, + } + got, err := ss.GetDescendants(tt.args.ctx, tt.args.orgID, tt.args.ancestorUID) + if tt.wantErr { + require.ErrorIs(t, err, folder.ErrCircularReference) + return + } + require.NoError(t, err) + require.Len(t, got, len(tt.want)) + + // Create a map for easier comparison since order may vary + gotMap := make(map[string]*folder.Folder) + for _, f := range got { + gotMap[f.UID] = f + } + require.Len(t, gotMap, len(tt.want)) + + for _, want := range tt.want { + gotFolder, exists := gotMap[want.UID] + require.True(t, exists, "Expected folder with UID %s not found", want.UID) + require.Equal(t, want.Title, gotFolder.Title) + require.Equal(t, want.OrgID, gotFolder.OrgID) + } + }) + } +} + func TestBuildFolderFullPaths(t *testing.T) { type args struct { f *folder.Folder From 562e7ba043944f59b8172d35434956b0f3b7c9ea Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 11 Nov 2025 13:47:21 +0000 Subject: [PATCH 137/209] Frontend Service: Ensure the favicon/appletouchicon/loadinglogo are using the CDN url (#113699) * ensure the favicon, appletouchicon and loadinglogo are using the CDN url * add nosec comments since we control the cdn url --- pkg/api/index.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/api/index.go b/pkg/api/index.go index a928aca581c..1f85f2b0dbe 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "html/template" "net/http" "strings" @@ -184,12 +185,12 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV NewGrafanaVersionExists: hs.grafanaUpdateChecker.UpdateAvailable(), AppName: setting.ApplicationName, AppNameBodyClass: "app-grafana", - FavIcon: "public/img/fav32.png", - AppleTouchIcon: "public/img/apple-touch-icon.png", + FavIcon: template.URL(assets.ContentDeliveryURL + "public/build/img/fav32.png"), // #nosec G203 + AppleTouchIcon: template.URL(assets.ContentDeliveryURL + "public/build/img/apple-touch-icon.png"), // #nosec G203 AppTitle: "Grafana", NavTree: navTree, Nonce: c.RequestNonce, - LoadingLogo: "public/img/grafana_icon.svg", + LoadingLogo: template.URL(assets.ContentDeliveryURL + "public/build/img/grafana_icon.svg"), // #nosec G203 IsDevelopmentEnv: hs.Cfg.Env == setting.Dev, Assets: assets, } From ee6c8a6e206849c82b866331bc3babd270be877f Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 11 Nov 2025 13:47:31 +0000 Subject: [PATCH 138/209] Storybook: Ensure panels have unique titles (#113703) ensure panels have unique titles --- eslint-suppressions.json | 5 -- .../PanelChrome/PanelChrome.story.tsx | 84 ++++++++++--------- 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7464e82bb3e..e06e54eec9e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -770,11 +770,6 @@ "count": 1 } }, - "packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "packages/grafana-ui/src/components/PanelChrome/PanelContext.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx index e312691bc4c..1b6342d035a 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx @@ -32,8 +32,6 @@ const meta: Meta = { docs: { page: mdx, }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, }; @@ -46,7 +44,7 @@ function getContentStyle(): CSSProperties { }; } -function renderPanel(name: string, overrides?: Partial) { +function renderPanel(content: string, overrides?: Partial) { const props: PanelChromeProps = { width: PANEL_WIDTH, height: PANEL_HEIGHT, @@ -60,7 +58,7 @@ function renderPanel(name: string, overrides?: Partial) { return ( {(innerWidth: number, innerHeight: number) => { - return
{name}
; + return
{content}
; }}
); @@ -133,70 +131,74 @@ export const Examples = () => {
- {renderPanel('Has statusMessage', { - title: 'Default title', + {renderPanel('Content', { + title: 'Panel with statusMessage', statusMessage: 'Error text', statusMessageOnClick: action('ErrorIndicator: onClick fired'), })} - {renderPanel('No padding, has statusMessage', { + {renderPanel('Content', { padding: 'none', - title: 'Default title', + title: 'Panel with statusMessage and no padding', statusMessage: 'Error text', statusMessageOnClick: action('ErrorIndicator: onClick fired'), })} - {renderPanel('No title, loadingState is Error, no statusMessage', { + {renderPanel('Content', { loadingState: LoadingState.Error, + title: 'No title, loadingState is Error, no statusMessage', })} - {renderPanel('loadingState is Streaming', { - title: 'Default title', + {renderPanel('Content', { + title: 'loadingState is Streaming', loadingState: LoadingState.Streaming, })} - {renderPanel('loadingState is Loading', { - title: 'Default title', + {renderPanel('Content', { + title: 'loadingState is Loading', loadingState: LoadingState.Loading, })} {renderPanel('Default panel: no non-required props')} - {renderPanel('No padding', { + {renderPanel('Content', { padding: 'none', + title: 'No padding', })} - {renderPanel('Very long title', { + {renderPanel('Content', { title: 'Very long title that should get ellipsis when there is no more space', })} - {renderPanel('No title, streaming loadingState', { + {renderPanel('Content', { + title: 'No title, streaming loadingState', loadingState: LoadingState.Streaming, })} - {renderPanel('Error status, menu', { - title: 'Default title', + {renderPanel('Content', { + title: 'Error status, menu', menu, statusMessage: 'Error text', statusMessageOnClick: action('ErrorIndicator: onClick fired'), })} - {renderPanel('No padding; has statusMessage, menu', { + {renderPanel('Content', { padding: 'none', - title: 'Default title', + title: 'No padding; has statusMessage, menu', menu, statusMessage: 'Error text', statusMessageOnClick: action('ErrorIndicator: onClick fired'), })} - {renderPanel('No title, loadingState is Error, no statusMessage, menu', { + {renderPanel('Content', { + title: 'No title, loadingState is Error, no statusMessage, menu', menu, loadingState: LoadingState.Error, })} - {renderPanel('loadingState is Streaming, menu', { - title: 'Default title', + {renderPanel('Content', { + title: 'loadingState is Streaming, menu', menu, loadingState: LoadingState.Streaming, })} - {renderPanel('loadingState is Loading, menu', { - title: 'Default title', + {renderPanel('Content', { + title: 'loadingState is Loading, menu', menu, loadingState: LoadingState.Loading, })} - {renderPanel('No padding, deprecated loading indicator', { + {renderPanel('Content', { padding: 'none', - title: 'Default title', + title: 'No padding, deprecated loading indicator', leftItems: [ { />, ], })} - {renderPanel('Display mode = transparent', { - title: 'Default title', + {renderPanel('Content', { + title: 'Display mode = transparent', displayMode: 'transparent', menu, })} - {renderPanel('Actions with button no menu', { + {renderPanel('Content', { title: 'Actions with button no menu', actions: ( ), })} - {renderPanel('Panel with two actions', { - title: 'I have two buttons', + {renderPanel('Content', { + title: 'Panel with two actions', actions: [ -
+ + + + + )} -
+ ); } } diff --git a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx index 6004fff49e6..d8708ddb470 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx @@ -3,6 +3,8 @@ import { t, Trans } from '@grafana/i18n'; import { InlineSwitch } from '../../components/Switch/Switch'; import { InlineField } from '../Forms/InlineField'; +import { Box } from '../Layout/Box/Box'; +import { Stack } from '../Layout/Stack/Stack'; export interface Props extends Pick, 'options' | 'onOptionsChange'> {} @@ -20,30 +22,32 @@ export function SecureSocksProxySettings({

Secure Socks Proxy

-
-
-
- - - onOptionsChange({ - ...options, - jsonData: { ...options.jsonData, enableSecureSocksProxy: event!.currentTarget.checked }, - }) - } - /> - -
-
-
+ + + + + + + onOptionsChange({ + ...options, + jsonData: { ...options.jsonData, enableSecureSocksProxy: event!.currentTarget.checked }, + }) + } + /> + + + + +
); } diff --git a/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx index b5a1537edd8..fbd20c5c533 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx @@ -1,4 +1,3 @@ -import { css, cx } from '@emotion/css'; import * as React from 'react'; import { KeyValue } from '@grafana/data'; @@ -6,6 +5,8 @@ import { t, Trans } from '@grafana/i18n'; import { FormField } from '../FormField/FormField'; import { Icon } from '../Icon/Icon'; +import { Box } from '../Layout/Box/Box'; +import { Stack } from '../Layout/Stack/Stack'; import { Tooltip } from '../Tooltip/Tooltip'; import { CertificationKey } from './CertificationKey'; @@ -53,29 +54,24 @@ export const TLSAuthSettings = ({ dataSourceConfig, onChange }: HttpSettingsBase const privateKeyBeginsWith = '-----BEGIN RSA PRIVATE KEY-----'; return ( -
-
-
- TLS/SSL Auth Details -
- - - -
+ + + +
+ TLS/SSL Auth Details +
+ + + +
+
{dataSourceConfig.jsonData.tlsAuthWithCACert && ( -
- -
+ + + + + )}
-
+ ); }; diff --git a/packages/grafana-ui/src/components/Forms/InlineFieldRow.mdx b/packages/grafana-ui/src/components/Forms/InlineFieldRow.mdx index 737bd59943f..31013010033 100644 --- a/packages/grafana-ui/src/components/Forms/InlineFieldRow.mdx +++ b/packages/grafana-ui/src/components/Forms/InlineFieldRow.mdx @@ -1,6 +1,6 @@ # InlineFieldRow -Used to align multiple `InlineField` components in one row. The row will wrap if the width of the children exceeds its own. Equivalent to the div with `gf-form-inline` class name. +Used to align multiple `InlineField` components in one row. The row will wrap if the width of the children exceeds its own. Multiple `InlineFieldRow`s vertically stack on each other. ### Usage From 96f34f8f56c562e2fb2678f356773d73edce931b Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 11 Nov 2025 16:33:46 +0000 Subject: [PATCH 140/209] EchoSrv: Enable auto route tracking for Azure App Insights (#113354) * Echo: Enable auto route tracking with Azure App Insights * Add server config option to disable auto route tracking * fix not using minified js --- conf/defaults.ini | 4 + conf/sample.ini | 4 + devenv/frontend-service/configs/nginx.conf | 2 +- .../setup-grafana/configure-grafana/_index.md | 6 +- packages/grafana-data/src/types/config.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + pkg/api/dtos/frontend_settings.go | 43 +++++---- pkg/api/frontendsettings.go | 95 ++++++++++--------- pkg/services/frontend/frontend_settings.go | 5 +- pkg/services/frontend/index.go | 57 +++++------ pkg/setting/setting.go | 18 ++-- .../analytics/ApplicationInsightsBackend.ts | 2 + public/app/core/services/echo/init.ts | 1 + 13 files changed, 131 insertions(+), 108 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 7168a6d9eee..e25fb0c1715 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -340,6 +340,10 @@ application_insights_connection_string = # Optional. Specifies an Application Insights endpoint URL where the endpoint string is wrapped in backticks ``. application_insights_endpoint_url = +# Optional, defaults to true. Configure automatic route tracking for single page applications in Application Insights. +# See https://learn.microsoft.com/en-us/azure/azure-monitor/app/application-insights-faq#is-there-a-way-to-see-fewer-events-per-transaction-when-i-use-the-application-insights-javascript-sdk +application_insights_auto_route_tracking = true + # Controls if the UI contains any links to user feedback forms feedback_links_enabled = true diff --git a/conf/sample.ini b/conf/sample.ini index 78a77294a99..ed0233c1a29 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -333,6 +333,10 @@ # Optional. Specifies an Application Insights endpoint URL where the endpoint string is wrapped in backticks ``. ;application_insights_endpoint_url = +# Optional, defaults to true. Configure automatic route tracking for single page applications in Application Insights. +# See https://learn.microsoft.com/en-us/azure/azure-monitor/app/application-insights-faq#is-there-a-way-to-see-fewer-events-per-transaction-when-i-use-the-application-insights-javascript-sdk +;application_insights_auto_route_tracking = + # Controls if the UI contains any links to user feedback forms ;feedback_links_enabled = true diff --git a/devenv/frontend-service/configs/nginx.conf b/devenv/frontend-service/configs/nginx.conf index bb136f695aa..f052bb52d17 100644 --- a/devenv/frontend-service/configs/nginx.conf +++ b/devenv/frontend-service/configs/nginx.conf @@ -30,7 +30,7 @@ server { server_name _; otel_trace on; - otel_trace_context inject; + otel_trace_context propagate; otel_span_name "$request_method Request"; location ~ ^/-/down/?$ { diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 1062414d287..01b8472f681 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -657,12 +657,16 @@ If you want to track Grafana usage via Azure Application Insights, then specify Optionally, use this option to override the default endpoint address for Application Insights data collecting. For details, refer to the [Azure documentation](https://docs.microsoft.com/en-us/azure/azure-monitor/app/custom-endpoints?tabs=js). -
+#### `application_insights_auto_route_tracking` + +Optionally, use this to configure `enableAutoRouteTracking` in Azure Application Insights. Defaults to `true`. For more details, refer to the [Azure documentation](https://learn.microsoft.com/en-us/azure/azure-monitor/app/application-insights-faq#is-there-a-way-to-see-fewer-events-per-transaction-when-i-use-the-application-insights-javascript-sdk) #### `feedback_links_enabled` Set to `false` to remove all feedback links from the UI. Default is `true`. +
+ ### `[security]` #### `disable_initial_admin_creation` diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 6d7be601af6..376229c83f4 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -291,6 +291,7 @@ export interface GrafanaConfig { rudderstackIntegrationsUrl: string; applicationInsightsConnectionString: string; applicationInsightsEndpointUrl: string; + applicationInsightsAutoRouteTracking: boolean; analyticsConsoleReporting: boolean; rendererAvailable: boolean; rendererVersion: string; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 41211ec9abb..d2911bf7770 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -205,6 +205,7 @@ export class GrafanaBootConfig { }; applicationInsightsConnectionString?: string; applicationInsightsEndpointUrl?: string; + applicationInsightsAutoRouteTracking?: boolean; recordedQueries = { enabled: true, }; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index f846e25795c..4d412a0e271 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -208,27 +208,28 @@ type FrontendSettingsDTO struct { DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"` PanelSeriesLimit int `json:"panelSeriesLimit"` - FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` - ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` - ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` - DisableLoginForm bool `json:"disableLoginForm"` - DisableUserSignUp bool `json:"disableUserSignUp"` - LoginHint string `json:"loginHint"` - PasswordHint string `json:"passwordHint"` - ExternalUserMngInfo string `json:"externalUserMngInfo"` - ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` - ExternalUserMngLinkName string `json:"externalUserMngLinkName"` - ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"` - ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"` - ViewersCanEdit bool `json:"viewersCanEdit"` - DisableSanitizeHtml bool `json:"disableSanitizeHtml"` - TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` - CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` - EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"` - PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"` - PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"` - ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"` - ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"` + FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` + ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` + ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` + ApplicationInsightsAutoRouteTracking bool `json:"applicationInsightsAutoRouteTracking"` + DisableLoginForm bool `json:"disableLoginForm"` + DisableUserSignUp bool `json:"disableUserSignUp"` + LoginHint string `json:"loginHint"` + PasswordHint string `json:"passwordHint"` + ExternalUserMngInfo string `json:"externalUserMngInfo"` + ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` + ExternalUserMngLinkName string `json:"externalUserMngLinkName"` + ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"` + ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"` + ViewersCanEdit bool `json:"viewersCanEdit"` + DisableSanitizeHtml bool `json:"disableSanitizeHtml"` + TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` + CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` + EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"` + PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"` + PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"` + ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"` + ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"` Auth FrontendSettingsAuthDTO `json:"auth"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index f3401ca2180..2895fd14cf7 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -197,53 +197,54 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro featureToggles["topnav"] = true frontendSettings := &dtos.FrontendSettingsDTO{ - DefaultDatasource: defaultDS, - Datasources: dataSources, - MinRefreshInterval: hs.Cfg.MinRefreshInterval, - Panels: panels, - Apps: apps, - AppUrl: hs.Cfg.AppURL, - AppSubUrl: hs.Cfg.AppSubURL, - AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, - AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled, - LdapEnabled: hs.Cfg.LDAPAuthEnabled, - JwtHeaderName: hs.Cfg.JWTAuth.HeaderName, - JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin, - LiveEnabled: hs.Cfg.LiveMaxConnections != 0, - LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit, - AutoAssignOrg: hs.Cfg.AutoAssignOrg, - VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled, - SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled, - AzureAuthEnabled: hs.Cfg.AzureAuthEnabled, - RbacEnabled: true, - ExploreEnabled: hs.Cfg.ExploreEnabled, - HelpEnabled: hs.Cfg.HelpEnabled, - ProfileEnabled: hs.Cfg.ProfileEnabled, - NewsFeedEnabled: hs.Cfg.NewsFeedEnabled, - QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled, - GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID, - GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID, - GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews, - RudderstackWriteKey: hs.Cfg.RudderstackWriteKey, - RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL, - RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL, - RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL, - RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL, - AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting, - DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics, - PanelSeriesLimit: hs.Cfg.PanelSeriesLimit, - FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled, - ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString, - ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl, - DisableLoginForm: hs.Cfg.DisableLoginForm, - DisableUserSignUp: !hs.Cfg.AllowUserSignUp, - LoginHint: hs.Cfg.LoginHint, - PasswordHint: hs.Cfg.PasswordHint, - ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo, - ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl, - ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName, - ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics, - ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams, + DefaultDatasource: defaultDS, + Datasources: dataSources, + MinRefreshInterval: hs.Cfg.MinRefreshInterval, + Panels: panels, + Apps: apps, + AppUrl: hs.Cfg.AppURL, + AppSubUrl: hs.Cfg.AppSubURL, + AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, + AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled, + LdapEnabled: hs.Cfg.LDAPAuthEnabled, + JwtHeaderName: hs.Cfg.JWTAuth.HeaderName, + JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin, + LiveEnabled: hs.Cfg.LiveMaxConnections != 0, + LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit, + AutoAssignOrg: hs.Cfg.AutoAssignOrg, + VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled, + SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled, + AzureAuthEnabled: hs.Cfg.AzureAuthEnabled, + RbacEnabled: true, + ExploreEnabled: hs.Cfg.ExploreEnabled, + HelpEnabled: hs.Cfg.HelpEnabled, + ProfileEnabled: hs.Cfg.ProfileEnabled, + NewsFeedEnabled: hs.Cfg.NewsFeedEnabled, + QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled, + GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID, + GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID, + GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews, + RudderstackWriteKey: hs.Cfg.RudderstackWriteKey, + RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL, + RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL, + RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL, + RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL, + AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting, + DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics, + PanelSeriesLimit: hs.Cfg.PanelSeriesLimit, + FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled, + ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString, + ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl, + ApplicationInsightsAutoRouteTracking: hs.Cfg.ApplicationInsightsAutoRouteTracking, + DisableLoginForm: hs.Cfg.DisableLoginForm, + DisableUserSignUp: !hs.Cfg.AllowUserSignUp, + LoginHint: hs.Cfg.LoginHint, + PasswordHint: hs.Cfg.PasswordHint, + ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo, + ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl, + ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName, + ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics, + ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams, //nolint:staticcheck // ViewersCanEdit is deprecated but still used for backward compatibility ViewersCanEdit: hs.Cfg.ViewersCanEdit, DisableSanitizeHtml: hs.Cfg.DisableSanitizeHtml, diff --git a/pkg/services/frontend/frontend_settings.go b/pkg/services/frontend/frontend_settings.go index 9ffdb3dbad0..f6cf9855b2d 100644 --- a/pkg/services/frontend/frontend_settings.go +++ b/pkg/services/frontend/frontend_settings.go @@ -38,8 +38,9 @@ type FSFrontendSettings struct { AnalyticsConsoleReporting bool `json:"analyticsConsoleReporting,omitempty"` GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent,omitempty"` - ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString,omitempty"` - ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl,omitempty"` + ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString,omitempty"` + ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl,omitempty"` + ApplicationInsightsAutoRouteTracking bool `json:"applicationInsightsAutoRouteTracking,omitempty"` TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled,omitempty"` CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled,omitempty"` diff --git a/pkg/services/frontend/index.go b/pkg/services/frontend/index.go index ec70d83468a..22d04234a36 100644 --- a/pkg/services/frontend/index.go +++ b/pkg/services/frontend/index.go @@ -67,34 +67,35 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets, li // subset of frontend settings needed for the login page // TODO what about enterprise settings here? frontendSettings := FSFrontendSettings{ - AnalyticsConsoleReporting: cfg.FrontendAnalyticsConsoleReporting, - AnonymousEnabled: cfg.Anonymous.Enabled, - ApplicationInsightsConnectionString: cfg.ApplicationInsightsConnectionString, - ApplicationInsightsEndpointUrl: cfg.ApplicationInsightsEndpointUrl, - AuthProxyEnabled: cfg.AuthProxy.Enabled, - AutoAssignOrg: cfg.AutoAssignOrg, - CSPReportOnlyEnabled: cfg.CSPReportOnlyEnabled, - DisableLoginForm: cfg.DisableLoginForm, - DisableUserSignUp: !cfg.AllowUserSignUp, - GoogleAnalytics4Id: cfg.GoogleAnalytics4ID, - GoogleAnalytics4SendManualPageViews: cfg.GoogleAnalytics4SendManualPageViews, - GoogleAnalyticsId: cfg.GoogleAnalyticsID, - GrafanaJavascriptAgent: cfg.GrafanaJavascriptAgent, - Http2Enabled: cfg.Protocol == setting.HTTP2Scheme, - JwtHeaderName: cfg.JWTAuth.HeaderName, - JwtUrlLogin: cfg.JWTAuth.URLLogin, - LdapEnabled: cfg.LDAPAuthEnabled, - LoginHint: cfg.LoginHint, - PasswordHint: cfg.PasswordHint, - ReportingStaticContext: cfg.ReportingStaticContext, - RudderstackConfigUrl: cfg.RudderstackConfigURL, - RudderstackDataPlaneUrl: cfg.RudderstackDataPlaneURL, - RudderstackIntegrationsUrl: cfg.RudderstackIntegrationsURL, - RudderstackSdkUrl: cfg.RudderstackSDKURL, - RudderstackWriteKey: cfg.RudderstackWriteKey, - TrustedTypesDefaultPolicyEnabled: (cfg.CSPEnabled && strings.Contains(cfg.CSPTemplate, "require-trusted-types-for")) || (cfg.CSPReportOnlyEnabled && strings.Contains(cfg.CSPReportOnlyTemplate, "require-trusted-types-for")), - VerifyEmailEnabled: cfg.VerifyEmailEnabled, - BuildInfo: getBuildInfo(license, cfg), + AnalyticsConsoleReporting: cfg.FrontendAnalyticsConsoleReporting, + AnonymousEnabled: cfg.Anonymous.Enabled, + ApplicationInsightsConnectionString: cfg.ApplicationInsightsConnectionString, + ApplicationInsightsEndpointUrl: cfg.ApplicationInsightsEndpointUrl, + ApplicationInsightsAutoRouteTracking: cfg.ApplicationInsightsAutoRouteTracking, + AuthProxyEnabled: cfg.AuthProxy.Enabled, + AutoAssignOrg: cfg.AutoAssignOrg, + CSPReportOnlyEnabled: cfg.CSPReportOnlyEnabled, + DisableLoginForm: cfg.DisableLoginForm, + DisableUserSignUp: !cfg.AllowUserSignUp, + GoogleAnalytics4Id: cfg.GoogleAnalytics4ID, + GoogleAnalytics4SendManualPageViews: cfg.GoogleAnalytics4SendManualPageViews, + GoogleAnalyticsId: cfg.GoogleAnalyticsID, + GrafanaJavascriptAgent: cfg.GrafanaJavascriptAgent, + Http2Enabled: cfg.Protocol == setting.HTTP2Scheme, + JwtHeaderName: cfg.JWTAuth.HeaderName, + JwtUrlLogin: cfg.JWTAuth.URLLogin, + LdapEnabled: cfg.LDAPAuthEnabled, + LoginHint: cfg.LoginHint, + PasswordHint: cfg.PasswordHint, + ReportingStaticContext: cfg.ReportingStaticContext, + RudderstackConfigUrl: cfg.RudderstackConfigURL, + RudderstackDataPlaneUrl: cfg.RudderstackDataPlaneURL, + RudderstackIntegrationsUrl: cfg.RudderstackIntegrationsURL, + RudderstackSdkUrl: cfg.RudderstackSDKURL, + RudderstackWriteKey: cfg.RudderstackWriteKey, + TrustedTypesDefaultPolicyEnabled: (cfg.CSPEnabled && strings.Contains(cfg.CSPTemplate, "require-trusted-types-for")) || (cfg.CSPReportOnlyEnabled && strings.Contains(cfg.CSPReportOnlyTemplate, "require-trusted-types-for")), + VerifyEmailEnabled: cfg.VerifyEmailEnabled, + BuildInfo: getBuildInfo(license, cfg), } return &IndexProvider{ diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index fa671ac93ca..c53feab2c76 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -390,14 +390,15 @@ type Cfg struct { LocalFileSystemAvailable bool // Analytics - CheckForGrafanaUpdates bool - CheckForPluginUpdates bool - ReportingDistributor string - ReportingEnabled bool - ApplicationInsightsConnectionString string - ApplicationInsightsEndpointUrl string - FeedbackLinksEnabled bool - ReportingStaticContext map[string]string + CheckForGrafanaUpdates bool + CheckForPluginUpdates bool + ReportingDistributor string + ReportingEnabled bool + ApplicationInsightsConnectionString string + ApplicationInsightsEndpointUrl string + ApplicationInsightsAutoRouteTracking bool + FeedbackLinksEnabled bool + ReportingStaticContext map[string]string // Frontend analytics GoogleAnalyticsID string @@ -1273,6 +1274,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.ApplicationInsightsConnectionString = analytics.Key("application_insights_connection_string").String() cfg.ApplicationInsightsEndpointUrl = analytics.Key("application_insights_endpoint_url").String() + cfg.ApplicationInsightsAutoRouteTracking = analytics.Key("application_insights_connection_string").MustBool(true) cfg.FeedbackLinksEnabled = analytics.Key("feedback_links_enabled").MustBool(true) // parse reporting static context string of key=value, key=value pairs into an object diff --git a/public/app/core/services/echo/backends/analytics/ApplicationInsightsBackend.ts b/public/app/core/services/echo/backends/analytics/ApplicationInsightsBackend.ts index 78db86015cd..07c89caf553 100644 --- a/public/app/core/services/echo/backends/analytics/ApplicationInsightsBackend.ts +++ b/public/app/core/services/echo/backends/analytics/ApplicationInsightsBackend.ts @@ -23,6 +23,7 @@ declare global { export interface ApplicationInsightsBackendOptions { connectionString: string; endpointUrl?: string; + autoRouteTracking?: boolean; } export class ApplicationInsightsBackend implements EchoBackend { @@ -33,6 +34,7 @@ export class ApplicationInsightsBackend implements EchoBackend Date: Tue, 11 Nov 2025 16:40:23 +0000 Subject: [PATCH 141/209] Alerting: Fix label value dropdown suggestions in alert rule editor (#113702) --- .../components/rule-editor/labels/LabelsField.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index abde3d3a4f7..b1571a2a6a5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -262,13 +262,13 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP selectedKey ); - const values = useMemo(() => { - return getValuesForLabel(selectedKey); - }, [selectedKey, getValuesForLabel]); - return ( {fields.map((field, index) => { + // Get the values for this specific row's key directly without memoization + const currentKey = labelsInSubform[index]?.key || ''; + const valuesForCurrentKey = getValuesForLabel(currentKey); + return (
{ if (newValue) { From 559dab8b1b676fa692efa0c66dcdb0542f6a547f Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Tue, 11 Nov 2025 11:53:36 -0500 Subject: [PATCH 142/209] Alerting: Fix error when updating Alertmanager config with autogenerated receivers (#113710) If an alert rule with an invalid receiver is created it breaks the entire alertmanager configuration rather than preventing the save. This fixes the issue by erroring on save and apply, and logging invalid receivers only when applying the config after an update. Introduced in #111838 --- pkg/services/ngalert/notifier/alertmanager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index dc2f63fb0ec..05e42a66b09 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -233,7 +233,7 @@ func (am *alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.P } err = am.Store.SaveAlertmanagerConfigurationWithCallback(ctx, cmd, func() error { - _, err = am.applyConfig(ctx, cfg, LogInvalidReceivers) // fail if the autogen config is invalid + _, err = am.applyConfig(ctx, cfg, ErrorOnInvalidReceivers) // fail if the autogen config is invalid return err }) if err != nil { @@ -259,7 +259,7 @@ func (am *alertmanager) ApplyConfig(ctx context.Context, dbCfg *ngmodels.AlertCo // Since we will now update last_applied when autogen changes even if the user-created config remains the same. // To fix this however, the local alertmanager needs to be able to tell the difference between user-created and // autogen config, which may introduce cross-cutting complexity. - configChanged, err := am.applyConfig(ctx, cfg, ErrorOnInvalidReceivers) + configChanged, err := am.applyConfig(ctx, cfg, LogInvalidReceivers) if err != nil { outerErr = fmt.Errorf("unable to apply configuration: %w", err) return From d8363bdfcf02190ed2ba490b85caa7453db827aa Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Tue, 11 Nov 2025 13:02:42 -0500 Subject: [PATCH 143/209] SaveProvisionedDashboardForm: When comment is edited, enable save button (#113686) SaveProvisionedDashboardForm: When comment is edit, enable save button --- .../SaveProvisionedDashboardForm.test.tsx | 32 +++++++++++++++++++ .../SaveProvisionedDashboardForm.tsx | 13 ++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index f74f7ddd949..bca9ca1939c 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -413,4 +413,36 @@ describe('SaveProvisionedDashboardForm', () => { // Branch field is not shown expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument(); }); + + it('enables save button when only the comment changes', async () => { + const { user } = setup({ + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + k8s: { name: 'test-dashboard' }, + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: false, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({}), + setManager: jest.fn(), + } as unknown as DashboardScene, + }); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + const saveButton = screen.getByRole('button', { name: /save/i }); + + expect(saveButton).toBeDisabled(); + + await user.type(commentInput, 'Comment-only change'); + + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + }); }); diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index a807ada4911..effa2e5c0e7 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -55,7 +55,16 @@ export function SaveProvisionedDashboardForm({ const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path); const methods = useForm({ defaultValues }); - const { handleSubmit, watch, control, reset, register } = methods; + const { + handleSubmit, + watch, + control, + reset, + register, + formState: { dirtyFields }, + } = methods; + // button enabled if form comment is dirty or dashboard state is dirty + const isDirtyState = Boolean(dirtyFields.comment) || isDirty; const [workflow, ref, path] = watch(['workflow', 'ref', 'path']); // Update the form if default values change @@ -282,7 +291,7 @@ export function SaveProvisionedDashboardForm({ -
-
+
{/* Last Ref */} Last Ref: @@ -92,7 +96,7 @@ export function RepositoryPullStatusCard({ repo }: { repo: Repository }) { ); } -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { spanTwo: css({ gridColumn: 'span 2', @@ -101,6 +105,7 @@ const getStyles = () => { gridColumn: '1 / -1', display: 'grid', gridTemplateColumns: 'subgrid', + gap: theme.spacing(1), }), historicalDataOverlay: css({ opacity: 0.6, diff --git a/public/app/features/provisioning/Shared/MessageList.tsx b/public/app/features/provisioning/Shared/MessageList.tsx index 2d7f0f15f2f..1dd70ff129d 100644 --- a/public/app/features/provisioning/Shared/MessageList.tsx +++ b/public/app/features/provisioning/Shared/MessageList.tsx @@ -1,7 +1,9 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; +import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Text, useStyles2 } from '@grafana/ui'; +import { Trans } from '@grafana/i18n'; +import { Box, Text, useStyles2 } from '@grafana/ui'; interface MessageListProps { messages: string[]; @@ -10,19 +12,87 @@ interface MessageListProps { export function MessageList({ messages, variant }: MessageListProps) { const styles = useStyles2(getStyles); + const [showFull, setShowFull] = useState(false); + const hasMultipleMessages = messages.length > 1; + + const handleExpand = () => { + setShowFull(true); + }; + + const handleCollapse = () => { + setShowFull(false); + }; + + const displayMessages = showFull ? messages : messages.slice(0, 1); return ( -
    - {messages.map((msg, index) => ( -
  • {variant ? {msg} : msg}
  • - ))} -
+ <> +
+
    + {displayMessages.map((msg, index) => ( +
  • + {variant ? {msg} : msg} + {!showFull && hasMultipleMessages && index === 0 && ( + <> + {' '} + + + Message truncated + + + + )} +
  • + ))} +
+
+ {showFull && hasMultipleMessages && ( + + + + )} + ); } const getStyles = (theme: GrafanaTheme2) => ({ + messageListWrapper: css({ + overflow: 'hidden', + maxHeight: '200px', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create('max-height', { + duration: theme.transitions.duration.standard, + easing: theme.transitions.easing.easeInOut, + }), + }, + }), + messageListWrapperExpanded: css({ + maxHeight: '9999px', + }), messageList: css({ margin: 0, paddingLeft: theme.spacing(3), + listStyle: 'disc', + }), + showMore: css({ + backgroundColor: 'transparent', + border: 'none', + padding: 0, + margin: 0, + textDecoration: 'underline', + cursor: 'pointer', + color: theme.colors.text.primary, + }), + showMoreInline: css({ + marginLeft: theme.spacing(0.5), }), }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a90eda76fe5..0a6e66ac0f7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11763,6 +11763,11 @@ "path-label": "Repository Path", "path-required": "Repository path is required" }, + "message": { + "show-less": "show less", + "show-more": "show more", + "truncated": "Message truncated" + }, "mode-options": { "folder": { "description": "After setup, a new Grafana folder will be created and synced with external storage. If any resources are present in external storage, they will be provisioned to this new folder. All new resources created in this folder will be stored and versioned in external storage.", From d54a2b33fe7dd269c61152c5ae1e6c0266d44ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Wed, 12 Nov 2025 13:40:11 +0100 Subject: [PATCH 153/209] Explore: Ensure data source is part of query object in internal data links (#112949) * Explore: Ensure data source is part of query object in internal data links Fixes #112945 * Fix tests * Fix tests * Update tests * Update tests * Add a safeguard for misconfigured links * Update logic to update uid when only type or name is defined --- .../grafana-data/src/utils/dataLinks.test.ts | 8 +- packages/grafana-data/src/utils/dataLinks.ts | 18 ++-- .../explore/TraceView/createSpanLink.test.ts | 93 ++++++++++--------- .../app/features/explore/utils/links.test.ts | 40 ++++---- 4 files changed, 83 insertions(+), 76 deletions(-) diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts index 86e01f4139c..379e678fd90 100644 --- a/packages/grafana-data/src/utils/dataLinks.test.ts +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -46,7 +46,7 @@ describe('mapInternalLinkToExplore', () => { expect(link).toEqual( expect.objectContaining({ title: 'dsName', - href: `/explore?left=${encodeURIComponent('{"datasource":"uid","queries":[{"query":"12344"}]}')}`, + href: `/explore?left=${encodeURIComponent('{"datasource":"uid","queries":[{"query":"12344","datasource":{"uid":"uid"}}]}')}`, onClick: undefined, interpolatedParams: { query: { @@ -95,7 +95,7 @@ describe('mapInternalLinkToExplore', () => { expect.objectContaining({ title: 'dsName', href: `/explore?left=${encodeURIComponent( - '{"datasource":"uid","queries":[{"query":"12344"}],"panelsState":{"trace":{"spanId":"abcdef"}}}' + '{"datasource":"uid","queries":[{"query":"12344","datasource":{"uid":"uid"}}],"panelsState":{"trace":{"spanId":"abcdef"}}}' )}`, onClick: undefined, }) @@ -144,6 +144,7 @@ describe('mapInternalLinkToExplore', () => { nested: { something: 'val1' }, num: 1, arr: ['val1', 'non var'], + datasource: { uid: 'uid' }, }; expect(decodeURIComponent(link.href)).toEqual( @@ -158,9 +159,6 @@ describe('mapInternalLinkToExplore', () => { ); expect(link.interpolatedParams?.query).toEqual({ - datasource: { - uid: 'uid', - }, ...query, }); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index ece36cf525a..a9b38e1df15 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -42,6 +42,15 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod typeof link.internal?.query === 'function' ? link.internal.query({ replaceVariables, scopedVars }) : internalLink.query; + + // datasource ref is optional in a query object, but Explore relies on it being defined for some + // functionalities, e.g., changing query filters directly from visualizations, so we need to put + // it here if it's missing. See also #112945 + if (query && typeof query === 'object' && !query.datasource?.uid && internalLink.datasourceUid) { + query.datasource = query.datasource || {}; + query.datasource.uid = internalLink.datasourceUid; + } + const interpolatedQuery = interpolateObject(query, scopedVars, replaceVariables); const interpolatedPanelsState = interpolateObject(link.internal?.panelsState, scopedVars, replaceVariables); const interpolatedCorrelationData = interpolateObject(link.meta?.correlationData, scopedVars, replaceVariables); @@ -49,14 +58,7 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod const interpolatedParams = interpolatedQuery ? { - query: { - ...interpolatedQuery, - // data source is defined in a separate property in DataLink, we ensure it's put back together after interpolation - datasource: { - ...interpolatedQuery.datasource, - uid: internalLink.datasourceUid, - }, - }, + query: interpolatedQuery, ...(range && { timeRange: range }), } : undefined; diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 4f80a23f5ea..fceb78b0b49 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -97,7 +97,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\", service_namespace=\\"namespace1\\"}","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\", service_namespace=\\"namespace1\\"}","refId":"","datasource":{"uid":"loki1_uid"}}]}' )}` ); }); @@ -123,7 +123,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":"","datasource":{"uid":"loki1_uid"}}]}' )}` ); }); @@ -149,7 +149,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":"","datasource":{"uid":"loki1_uid"}}]}' )}` ); }); @@ -177,7 +177,7 @@ describe('createSpanLinkFactory', () => { `/explore?left=${encodeURIComponent( `{"range":{"from":"${span.startTime / 1000 - 60000}","to":"${ span.startTime / 1000 + span.duration / 1000 + 60000 - }"},"datasource":"loki1_uid","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}]}` + }"},"datasource":"loki1_uid","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":"","datasource":{"uid":"loki1_uid"}}]}` )}` ); }); @@ -202,6 +202,7 @@ describe('createSpanLinkFactory', () => { { expr: '{cluster="cluster1", hostname="hostname1", service_namespace="namespace1"} | label_format log_line_contains_trace_id=`{{ contains "7946b05c2e2e4e5a" __line__ }}` | log_line_contains_trace_id="true" or trace_id="7946b05c2e2e4e5a" | label_format log_line_contains_span_id=`{{ contains "6605c7b08e715d6c" __line__ }}` | log_line_contains_span_id="true" or span_id="6605c7b08e715d6c"', refId: '', + datasource: { uid: 'loki1_uid' }, }, ], }) @@ -258,7 +259,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{service=\\"serviceName\\", pod=\\"podName\\"}","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{service=\\"serviceName\\", pod=\\"podName\\"}","refId":"","datasource":{"uid":"loki1_uid"}}]}' )}` ); }); @@ -288,7 +289,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","refId":"","datasource":{"uid":"loki1_uid"}}]}' )}` ); }); @@ -385,7 +386,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" service_namespace=\\"namespace1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" service_namespace=\\"namespace1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":"","datasource":{"uid":"splunkUID"}}]}' )}` ); }); @@ -409,7 +410,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"splunkUID"}}]}' )}` ); }); @@ -436,7 +437,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"splunkUID"}}]}' )}` ); }); @@ -466,7 +467,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"splunkUID","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":"","datasource":{"uid":"splunkUID"}}]}' )}` ); }); @@ -503,7 +504,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Metrics); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' + '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A","datasource":{"uid":"prom1Uid"}}]}' )}` ); }); @@ -552,7 +553,7 @@ describe('createSpanLinkFactory', () => { expect(namedLink!.title).toBe('Named Query'); expect(namedLink!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' + '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A","datasource":{"uid":"prom1Uid"}}]}' )}` ); @@ -562,7 +563,7 @@ describe('createSpanLinkFactory', () => { expect(defaultLink!.title).toBe('defaultQuery'); expect(defaultLink!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"histogram_quantile(0.5, sum(rate(traces_spanmetrics_latency_bucket{service=\\"test service\\"}[5m])) by (le))","refId":"A"}]}' + '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"histogram_quantile(0.5, sum(rate(traces_spanmetrics_latency_bucket{service=\\"test service\\"}[5m])) by (le))","refId":"A","datasource":{"uid":"prom1Uid"}}]}' )}` ); @@ -572,7 +573,7 @@ describe('createSpanLinkFactory', () => { expect(unnamedQuery!.title).toBeUndefined(); expect(unnamedQuery!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"no_name_here","refId":"A"}]}' + '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"no_name_here","refId":"A","datasource":{"uid":"prom1Uid"}}]}' )}` ); }); @@ -598,7 +599,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Metrics); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602633600000","to":"1602640801000"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A"}]}' + '{"range":{"from":"1602633600000","to":"1602640801000"},"datasource":"prom1Uid","queries":[{"expr":"customQuery","refId":"A","datasource":{"uid":"prom1Uid"}}]}' )}` ); }); @@ -636,7 +637,7 @@ describe('createSpanLinkFactory', () => { expect(links![0].type).toBe(SpanLinkType.Metrics); expect(links![0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"metric{job=\\"tns/app\\", pod=\\"sample-pod\\", job=\\"tns/app\\", pod=\\"sample-pod\\"}[5m]","refId":"A"}]}' + '{"range":{"from":"1602637080000","to":"1602637321000"},"datasource":"prom1Uid","queries":[{"expr":"metric{job=\\"tns/app\\", pod=\\"sample-pod\\", job=\\"tns/app\\", pod=\\"sample-pod\\"}[5m]","refId":"A","datasource":{"uid":"prom1Uid"}}]}' )}` ); }); @@ -726,7 +727,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(decodeURIComponent(linkDef!.href)).toContain( - `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\" AND service_namespace:\\"namespace1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` + `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\" AND service_namespace:\\"namespace1\\"","refId":"","metrics":[{"id":"1","type":"logs"}],"datasource":{"uid":"${searchUID}"}}]` ); }); @@ -765,7 +766,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\" AND service_namespace:\\"namespace1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\" AND service_namespace:\\"namespace1\\"","refId":"","metrics":[{"id":"1","type":"logs"}],"datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -793,7 +794,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(decodeURIComponent(linkDef!.href)).toBe( - `/explore?left={"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` + `/explore?left={"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","metrics":[{"id":"1","type":"logs"}],"datasource":{"uid":"${searchUID}"}}]}` ); }); @@ -819,7 +820,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}],"datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -849,7 +850,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}],"datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -882,7 +883,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}],"datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -916,7 +917,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(decodeURIComponent(linkDef!.href)).toContain( - `datasource":"${searchUID}","queries":[{"query":"cluster=\\"cluster1\\" AND hostname=\\"hostname1\\" AND service_namespace=\\"namespace1\\"","refId":""}]` + `datasource":"${searchUID}","queries":[{"query":"cluster=\\"cluster1\\" AND hostname=\\"hostname1\\" AND service_namespace=\\"namespace1\\"","refId":"","datasource":{"uid":"${searchUID}"}}]` ); }); @@ -955,7 +956,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster=\\"cluster1\\" AND hostname=\\"hostname1\\" AND service_namespace=\\"namespace1\\"","refId":""}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster=\\"cluster1\\" AND hostname=\\"hostname1\\" AND service_namespace=\\"namespace1\\"","refId":"","datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -983,7 +984,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(decodeURIComponent(linkDef!.href)).toBe( - `/explore?left={"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":""}]}` + `/explore?left={"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"searchUID","queries":[{"query":"\\"7946b05c2e2e4e5a\\"","refId":"","datasource":{"uid":"${searchUID}"}}]}` ); }); @@ -1009,7 +1010,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -1039,7 +1040,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"hostname=\\"hostname1\\" AND ip=\\"192.168.0.1\\"","refId":""}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"hostname=\\"hostname1\\" AND ip=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -1072,7 +1073,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"service=\\"serviceName\\" AND pod=\\"podName\\"","refId":""}]}` + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"${searchUID}","queries":[{"query":"service=\\"serviceName\\" AND pod=\\"podName\\"","refId":"","datasource":{"uid":"${searchUID}"}}]}` )}` ); }); @@ -1117,7 +1118,13 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(decodeURIComponent(linkDef!.href)).toContain( '"queries":' + - JSON.stringify([{ expr: '{service="serviceName", pod="podName"} |="serviceName" |="trace1"', refId: '' }]) + JSON.stringify([ + { + expr: '{service="serviceName", pod="podName"} |="serviceName" |="trace1"', + refId: '', + datasource: { uid: 'loki1_uid' }, + }, + ]) ); }); @@ -1179,7 +1186,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"cluster=\\"cluster1\\" OR hostname=\\"hostname1\\" OR service_namespace=\\"namespace1\\" or \\"7946b05c2e2e4e5a\\" or \\"6605c7b08e715d6c\\"","refId":""}]}' + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"cluster=\\"cluster1\\" OR hostname=\\"hostname1\\" OR service_namespace=\\"namespace1\\" or \\"7946b05c2e2e4e5a\\" or \\"6605c7b08e715d6c\\"","refId":"","datasource":{"uid":"${falconLogScaleUID}"}}]}` )}` ); }); @@ -1203,7 +1210,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"ip=\\"192.168.0.1\\"","refId":""}]}' + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"ip=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"${falconLogScaleUID}"}}]}` )}` ); }); @@ -1230,7 +1237,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"hostname=\\"hostname1\\" OR ip=\\"192.168.0.1\\"","refId":""}]}' + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"hostname=\\"hostname1\\" OR ip=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"${falconLogScaleUID}"}}]}` )}` ); }); @@ -1260,7 +1267,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"service=\\"serviceName\\" OR pod=\\"podName\\"","refId":""}]}' + `{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"falconLogScaleUID","queries":[{"lsql":"service=\\"serviceName\\" OR pod=\\"podName\\"","refId":"","datasource":{"uid":"${falconLogScaleUID}"}}]}` )}` ); }); @@ -1342,7 +1349,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Profiles); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service_namespace=\\"namespace1\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}' + '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service_namespace=\\"namespace1\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":"","datasource":{"uid":"pyroscopeUid"}}]}' )}` ); }); @@ -1372,7 +1379,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Profiles); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{ip=\\"192.168.0.1\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}' + '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{ip=\\"192.168.0.1\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":"","datasource":{"uid":"pyroscopeUid"}}]}' )}` ); }); @@ -1402,7 +1409,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Profiles); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}' + '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":"","datasource":{"uid":"pyroscopeUid"}}]}' )}` ); }); @@ -1461,7 +1468,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Profiles); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service=\\"serviceName\\", pod=\\"podName\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}' + '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service=\\"serviceName\\", pod=\\"podName\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":"","datasource":{"uid":"pyroscopeUid"}}]}' )}` ); }); @@ -1495,7 +1502,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Profiles); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}' + '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":"","datasource":{"uid":"pyroscopeUid"}}]}' )}` ); }); @@ -1543,7 +1550,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"victoriaLogsUID","queries":[{"expr":"cluster:=\\"cluster1\\" AND hostname:=\\"hostname1\\" AND service_namespace:=\\"namespace1\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"victoriaLogsUID","queries":[{"expr":"cluster:=\\"cluster1\\" AND hostname:=\\"hostname1\\" AND service_namespace:=\\"namespace1\\"","refId":"","datasource":{"uid":"victoriaLogsUID"}}]}' )}` ); }); @@ -1565,7 +1572,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"victoriaLogsUID","queries":[{"expr":"span_id:=\\"6605c7b08e715d6c\\" AND trace_id:=\\"7946b05c2e2e4e5a\\" AND cluster:=\\"cluster1\\" AND hostname:=\\"hostname1\\" AND service_namespace:=\\"namespace1\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"victoriaLogsUID","queries":[{"expr":"span_id:=\\"6605c7b08e715d6c\\" AND trace_id:=\\"7946b05c2e2e4e5a\\" AND cluster:=\\"cluster1\\" AND hostname:=\\"hostname1\\" AND service_namespace:=\\"namespace1\\"","refId":"","datasource":{"uid":"victoriaLogsUID"}}]}' )}` ); }); @@ -1595,7 +1602,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef?.type).toBe(SpanLinkType.Logs); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"victoriaLogsUID","queries":[{"expr":"hostname:=\\"hostname1\\" AND ip:=\\"192.168.0.1\\"","refId":""}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"victoriaLogsUID","queries":[{"expr":"hostname:=\\"hostname1\\" AND ip:=\\"192.168.0.1\\"","refId":"","datasource":{"uid":"victoriaLogsUID"}}]}' )}` ); }); @@ -1630,13 +1637,13 @@ describe('dataFrame links', () => { expect(links![0].type).toBe(SpanLinkType.Unknown); expect(links![1].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"message":"SELECT * FROM superhero WHERE name=host"}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"message":"SELECT * FROM superhero WHERE name=host","datasource":{"uid":"loki1_uid"}}]}' )}` ); expect(links![1].type).toBe(SpanLinkType.Unknown); expect(links![2].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"go_memstats_heap_inuse_bytes{job=\'host\'}"}]}' + '{"range":{"from":"1602637200000","to":"1602637201000"},"datasource":"loki1_uid","queries":[{"expr":"go_memstats_heap_inuse_bytes{job=\'host\'}","datasource":{"uid":"loki1_uid"}}]}' )}` ); expect(links![2].type).toBe(SpanLinkType.Unknown); diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 9c7f12afc10..349b9cb9875 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -100,7 +100,7 @@ describe('explore links utils', () => { expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1"}],"panelsState":{"trace":{"spanId":"abcdef"}}}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1","datasource":{"uid":"uid_1"}}],"panelsState":{"trace":{"spanId":"abcdef"}}}' )}` ); expect(links[0].title).toBe('test_ds'); @@ -115,7 +115,7 @@ describe('explore links utils', () => { expect(splitfn).toBeCalledWith({ datasourceUid: 'uid_1', - queries: [{ query: 'query_1' }], + queries: [{ query: 'query_1', datasource: { uid: 'uid_1' } }], range, panelsState: { trace: { @@ -178,7 +178,7 @@ describe('explore links utils', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -197,7 +197,7 @@ describe('explore links utils', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -225,7 +225,7 @@ describe('explore links utils', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -267,7 +267,7 @@ describe('explore links utils', () => { expect(links).toHaveLength(1); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -306,7 +306,7 @@ describe('explore links utils', () => { expect(links[0]).toHaveLength(1); expect(links[0][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}","datasource":{"uid":"uid_1"}}]}' )}` ); @@ -319,7 +319,7 @@ describe('explore links utils', () => { expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar env=prod}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar env=prod}","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -357,13 +357,13 @@ describe('explore links utils', () => { expect(links[0]).toHaveLength(1); expect(links[0][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=banana}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=banana}","datasource":{"uid":"uid_1"}}]}' )}` ); expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=apple}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=apple}","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -414,13 +414,13 @@ describe('explore links utils', () => { expect(links[0]).toHaveLength(1); expect(links[0][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=broccoli}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=broccoli}","datasource":{"uid":"uid_1"}}]}' )}` ); expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=cauliflower}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=cauliflower}","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -453,13 +453,13 @@ describe('explore links utils', () => { expect(links[0]).toHaveLength(1); expect(links[0][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo isOnline=true}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo isOnline=true}","datasource":{"uid":"uid_1"}}]}' )}` ); expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar isOnline=false}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar isOnline=false}","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -505,13 +505,13 @@ describe('explore links utils', () => { expect(links[0]).toHaveLength(1); expect(links[0][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform}","datasource":{"uid":"uid_1"}}]}' )}` ); expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform2}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform2}","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -552,20 +552,20 @@ describe('explore links utils', () => { expect(links[0]).toHaveLength(1); expect(links[0][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=loki env=prod}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=loki env=prod}","datasource":{"uid":"uid_1"}}]}' )}` ); expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=dev}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=dev}","datasource":{"uid":"uid_1"}}]}' )}` ); expect(links[2]).toHaveLength(1); expect(links[2][0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=prod}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=prod}","datasource":{"uid":"uid_1"}}]}' )}` ); }); @@ -655,7 +655,7 @@ describe('explore links utils', () => { expect(links[0].variables![0].value).toBe(''); expect(links[0].href).toBe( `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=test}"}]}' + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=test}","datasource":{"uid":"uid_1"}}]}' )}` ); }); From ed19a92a2a44ab506eabd42b90ac929f0367f201 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:48:20 +0000 Subject: [PATCH 154/209] Alerting: Fix to prevent regex escape on search input query (#113734) prevent regex escape on search input query --- .../alerting/unified/rule-list/filter/RulesFilter.v2.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx index 7bdc105496b..70ed636bb7a 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -162,6 +162,7 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP 'Search by name or enter filter query...' )} name="searchQuery" + escapeRegex={false} onChange={(next) => { trackRulesSearchInputCleared(field.value, next); field.onChange(next); From 4e050267c76aa1a856f06ff792ae25c469d08506 Mon Sep 17 00:00:00 2001 From: Gareth Date: Wed, 12 Nov 2025 22:33:17 +0900 Subject: [PATCH 155/209] decouple the opentsdb data source from core (#113588) * enable linting rules for opentsdb * remove core imports * update plugin.json * write backend standalone files * remove frontend core imports * add yarn workspace * remove core import for the plugin * update grafana dependency * update package.json * add jest config --- .golangci.yml | 2 + eslint.config.js | 1 + .../api/plugins/data/expectedListResp.json | 2 +- pkg/tsdb/opentsdb/opentsdb.go | 77 ++++++++++--------- pkg/tsdb/opentsdb/opentsdb_test.go | 2 +- pkg/tsdb/opentsdb/standalone/datasource.go | 28 +++++++ pkg/tsdb/opentsdb/standalone/main.go | 15 ++++ .../app/features/plugins/built_in_plugins.ts | 3 - .../plugins/datasource/opentsdb/CHANGELOG.md | 1 + .../plugins/datasource/opentsdb/datasource.ts | 3 +- .../plugins/datasource/opentsdb/jest-setup.js | 1 + .../datasource/opentsdb/jest.config.js | 3 + .../plugins/datasource/opentsdb/package.json | 52 +++++++++++++ .../plugins/datasource/opentsdb/plugin.json | 7 +- .../plugins/datasource/opentsdb/project.json | 9 +++ .../opentsdb/specs/datasource.test.ts | 26 +++++-- .../plugins/datasource/opentsdb/tsconfig.json | 8 ++ .../datasource/opentsdb/webpack.config.ts | 4 + yarn.lock | 42 +++++++++- 19 files changed, 235 insertions(+), 51 deletions(-) create mode 100644 pkg/tsdb/opentsdb/standalone/datasource.go create mode 100644 pkg/tsdb/opentsdb/standalone/main.go create mode 100644 public/app/plugins/datasource/opentsdb/CHANGELOG.md create mode 100644 public/app/plugins/datasource/opentsdb/jest-setup.js create mode 100644 public/app/plugins/datasource/opentsdb/jest.config.js create mode 100644 public/app/plugins/datasource/opentsdb/package.json create mode 100644 public/app/plugins/datasource/opentsdb/project.json create mode 100644 public/app/plugins/datasource/opentsdb/tsconfig.json create mode 100644 public/app/plugins/datasource/opentsdb/webpack.config.ts diff --git a/.golangci.yml b/.golangci.yml index 1fee8547139..33b2b9e8a19 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -105,6 +105,8 @@ linters: - '**/pkg/tsdb/graphite/**/*' - '**/pkg/tsdb/mysql/*' - '**/pkg/tsdb/mysql/**/*' + - '**/pkg/tsdb/opentsdb/*' + - '**/pkg/tsdb/opentsdb/**/*' - '**/pkg/tsdb/parca/*' - '**/pkg/tsdb/parca/**/*' - '**/pkg/tsdb/tempo/*' diff --git a/eslint.config.js b/eslint.config.js index 78a7a880597..2af812e1fe3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -434,6 +434,7 @@ module.exports = [ 'public/app/plugins/datasource/loki/**/*.{ts,tsx}', 'public/app/plugins/datasource/loki/**/*.{ts,tsx}', 'public/app/plugins/datasource/mysql/**/*.{ts,tsx}', + 'public/app/plugins/datasource/opentsdb/**/*.{ts,tsx}', 'public/app/plugins/datasource/parca/**/*.{ts,tsx}', 'public/app/plugins/datasource/tempo/**/*.{ts,tsx}', 'public/app/plugins/datasource/zipkin/**/*.{ts,tsx}', diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 78d56e06675..24f705eccd1 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -1580,7 +1580,7 @@ "keywords": null }, "dependencies": { - "grafanaDependency": "", + "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", "plugins": [], "extensions": { diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 92fe36c3d26..2ce2ecd8781 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -15,22 +15,19 @@ 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/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/httpclient" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" ) -var logger = log.New("tsdb.opentsdb") +var logger = backend.NewLoggerWith("tsdb.opentsdb") type Service struct { im instancemgmt.InstanceManager } -func ProvideService(httpClientProvider httpclient.Provider) *Service { +func ProvideService(httpClientProvider *httpclient.Provider) *Service { return &Service{ im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), } @@ -52,7 +49,22 @@ type JSONData struct { LookupLimit int32 `json:"lookupLimit"` } -func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc { +type QueryModel struct { + Metric string `json:"metric"` + Aggregator string `json:"aggregator"` + DownsampleInterval string `json:"downsampleInterval"` + DownsampleAggregator string `json:"downsampleAggregator"` + DownsampleFillPolicy string `json:"downsampleFillPolicy"` + DisableDownsampling bool `json:"disableDownsampling"` + Filters []any `json:"filters"` + Tags map[string]interface{} `json:"tags"` + ShouldComputeRate bool `json:"shouldComputeRate"` + IsCounter bool `json:"isCounter"` + CounterMax float64 `json:"counterMax"` + CounterResetValue float64 `json:"counterResetValue"` +} + +func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { opts, err := settings.HTTPClientOptions(ctx) if err != nil { @@ -102,10 +114,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) }, } - if setting.Env == setting.Dev { - logger.Debug("OpenTsdb request", "refId", query.RefID, "params", tsdbQuery) - } - httpReq, err := s.createRequest(ctx, logger, dsInfo, tsdbQuery) if err != nil { return nil, err @@ -274,47 +282,44 @@ func (s *Service) parseResponse(logger log.Logger, res *http.Response, refID str func (s *Service) buildMetric(query backend.DataQuery) map[string]any { metric := make(map[string]any) - model, err := simplejson.NewJson(query.JSON) - if err != nil { + var model QueryModel + if err := json.Unmarshal(query.JSON, &model); err != nil { return nil } // Setting metric and aggregator - metric["metric"] = model.Get("metric").MustString() - metric["aggregator"] = model.Get("aggregator").MustString() + metric["metric"] = model.Metric + metric["aggregator"] = model.Aggregator // Setting downsampling options - disableDownsampling := model.Get("disableDownsampling").MustBool() - if !disableDownsampling { - downsampleInterval := model.Get("downsampleInterval").MustString() + if !model.DisableDownsampling { + downsampleInterval := model.DownsampleInterval if downsampleInterval == "" { downsampleInterval = "1m" // default value for blank } - downsample := downsampleInterval + "-" + model.Get("downsampleAggregator").MustString() - if model.Get("downsampleFillPolicy").MustString() != "none" { - metric["downsample"] = downsample + "-" + model.Get("downsampleFillPolicy").MustString() + downsample := downsampleInterval + "-" + model.DownsampleAggregator + if model.DownsampleFillPolicy != "none" { + metric["downsample"] = downsample + "-" + model.DownsampleFillPolicy } else { metric["downsample"] = downsample } } // Setting rate options - if model.Get("shouldComputeRate").MustBool() { + if model.ShouldComputeRate { metric["rate"] = true rateOptions := make(map[string]any) - rateOptions["counter"] = model.Get("isCounter").MustBool() + rateOptions["counter"] = model.IsCounter - counterMax, counterMaxCheck := model.CheckGet("counterMax") - if counterMaxCheck { - rateOptions["counterMax"] = counterMax.MustFloat64() + if model.CounterMax != 0 { + rateOptions["counterMax"] = model.CounterMax } - resetValue, resetValueCheck := model.CheckGet("counterResetValue") - if resetValueCheck { - rateOptions["resetValue"] = resetValue.MustFloat64() + if model.CounterResetValue != 0 { + rateOptions["resetValue"] = model.CounterResetValue } - if !counterMaxCheck && (!resetValueCheck || resetValue.MustFloat64() == 0) { + if model.CounterMax == 0 && (model.CounterResetValue == 0) { rateOptions["dropResets"] = true } @@ -322,15 +327,13 @@ func (s *Service) buildMetric(query backend.DataQuery) map[string]any { } // Setting tags - tags, tagsCheck := model.CheckGet("tags") - if tagsCheck && len(tags.MustMap()) > 0 { - metric["tags"] = tags.MustMap() + if len(model.Tags) > 0 { + metric["tags"] = model.Tags } // Setting filters - filters, filtersCheck := model.CheckGet("filters") - if filtersCheck && len(filters.MustArray()) > 0 { - metric["filters"] = filters.MustArray() + if len(model.Filters) > 0 { + metric["filters"] = model.Filters } return metric diff --git a/pkg/tsdb/opentsdb/opentsdb_test.go b/pkg/tsdb/opentsdb/opentsdb_test.go index 11a13d7ddd5..da1950e0aad 100644 --- a/pkg/tsdb/opentsdb/opentsdb_test.go +++ b/pkg/tsdb/opentsdb/opentsdb_test.go @@ -12,8 +12,8 @@ import ( "github.com/google/go-cmp/cmp" "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/httpclient" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/tsdb/opentsdb/standalone/datasource.go b/pkg/tsdb/opentsdb/standalone/datasource.go new file mode 100644 index 00000000000..7ca315aabb5 --- /dev/null +++ b/pkg/tsdb/opentsdb/standalone/datasource.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + opentsdb "github.com/grafana/grafana/pkg/tsdb/opentsdb" +) + +var ( + _ backend.QueryDataHandler = (*Datasource)(nil) +) + +type Datasource struct { + Service *opentsdb.Service +} + +func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return &Datasource{ + Service: opentsdb.ProvideService(httpclient.NewProvider()), + }, nil +} + +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return d.Service.QueryData(ctx, req) +} diff --git a/pkg/tsdb/opentsdb/standalone/main.go b/pkg/tsdb/opentsdb/standalone/main.go new file mode 100644 index 00000000000..79d3a3cd518 --- /dev/null +++ b/pkg/tsdb/opentsdb/standalone/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "os" + + "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + if err := datasource.Manage("opentsdb", NewDatasource, datasource.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index e3d5cb8902f..b4334bedba9 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -6,8 +6,6 @@ const dashboardDSPlugin = async () => await import(/* webpackChunkName "dashboardDSPlugin" */ 'app/plugins/datasource/dashboard/module'); const elasticsearchPlugin = async () => await import(/* webpackChunkName: "elasticsearchPlugin" */ 'app/plugins/datasource/elasticsearch/module'); -const opentsdbPlugin = async () => - await import(/* webpackChunkName: "opentsdbPlugin" */ 'app/plugins/datasource/opentsdb/module'); const grafanaPlugin = async () => await import(/* webpackChunkName: "grafanaPlugin" */ 'app/plugins/datasource/grafana/module'); const influxdbPlugin = async () => @@ -78,7 +76,6 @@ const builtInPlugins: Record Promise=10.3.0-0" } } diff --git a/public/app/plugins/datasource/opentsdb/project.json b/public/app/plugins/datasource/opentsdb/project.json new file mode 100644 index 00000000000..4247352791d --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/project.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "tags": ["scope:plugin", "type:datasource"], + "targets": { + "build": {}, + "dev": {} + } +} diff --git a/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts b/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts index fc27d9a3d02..90c06344ca1 100644 --- a/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts +++ b/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts @@ -1,16 +1,32 @@ import { of } from 'rxjs'; import { DataQueryRequest, dateTime } from '@grafana/data'; -import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ -import { TemplateSrv } from 'app/features/templating/template_srv'; +import { BackendSrv, FetchResponse, TemplateSrv } from '@grafana/runtime'; -import { createFetchResponse } from '../../../../../test/helpers/createFetchResponse'; import OpenTsDatasource from '../datasource'; import { OpenTsdbQuery } from '../types'; +export function createFetchResponse(data: T): FetchResponse { + return { + data, + status: 200, + url: 'http://localhost:3000/api/ds/query', + config: { url: 'http://localhost:3000/api/ds/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: new Headers(), + ok: true, + }; +} + +const mockBackendSrv = { + fetch: jest.fn(), +} as unknown as BackendSrv; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - getBackendSrv: () => backendSrv, + getBackendSrv: () => mockBackendSrv, })); const metricFindQueryData = [ @@ -26,7 +42,7 @@ const metricFindQueryData = [ describe('opentsdb', () => { function getTestcontext({ data = metricFindQueryData }: { data?: unknown } = {}) { jest.clearAllMocks(); - const fetchMock = jest.spyOn(backendSrv, 'fetch'); + const fetchMock = jest.spyOn(mockBackendSrv, 'fetch'); fetchMock.mockImplementation(() => of(createFetchResponse(data))); const instanceSettings = { url: '', jsonData: { tsdbVersion: 1 } }; diff --git a/public/app/plugins/datasource/opentsdb/tsconfig.json b/public/app/plugins/datasource/opentsdb/tsconfig.json new file mode 100644 index 00000000000..40352099203 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/public/app/plugins/datasource/opentsdb/webpack.config.ts b/public/app/plugins/datasource/opentsdb/webpack.config.ts new file mode 100644 index 00000000000..7931eb9cb5c --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/webpack.config.ts @@ -0,0 +1,4 @@ +import config from '@grafana/plugin-configs/webpack.config.ts'; + +// eslint-disable-next-line no-barrel-files/no-barrel-files +export default config; diff --git a/yarn.lock b/yarn.lock index 5b9221f61fd..f5eebf42336 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2784,6 +2784,46 @@ __metadata: languageName: unknown linkType: soft +"@grafana-plugins/opentsdb@workspace:public/app/plugins/datasource/opentsdb": + version: 0.0.0-use.local + resolution: "@grafana-plugins/opentsdb@workspace:public/app/plugins/datasource/opentsdb" + dependencies: + "@emotion/css": "npm:11.13.5" + "@grafana/data": "workspace:*" + "@grafana/e2e-selectors": "workspace:*" + "@grafana/plugin-configs": "workspace:*" + "@grafana/runtime": "workspace:*" + "@grafana/schema": "workspace:*" + "@grafana/ui": "workspace:*" + "@testing-library/dom": "npm:10.4.1" + "@testing-library/jest-dom": "npm:6.6.4" + "@testing-library/react": "npm:16.3.0" + "@testing-library/user-event": "npm:14.6.1" + "@types/debounce-promise": "npm:3.1.9" + "@types/jest": "npm:29.5.14" + "@types/lodash": "npm:4.17.20" + "@types/node": "npm:22.17.0" + "@types/react": "npm:18.3.18" + "@types/react-dom": "npm:18.3.5" + "@types/uuid": "npm:10.0.0" + debounce-promise: "npm:3.1.2" + jest: "npm:29.7.0" + lodash: "npm:4.17.21" + react: "npm:18.3.1" + react-dom: "npm:18.3.1" + react-select: "npm:5.10.2" + react-use: "npm:17.6.0" + rxjs: "npm:7.8.2" + ts-node: "npm:10.9.2" + tslib: "npm:2.8.1" + typescript: "npm:5.9.2" + uuid: "npm:11.1.0" + webpack: "npm:5.101.0" + peerDependencies: + "@grafana/runtime": "*" + languageName: unknown + linkType: soft + "@grafana-plugins/parca@workspace:public/app/plugins/datasource/parca": version: 0.0.0-use.local resolution: "@grafana-plugins/parca@workspace:public/app/plugins/datasource/parca" @@ -10234,7 +10274,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=13.7.4": +"@types/node@npm:*, @types/node@npm:22.17.0, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=13.7.4": version: 22.17.0 resolution: "@types/node@npm:22.17.0" dependencies: From b9e39cdfcc42f436208d5c51305c389ed1b9544a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Wed, 12 Nov 2025 14:39:03 +0100 Subject: [PATCH 156/209] chore(unified-storage): add debug log for read after write (#113747) --- pkg/storage/unified/resource/server.go | 16 ++++++++++++---- pkg/storage/unified/resource/server_test.go | 7 +++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index b317bf20727..13bac8a2f6e 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -673,7 +673,7 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re }) } - s.sleepAfterSuccessfulWriteOperation(res, err) + s.sleepAfterSuccessfulWriteOperation("Create", req.Key, res, err) return res, err } @@ -706,7 +706,7 @@ type responseWithErrorResult interface { // 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 { +func (s *server) sleepAfterSuccessfulWriteOperation(operation string, key *resourcepb.ResourceKey, res responseWithErrorResult, err error) bool { if s.artificialSuccessfulWriteDelay <= 0 { return false } @@ -725,6 +725,14 @@ func (s *server) sleepAfterSuccessfulWriteOperation(res responseWithErrorResult, } } + s.log.Debug("sleeping after successful write operation", + "operation", operation, + "delay", s.artificialSuccessfulWriteDelay, + "group", key.Group, + "resource", key.Resource, + "namespace", key.Namespace, + "name", key.Name) + time.Sleep(s.artificialSuccessfulWriteDelay) return true } @@ -760,7 +768,7 @@ func (s *server) Update(ctx context.Context, req *resourcepb.UpdateRequest) (*re }) } - s.sleepAfterSuccessfulWriteOperation(res, err) + s.sleepAfterSuccessfulWriteOperation("Update", req.Key, res, err) return res, err } @@ -834,7 +842,7 @@ func (s *server) Delete(ctx context.Context, req *resourcepb.DeleteRequest) (*re }) } - s.sleepAfterSuccessfulWriteOperation(res, err) + s.sleepAfterSuccessfulWriteOperation("Delete", req.Key, res, err) return res, err } diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index 391a931696d..38df20e8151 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -587,10 +587,13 @@ func newTestServerWithQueue(t *testing.T, maxSizePerTenant int, numWorkers int) } func TestArtificialDelayAfterSuccessfulOperation(t *testing.T) { - s := &server{artificialSuccessfulWriteDelay: 1 * time.Millisecond} + s := &server{ + artificialSuccessfulWriteDelay: 1 * time.Millisecond, + log: slog.Default(), + } check := func(t *testing.T, expectedSleep bool, res responseWithErrorResult, err error) { - slept := s.sleepAfterSuccessfulWriteOperation(res, err) + slept := s.sleepAfterSuccessfulWriteOperation("test", &resourcepb.ResourceKey{}, res, err) require.Equal(t, expectedSleep, slept) } From 6d64c373ce487dbce3693b7bce61c0b113fd0a57 Mon Sep 17 00:00:00 2001 From: beejeebus Date: Tue, 11 Nov 2025 16:28:50 +0000 Subject: [PATCH 157/209] Allow FlagQueryServiceWithConnections to enable datasource config CRUD The FlagGrafanaAPIServerWithExperimentalAPIs is only available when `app_mode=development`. We have a more specific flag that is usable in production, so use that. Also, there was some old code constraining these APIs to a static list of datasources. We don't need that anymore, so this PR removes it. The FlagQueryServiceWithConnections is left as is, because there are multiple existing tests that rely on this development-only, experimental flag. I don't want to understand why that is. --- pkg/registry/apis/datasource/register.go | 48 +++++++----------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 61709652d31..9feb46a33d4 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -14,7 +14,6 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" openapi "k8s.io/kube-openapi/pkg/common" - "k8s.io/utils/strings/slices" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -58,14 +57,9 @@ func RegisterAPIService( reg prometheus.Registerer, pluginSources sources.Registry, ) (*DataSourceAPIBuilder, error) { - // We want to expose just a limited set of plugins //nolint:staticcheck // not yet migrated to OpenFeature - explicitPluginList := features.IsEnabledGlobally(featuremgmt.FlagDatasourceAPIServers) - - // This requires devmode! - //nolint:staticcheck // not yet migrated to OpenFeature - if !explicitPluginList && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { - return nil, nil // skip registration unless opting into experimental apis + if !features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil, nil } var err error @@ -76,31 +70,14 @@ func RegisterAPIService( return nil, fmt.Errorf("error getting list of datasource plugins: %s", err) } - ids := []string{ - "grafana-testdata-datasource", - "prometheus", - "graphite", - } - for _, pluginJSON := range pluginJSONs { - if explicitPluginList && !slices.Contains(ids, pluginJSON.ID) { - continue // skip this one - } - - if !pluginJSON.Backend { - continue // skip frontend only plugins - } - - if pluginJSON.Type != plugins.TypeDataSource { - continue // skip non-datasource plugins - } - client, ok := pluginClient.(PluginClient) if !ok { return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient) } - builder, err = NewDataSourceAPIBuilder(pluginJSON, + builder, err = NewDataSourceAPIBuilder( + pluginJSON, client, datasources.GetDatasourceProvider(pluginJSON), contextProvider, @@ -305,14 +282,17 @@ func getDatasourcePlugins(pluginSources sources.Registry) ([]plugins.JSONData, e return nil, err } for _, p := range res { - if p.Primary.JSONData.Type == plugins.TypeDataSource { - if _, found := uniquePlugins[p.Primary.JSONData.ID]; found { - backend.Logger.Info("Found duplicate plugin %s when registering API groups.", p.Primary.JSONData.ID) - continue - } - uniquePlugins[p.Primary.JSONData.ID] = true - pluginJSONs = append(pluginJSONs, p.Primary.JSONData) + if !p.Primary.JSONData.Backend || p.Primary.JSONData.Type != plugins.TypeDataSource { + continue } + + if _, found := uniquePlugins[p.Primary.JSONData.ID]; found { + backend.Logger.Info("Found duplicate plugin %s when registering API groups.", p.Primary.JSONData.ID) + continue + } + + uniquePlugins[p.Primary.JSONData.ID] = true + pluginJSONs = append(pluginJSONs, p.Primary.JSONData) } } return pluginJSONs, nil From cdc6a6114cfcc4ba773bca0810125d8581981ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Wed, 12 Nov 2025 14:59:27 +0100 Subject: [PATCH 158/209] Provisioning: Improve logging and tracing in job processing (#113454) * Provisioning: Improve logging and tracing in job processing - Add comprehensive tracing with OpenTelemetry spans across all job operations - Enhance logging with consistent style: lowercase, concise messages, appropriate log levels - Use past tense for completed lifecycle events (e.g., 'stopped' vs 'stop') - Add structured logging with contextual attributes for better searchability - Handle graceful shutdowns without throwing errors on context cancellation - Refactor Cleanup method into listExpiredJobs and cleanUpExpiredJob for better code quality - Avoid double logging by only logging errors when handled locally - Add tracing and logging to historyjob controller cleanup operations Files modified: - pkg/registry/apis/provisioning/jobs/driver.go: Add tracing spans and improve error handling for graceful shutdown - pkg/registry/apis/provisioning/jobs/concurrent_driver.go: Add tracing and consistent logging - pkg/registry/apis/provisioning/jobs/persistentstore.go: Add comprehensive tracing and logging to all public methods, refactor cleanup - apps/provisioning/pkg/controller/historyjob.go: Add tracing and improve logging consistency * Update pkg/registry/apis/provisioning/jobs/persistentstore.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor logging in persistentstore.go - Remove debug log statements at the start of job operations for cleaner output - Maintain structured logging with contextual attributes for improved traceability Files modified: - pkg/registry/apis/provisioning/jobs/persistentstore.go: Clean up logging for job operations * Enhance logging and tracing in provisioning job operations - Introduce OpenTelemetry spans for better observability in job processing and webhook handling - Improve structured logging with contextual attributes for key operations - Remove unnecessary tracing spans in long-running functions to streamline performance - Update error handling to record errors in spans for better traceability Files modified: - pkg/registry/apis/provisioning/controller/repository.go: Add tracing and structured logging to sync job operations - pkg/registry/apis/provisioning/jobs/concurrent_driver.go: Remove tracing span from long-running function - pkg/registry/apis/provisioning/jobs/driver.go: Enhance logging and tracing in job processing - pkg/registry/apis/provisioning/webhooks/webhook.go: Implement tracing and structured logging for webhook connections * Update pkg/registry/apis/provisioning/jobs/driver.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improve error handling in ConcurrentJobDriver to differentiate between graceful shutdown and unexpected stops * Remove unused import in driver.go to clean up code --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../provisioning/pkg/controller/historyjob.go | 52 +-- .../provisioning/controller/repository.go | 18 +- .../provisioning/jobs/concurrent_driver.go | 23 +- pkg/registry/apis/provisioning/jobs/driver.go | 98 +++++- .../apis/provisioning/jobs/persistentstore.go | 323 +++++++++++++++--- .../apis/provisioning/webhooks/webhook.go | 24 +- 6 files changed, 444 insertions(+), 94 deletions(-) diff --git a/apps/provisioning/pkg/controller/historyjob.go b/apps/provisioning/pkg/controller/historyjob.go index 2e4e13f359d..bdfefb0b613 100644 --- a/apps/provisioning/pkg/controller/historyjob.go +++ b/apps/provisioning/pkg/controller/historyjob.go @@ -58,32 +58,42 @@ func NewHistoryJobController( func (c *HistoryJobController) cleanupJob(obj interface{}) { job, ok := obj.(*provisioning.HistoricJob) if !ok { - c.logger.Error("Expected HistoricJob but got", "type", obj) + c.logger.Error("unexpected object type - expected HistoricJob", "type", obj) return } age := time.Since(job.CreationTimestamp.Time) - if age > c.expirationTime { - namespace := job.Namespace - ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace) - if err != nil { - c.logger.Error("Failed to set provisioning identity for cleanup", "error", err) + + // Only cleanup jobs older than expiration time + if age <= c.expirationTime { + return + } + + logger := c.logger.With( + "job", job.Name, + "namespace", job.Namespace, + "age", age, + ) + + logger.Debug("start cleanup expired historic job") + + namespace := job.Namespace + ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace) + if err != nil { + logger.Error("failed to set provisioning identity", "error", err) + return + } + + ctx = request.WithNamespace(ctx, namespace) + err = c.client.HistoricJobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + logger.Debug("historic job already deleted") return } - - ctx = request.WithNamespace(ctx, namespace) - err = c.client.HistoricJobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{}) - if err != nil && !apierrors.IsNotFound(err) { - c.logger.Error("Failed to delete expired HistoryJob", - "namespace", job.Namespace, - "name", job.Name, - "age", age, - "error", err) - } else { - c.logger.Info("Deleted expired HistoryJob", - "namespace", job.Namespace, - "name", job.Name, - "age", age) - } + logger.Error("failed to delete expired historic job", "error", err) + return } + + logger.Info("deleted expired historic job") } diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index b0ba13385b6..ffeb3f1fb9f 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "go.opentelemetry.io/otel/attribute" apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -140,6 +141,9 @@ func repoKeyFunc(obj any) (string, error) { } // Run starts the RepositoryController. +// +// Note: This function intentionally does NOT create a tracing span because it runs indefinitely +// until shutdown. Individual processing operations already have their own spans. func (rc *RepositoryController) Run(ctx context.Context, workerCount int) { defer utilruntime.HandleCrash() defer rc.queue.ShutDown() @@ -386,21 +390,31 @@ func shouldUseIncrementalSync(ctx context.Context, versioned repository.Versione } func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) error { + ctx, span := rc.tracer.Start(ctx, "provisioning.controller.add_sync_job") + defer span.End() + + span.SetAttributes( + attribute.String("repository", obj.GetName()), + attribute.String("namespace", obj.Namespace), + attribute.Bool("incremental", syncOptions != nil && syncOptions.Incremental), + ) + job, err := rc.jobs.Insert(ctx, obj.Namespace, provisioning.JobSpec{ Repository: obj.GetName(), Action: provisioning.JobActionPull, Pull: syncOptions, }) if apierrors.IsAlreadyExists(err) { - logging.FromContext(ctx).Info("sync job already exists, nothing triggered") + logging.FromContext(ctx).Info("sync job already exists") return nil } if err != nil { + span.RecordError(err) // FIXME: should we update the status of the repository if we fail to add the job? return fmt.Errorf("error adding sync job: %w", err) } - logging.FromContext(ctx).Info("sync job triggered", "job", job.Name) + span.SetAttributes(attribute.String("job.name", job.Name)) return nil } diff --git a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go index 1fdc32bea60..dce8d65a97e 100644 --- a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go +++ b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go @@ -75,9 +75,12 @@ func NewConcurrentJobDriver( // Run starts multiple job drivers concurrently and handles cleanup coordination. // This is a blocking function that will run until the context is canceled or an error occurs. +// +// Note: This function intentionally does NOT create a tracing span because it runs indefinitely +// until shutdown. Individual job processing and cleanup operations already have their own spans. func (c *ConcurrentJobDriver) Run(ctx context.Context) error { logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers) - logger.Info("starting concurrent job driver with lease-based cleanup", "cleanup_interval", c.cleanupInterval) + logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval) // Set up cleanup ticker - runs more frequently with lease-based approach cleanupTicker := time.NewTicker(c.cleanupInterval) @@ -85,7 +88,7 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error { // Initial cleanup if err := c.store.Cleanup(ctx); err != nil { - logger.Error("failed to clean up old jobs at start", "error", err) + logger.Error("failed initial cleanup", "error", err) } var wg sync.WaitGroup @@ -99,10 +102,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error { select { case <-cleanupTicker.C: if err := c.store.Cleanup(ctx); err != nil { - logger.Error("failed to cleanup jobs", "error", err) + logger.Error("failed cleanup", "error", err) } case <-ctx.Done(): - logger.Debug("cleanup goroutine stopping") + logger.Debug("cleanup routine stopped") return } } @@ -133,13 +136,13 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error { return } - driverLogger.Debug("starting job driver") + driverLogger.Info("start job driver") if err := driver.Run(driverCtx); err != nil { driverLogger.Error("job driver failed", "error", err) errChan <- err return } - driverLogger.Debug("job driver stopped") + driverLogger.Info("job driver stopped") }(i) } @@ -157,6 +160,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error { } } - logger.Info("all job driver workers stopped") - return ctx.Err() + if ctx.Err() != nil { + logger.Info("all job drivers gracefully stopped") + return nil + } + + return fmt.Errorf("concurrent job driver stopped unexpectedly") } diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index 7902aad12fc..b1cb0ee6da1 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "go.opentelemetry.io/otel/attribute" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apiserver/pkg/endpoints/request" @@ -14,6 +15,7 @@ import ( "github.com/grafana/grafana/apps/provisioning/pkg/apifmt" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/tracing" ) // Store is an abstraction for the storage API. @@ -105,6 +107,9 @@ func NewJobDriver( // Run drives jobs to completion. This is a blocking function. // It will run until the context is canceled or an error occurs. // This is a thread-safe function; it may be called from multiple goroutines. +// +// Note: This function intentionally does NOT create a tracing span because it runs indefinitely +// until shutdown. Individual job processing operations already have their own spans. func (d *jobDriver) Run(ctx context.Context) error { jobTicker := time.NewTicker(d.jobInterval) defer jobTicker.Stop() @@ -122,7 +127,8 @@ func (d *jobDriver) Run(ctx context.Context) error { for { select { case <-ctx.Done(): - return ctx.Err() + logger.Info("job driver stopped") + return nil // Context cancellation is expected during shutdown case <-jobTicker.C: d.processJobsUntilDoneOrError(ctx) case <-d.notifications: @@ -134,6 +140,11 @@ func (d *jobDriver) Run(ctx context.Context) error { // This will keep processing jobs until there are none left (or we hit an error) func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) { for { + // Check if context is cancelled before attempting to claim jobs + if ctx.Err() != nil { + return + } + err := d.claimAndProcessOneJob(ctx) if err != nil { if !errors.Is(err, ErrNoJobs) { @@ -145,11 +156,17 @@ func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) { } func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.claim_and_process_one_job") + defer span.End() + logger := logging.FromContext(ctx) // Claim a job to work on. claimedJob, rollback, err := d.store.Claim(ctx) if err != nil { + if !errors.Is(err, ErrNoJobs) { + span.RecordError(err) + } return apifmt.Errorf("failed to claim job: %w", err) } // Ensure that the job is cleaned up if we fail to complete it. @@ -159,9 +176,15 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { namespace := claimedJob.GetNamespace() logger = logger.With("job", claimedJob.GetName(), "namespace", namespace) ctx = logging.Context(ctx, logger) - logger.Debug("claimed a job") d.currentJob = claimedJob + span.SetAttributes( + attribute.String("job.name", claimedJob.GetName()), + attribute.String("job.namespace", namespace), + attribute.String("job.repository", claimedJob.Spec.Repository), + attribute.String("job.action", string(claimedJob.Spec.Action)), + ) + // Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it. // Incidentally, this also limits our permissions to only the namespace of the job. ctx = request.WithNamespace(ctx, namespace) @@ -188,11 +211,26 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { end := time.Now() logger.Debug("job processed", "duration", end.Sub(recorder.Started()), "error", err) - // Capture job timeout - if jobctx.Err() != nil && err == nil { + // Check if parent context was cancelled (graceful shutdown) + if ctx.Err() != nil { + logger.Debug("context cancel - job will retry") + // Don't complete the job - let it be retried by another worker + d.mu.Lock() + d.currentJob = nil + d.mu.Unlock() + return nil + } + + // Capture job timeout (but not parent context cancellation) + if jobctx.Err() != nil && err == nil && ctx.Err() == nil { err = jobctx.Err() } + // Record job processing error on span + if err != nil { + span.RecordError(err) + } + // Complete the job d.mu.Lock() d.currentJob.Status = recorder.Complete(ctx, err) @@ -205,27 +243,29 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error { err = d.historicJobs.WriteJob(ctx, d.currentJob.DeepCopy()) if err != nil { // We're not going to return this as it is not critical. Not ideal, but not critical. - logger.Warn("failed to create historic job", "historic_job", *d.currentJob, "error", err) - } else { - logger.Debug("created historic job", "historic_job", *d.currentJob) + logger.Warn("failed to write historic job", "error", err) } // Mark the job as completed. if err := d.store.Complete(ctx, d.currentJob); err != nil { + span.RecordError(err) return apifmt.Errorf("failed to complete job '%s' in '%s': %w", d.currentJob.GetName(), d.currentJob.GetNamespace(), err) } - logger.Debug("job completed") + logger.Info("job complete") return nil } // leaseRenewalLoop continuously renews the lease for a job until the context is cancelled. // If lease renewal fails persistently, it signals via the leaseExpired channel. +// +// Note: This function intentionally does NOT create a tracing span because it runs indefinitely +// for the lifetime of a job. Individual RenewLease calls already have their own spans. func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, leaseExpired chan struct{}) { ticker := time.NewTicker(d.leaseRenewalInterval) defer ticker.Stop() - logger.Debug("starting lease renewal loop", "renewal_interval", d.leaseRenewalInterval) + logger.Debug("start lease renewal loop", "renewal_interval", d.leaseRenewalInterval) consecutiveFailures := 0 maxFailures := 3 // Allow a few failures before giving up @@ -233,7 +273,7 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, for { select { case <-ctx.Done(): - logger.Debug("lease renewal loop stopping") + logger.Debug("lease renewal loop stopped") return case <-ticker.C: d.mu.Lock() @@ -267,13 +307,12 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, logger.Debug("lease renewal recovered", "previous_failures", consecutiveFailures) } consecutiveFailures = 0 - logger.Debug("lease renewed successfully") } } } } -// processJobWithLeaseCheck processes a job but aborts if the lease expires. +// processJobWithLeaseCheck processes a job but aborts if the lease expires or context is cancelled. func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error { // Run the job processing in a goroutine so we can monitor lease expiry resultChan := make(chan error, 1) @@ -287,11 +326,16 @@ func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobPr case <-leaseExpired: return apifmt.Errorf("job aborted due to lease expiry") case <-ctx.Done(): + // Return context error directly - caller will determine if this is due to graceful shutdown + // or job timeout based on which context was cancelled return ctx.Err() } } func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.process_job") + defer span.End() + logger := logging.FromContext(ctx) d.mu.Lock() if d.currentJob == nil { @@ -305,6 +349,11 @@ func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder namespace := d.currentJob.Namespace d.mu.Unlock() + span.SetAttributes( + attribute.String("job.repository", repoName), + attribute.String("job.action", string(job.Spec.Action)), + ) + for _, worker := range d.workers { if !worker.IsSupported(ctx, *job) { continue @@ -312,12 +361,13 @@ func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder repo, err := d.repoGetter.GetRepository(ctx, namespace, repoName) if err != nil { + span.RecordError(err) return apifmt.Errorf("failed to get repository '%s': %w", repoName, err) } r := repo.Config() if r.DeletionTimestamp != nil && !r.DeletionTimestamp.IsZero() { - logger.Info("repository is marked for deletion, skipping processing job", + logger.Info("repository marked for deletion - skip job", "name", r.Name, "namespace", r.Namespace, "deletionTimestamp", r.DeletionTimestamp, @@ -325,14 +375,23 @@ func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder return nil } - return worker.Process(ctx, repo, *job, recorder) + err = worker.Process(ctx, repo, *job, recorder) + if err != nil { + span.RecordError(err) + } + return err } - return apifmt.Errorf("no workers were registered to handle the job") + err := apifmt.Errorf("no workers were registered to handle the job") + span.RecordError(err) + return err } func (d *jobDriver) onProgress() ProgressFn { return func(ctx context.Context, status provisioning.JobStatus) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.update_progress") + defer span.End() + logging.FromContext(ctx).Debug("job progress", "status", status) const maxRetries = 3 @@ -376,9 +435,16 @@ func (d *jobDriver) onProgress() ProgressFn { // Update succeeded, update our local copy *d.currentJob = *updated d.mu.Unlock() + + span.SetAttributes( + attribute.String("job.state", string(status.State)), + attribute.Int("attempt", attempt+1), + ) return nil } - return apifmt.Errorf("failed to update job progress after %d attempts", maxRetries) + err := apifmt.Errorf("failed to update job progress after %d attempts", maxRetries) + span.RecordError(err) + return err } } diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go index 78494aa82cc..b7c7e47bd35 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + "go.opentelemetry.io/otel/attribute" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -18,6 +19,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/prometheus/client_golang/prometheus" ) @@ -100,6 +102,16 @@ func NewJobStore(provisioningClient client.ProvisioningV0alpha1Interface, expiry // If err is not nil, the job and rollback values are always nil. // The err may be ErrNoJobs if there are no jobs to claim. func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) { + ctx, span := tracing.Start(ctx, "provisioning.jobs.claim") + defer func() { + if err != nil && !errors.Is(err, ErrNoJobs) { + span.RecordError(err) + } + span.End() + }() + + logger := logging.FromContext(ctx).With("operation", "claim") + requirement, err := labels.NewRequirement(LabelJobClaim, selection.DoesNotExist, nil) if err != nil { return nil, nil, apifmt.Errorf("could not create requirement: %w", err) @@ -114,9 +126,12 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol } if len(jobs.Items) == 0 { + logger.Debug("no jobs available to claim") return nil, nil, ErrNoJobs } + logger.Debug("found jobs available", "count", len(jobs.Items)) + for _, job := range jobs.Items { if job.Labels == nil { job.Labels = make(map[string]string) @@ -145,6 +160,20 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol return nil, nil, apifmt.Errorf("failed to claim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } + logger.Info("job claim complete", + "job", updatedJob.GetName(), + "namespace", updatedJob.GetNamespace(), + "repository", updatedJob.Spec.Repository, + "action", updatedJob.Spec.Action, + ) + + span.SetAttributes( + attribute.String("job.name", updatedJob.GetName()), + attribute.String("job.namespace", updatedJob.GetNamespace()), + attribute.String("job.repository", updatedJob.Spec.Repository), + attribute.String("job.action", string(updatedJob.Spec.Action)), + ) + return updatedJob.DeepCopy(), func() { // Rolling back does not need to care about the parent's cancellation state. // This will also use the parent context (i.e. from the for loop!), ensuring we have permissions to do this. @@ -181,50 +210,99 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol } // We failed to claim any jobs. + logger.Debug("no jobs claimed - all already claimed by others") return nil, nil, ErrNoJobs } // Update saves the job back to the store. func (s *persistentStore) Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) { + ctx, span := tracing.Start(ctx, "provisioning.jobs.update") + defer span.End() + + logger := logging.FromContext(ctx).With( + "operation", "update", + "job", job.GetName(), + "namespace", job.GetNamespace(), + ) + + span.SetAttributes( + attribute.String("job.name", job.GetName()), + attribute.String("job.namespace", job.GetNamespace()), + ) + // Set up the provisioning identity for this namespace ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace()) if err != nil { + span.RecordError(err) return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } updatedJob, err := s.client.Jobs(job.GetNamespace()).Update(ctx, job, metav1.UpdateOptions{}) if err != nil { + span.RecordError(err) return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } + logger.Debug("update job complete") return updatedJob, nil } // Get retrieves a job by name for conflict resolution. func (s *persistentStore) Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) { + ctx, span := tracing.Start(ctx, "provisioning.jobs.get") + defer span.End() + + logger := logging.FromContext(ctx).With( + "operation", "get", + "job", name, + "namespace", namespace, + ) + + span.SetAttributes( + attribute.String("job.name", name), + attribute.String("job.namespace", namespace), + ) + // Set up provisioning identity to access jobs across all namespaces ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace) if err != nil { + span.RecordError(err) return nil, apifmt.Errorf("failed to grant provisioning identity for job lookup: %w", err) } // Use Get to directly fetch the job by name job, err := s.client.Jobs(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { + span.RecordError(err) return nil, apifmt.Errorf("failed to get job by name '%s': %w", name, err) } + logger.Debug("get job complete") return job, nil } // Complete marks a job as completed and moves it to the historic job store. // When in the historic store, there is no more claim on the job. func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) error { - logger := logging.FromContext(ctx).With("namespace", job.GetNamespace(), "job", job.GetName()) + ctx, span := tracing.Start(ctx, "provisioning.jobs.complete") + defer span.End() + + logger := logging.FromContext(ctx).With( + "operation", "complete", + "namespace", job.GetNamespace(), + "job", job.GetName(), + ) + + span.SetAttributes( + attribute.String("job.name", job.GetName()), + attribute.String("job.namespace", job.GetNamespace()), + attribute.String("job.action", string(job.Spec.Action)), + ) // Set up the provisioning identity for this namespace ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace()) if err != nil { + span.RecordError(err) return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } @@ -235,6 +313,7 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store. err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{}) if err != nil { + span.RecordError(err) return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } logger.Debug("deleted job from job store") @@ -246,26 +325,44 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e delete(job.Labels, LabelJobClaim) s.queueMetrics.DecreaseQueueSize(string(job.Spec.Action)) - logger.Debug("job completion done") + logger.Debug("complete job complete") return nil } // RenewLease renews the lease for a claimed job, extending its expiry time. // Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired). func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.renew_lease") + defer span.End() + + logger := logging.FromContext(ctx).With( + "operation", "renew_lease", + "job", job.GetName(), + "namespace", job.GetNamespace(), + ) + + span.SetAttributes( + attribute.String("job.name", job.GetName()), + attribute.String("job.namespace", job.GetNamespace()), + ) + if job.Labels == nil || job.Labels[LabelJobClaim] == "" { - return apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace()) + err := apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace()) + span.RecordError(err) + return err } // Set up the provisioning identity for this namespace ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace()) if err != nil { + span.RecordError(err) return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } // Fetch the latest version to avoid conflicts latestJob, err := s.client.Jobs(job.GetNamespace()).Get(ctx, job.GetName(), metav1.GetOptions{}) if err != nil { + span.RecordError(err) if apierrors.IsNotFound(err) { return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace()) } @@ -274,7 +371,9 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) // Verify we still own the lease if latestJob.Labels == nil || latestJob.Labels[LabelJobClaim] == "" { - return apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace()) + err := apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace()) + span.RecordError(err) + return err } // Update the claim timestamp to current time @@ -284,85 +383,213 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) // Update the job in storage with the latest resource version _, err = s.client.Jobs(job.GetNamespace()).Update(ctx, updatedJob, metav1.UpdateOptions{}) if apierrors.IsConflict(err) { - return apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace()) + err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace()) + span.RecordError(err) + return err } if apierrors.IsNotFound(err) { - return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace()) + err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace()) + span.RecordError(err) + return err } if err != nil { + span.RecordError(err) return apifmt.Errorf("failed to renew lease for job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } // Update the job's claim timestamp and resource version in memory job.Labels[LabelJobClaim] = updatedJob.Labels[LabelJobClaim] job.ResourceVersion = updatedJob.ResourceVersion + + logger.Debug("renew lease complete") return nil } // Cleanup finds jobs with expired leases and marks them as failed. // This replaces the old cleanup mechanism and should be called more frequently. func (s *persistentStore) Cleanup(ctx context.Context) error { - // Set up provisioning identity to access jobs across all namespaces - ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces - if err != nil { - return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err) - } + ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup") + defer span.End() - // Find jobs with expired leases (older than expiry time) - expiry := s.clock().Add(-s.expiry).UnixMilli() - requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)}) - if err != nil { - return apifmt.Errorf("could not create requirement: %w", err) - } + startTime := s.clock() + logger := logging.FromContext(ctx).With("operation", "cleanup") - timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - jobs, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{ - LabelSelector: labels.NewSelector().Add(*requirement).String(), - Limit: 100, // Process in batches - }) - cancel() + // List expired jobs + jobs, err := s.listExpiredJobs(ctx) if err != nil { - return apifmt.Errorf("failed to list jobs with expired leases: %w", err) + span.RecordError(err) + return err } // If no jobs found, cleanup is complete - if len(jobs.Items) == 0 { + if len(jobs) == 0 { + duration := s.clock().Sub(startTime) + logger.Info("cleanup complete - no expired jobs found", "duration", duration) + span.SetAttributes( + attribute.Int("count", 0), + attribute.Int64("duration_ms", duration.Milliseconds()), + ) return nil } - for _, job := range jobs.Items { - // Mark job as failed due to lease expiry and archive it - job := job.DeepCopy() - job.Status.State = provisioning.JobStateError - job.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" + logger.Info("found expired jobs", "count", len(jobs)) - // Set namespace context for the completion - ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace()) - if err != nil { - return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) - } - - // Use Complete to properly archive the failed job - if err := s.Complete(ctx, job); err != nil { - if apierrors.IsNotFound(err) { - // Job was already completed/deleted by another process - continue - } - return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) + // Clean up each expired job + for _, job := range jobs { + if err := s.cleanUpExpiredJob(ctx, job); err != nil { + span.RecordError(err) + return err } } + duration := s.clock().Sub(startTime) + logger.Info("cleanup complete", + "duration", duration, + "count", len(jobs), + ) + + span.SetAttributes( + attribute.Int("count", len(jobs)), + attribute.Int64("duration_ms", duration.Milliseconds()), + ) + + return nil +} + +// listExpiredJobs returns jobs with expired leases. +func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) { + logger := logging.FromContext(ctx) + + // Set up provisioning identity to access jobs across all namespaces + ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces + if err != nil { + return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err) + } + + // Find jobs with expired leases (older than expiry time) + expiry := s.clock().Add(-s.expiry).UnixMilli() + expiryTime := time.UnixMilli(expiry) + logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339)) + + requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)}) + if err != nil { + return nil, apifmt.Errorf("could not create requirement: %w", err) + } + + listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs") + defer listSpan.End() + + listSpan.SetAttributes( + attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)), + attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())), + ) + + timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second) + defer cancel() + + jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{ + LabelSelector: labels.NewSelector().Add(*requirement).String(), + Limit: 100, // Process in batches + }) + if err != nil { + listSpan.RecordError(err) + return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err) + } + + listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items))) + return jobList.Items, nil +} + +// cleanUpExpiredJob marks a single expired job as failed and archives it. +func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error { + // Calculate how long the job has been expired + var expiredFor time.Duration + var claimTimestamp time.Time + if claimTime, exists := job.Labels[LabelJobClaim]; exists { + claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64) + if parseErr == nil { + claimTimestamp = time.UnixMilli(claimMillis) + expiredFor = s.clock().Sub(claimTimestamp) + } + } + + logger := logging.FromContext(ctx).With( + "job", job.GetName(), + "namespace", job.GetNamespace(), + "repository", job.Spec.Repository, + "action", job.Spec.Action, + "expired_for", expiredFor, + ) + + if !claimTimestamp.IsZero() { + logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339)) + } + + jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job") + defer jobSpan.End() + + jobSpan.SetAttributes( + attribute.String("job.name", job.GetName()), + attribute.String("job.namespace", job.GetNamespace()), + attribute.String("job.repository", job.Spec.Repository), + attribute.String("job.action", string(job.Spec.Action)), + attribute.String("job.expired_for", expiredFor.String()), + ) + + // Mark job as failed due to lease expiry and archive it + jobCopy := job.DeepCopy() + jobCopy.Status.State = provisioning.JobStateError + jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" + + // Set namespace context for the completion + jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace()) + if err != nil { + jobSpan.RecordError(err) + return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) + } + + // Use Complete to properly archive the failed job + if err := s.Complete(jobCtx, jobCopy); err != nil { + if apierrors.IsNotFound(err) { + // Job was already completed/deleted by another process - this is expected + logger.Warn("job already completed or deleted by another process") + return nil + } + jobSpan.RecordError(err) + return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) + } + + logger.Info("clean up expired job complete") return nil } func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) { + ctx, span := tracing.Start(ctx, "provisioning.jobs.insert") + defer span.End() + + logger := logging.FromContext(ctx).With( + "operation", "insert", + "namespace", namespace, + "repository", spec.Repository, + "action", spec.Action, + ) + + span.SetAttributes( + attribute.String("job.namespace", namespace), + attribute.String("job.repository", spec.Repository), + attribute.String("job.action", string(spec.Action)), + ) + if spec.Repository == "" { - return nil, errors.New("missing repository in job") + err := errors.New("missing repository in job") + span.RecordError(err) + return nil, err } // Set up the provisioning identity for this namespace ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace) if err != nil { + span.RecordError(err) return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", namespace, err) } @@ -376,19 +603,27 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro Spec: spec, } if err := mutateJobAction(job); err != nil { + span.RecordError(err) return nil, err } generateJobName(job) // Side-effect: updates the job's name. + + logger = logger.With("job", job.GetName()) + span.SetAttributes(attribute.String("job.name", job.GetName())) + created, err := s.client.Jobs(namespace).Create(ctx, job, metav1.CreateOptions{}) if apierrors.IsAlreadyExists(err) { + span.RecordError(err) return nil, apifmt.Errorf("job '%s' in '%s' already exists: %w", job.GetName(), job.GetNamespace(), err) } if err != nil { + span.RecordError(err) return nil, apifmt.Errorf("failed to create job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } s.queueMetrics.IncreaseQueueSize(string(job.Spec.Action)) + logger.Info("insert job complete") return created, nil } diff --git a/pkg/registry/apis/provisioning/webhooks/webhook.go b/pkg/registry/apis/provisioning/webhooks/webhook.go index 4e5bfffb28a..859437f1dda 100644 --- a/pkg/registry/apis/provisioning/webhooks/webhook.go +++ b/pkg/registry/apis/provisioning/webhooks/webhook.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "go.opentelemetry.io/otel/attribute" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/authorization/authorizer" @@ -17,6 +18,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/tracing" provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest" "github.com/prometheus/client_golang/prometheus" @@ -122,8 +124,16 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim } return provisioningapis.WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logger := logging.FromContext(r.Context()).With("logger", "webhook-connector", "repo", name) - ctx := logging.Context(r.Context(), logger) + ctx, span := tracing.Start(r.Context(), "provisioning.webhook.handle") + defer span.End() + + span.SetAttributes( + attribute.String("repository", name), + attribute.String("namespace", namespace), + ) + + logger := logging.FromContext(ctx).With("logger", "webhook-connector", "repo", name) + ctx = logging.Context(ctx, logger) if !s.webhooksEnabled { responder.Error(errors.NewBadRequest("webhooks are not enabled")) return @@ -140,12 +150,15 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim rsp, err := hooks.Webhook(ctx, r) if err != nil { + span.RecordError(err) responder.Error(err) return } if rsp == nil { - responder.Error(fmt.Errorf("expecting a response")) + err := fmt.Errorf("expecting a response") + span.RecordError(err) + responder.Error(err) return } @@ -162,12 +175,17 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim if rsp.Job != nil { rsp.Job.Repository = name actionTaken = string(rsp.Job.Action) + span.SetAttributes(attribute.String("job.action", actionTaken)) + job, err := s.core.GetJobQueue().Insert(ctx, namespace, *rsp.Job) if err != nil { + span.RecordError(err) logger.Error("failed to insert job", "error", err) responder.Error(err) return } + span.SetAttributes(attribute.String("job.name", job.Name)) + logger.Info("webhook job created", "job", job.Name, "action", actionTaken) responder.Object(rsp.Code, job) return } From 735b776edccc4bbbc578dfca121936a2c96a3f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:02:36 +0100 Subject: [PATCH 159/209] fix: cleanup legacy resource if it is created in legacy during dual update (#113753) --- pkg/storage/legacysql/dualwrite/dualwriter.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index 8aed81d7200..ba4e757742e 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -419,6 +419,15 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat // If we want to check unified errors just run it in foreground. if _, _, err := d.unified.Update(ctx, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options); err != nil { log.With("objectInfo", objectInfo(objFromLegacy)).Error("failed to UPDATE in unified storage", "err", err) + // cleanup the legacy object if we created it there + if createdLegacy { + go func(ctxBg context.Context, cancel context.CancelFunc) { + defer cancel() + if _, asyncDelete, err := d.legacy.Delete(ctxBg, name, nil, &metav1.DeleteOptions{}); err != nil { + log.With("name", name).Error("failed to CLEANUP object in legacy storage after unified storage update failure", "err", err, "asyncDelete", asyncDelete) + } + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) + } return nil, false, err } return objFromLegacy, createdLegacy, nil From 3e2dccb5441d99cfdd9bf3390a9c23de6a61b456 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 12 Nov 2025 09:06:41 -0500 Subject: [PATCH 160/209] FilesView: .keep file should not be clickable and no actions available (#113687) * FilesView: .keep file should not be clickable and no actions available --- .../provisioning/File/FilesView.test.tsx | 17 +++++++++++++++++ .../features/provisioning/File/FilesView.tsx | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/public/app/features/provisioning/File/FilesView.test.tsx b/public/app/features/provisioning/File/FilesView.test.tsx index 8f3412188b1..ed29db649ae 100644 --- a/public/app/features/provisioning/File/FilesView.test.tsx +++ b/public/app/features/provisioning/File/FilesView.test.tsx @@ -146,4 +146,21 @@ describe('FilesView', () => { expect(screen.getByRole('link', { name: 'View' })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); }); + + it('renders plain text and hides actions for .keep files', () => { + mockRepositoryFilesQuery({ + isSuccess: true, + status: 'fulfilled', + data: { + items: [{ path: 'dashboards/.keep', hash: 'abc', size: '0' }], + }, + }); + + renderComponent(); + + expect(screen.getByText('dashboards/.keep')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'dashboards/.keep' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'View' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/provisioning/File/FilesView.tsx b/public/app/features/provisioning/File/FilesView.tsx index 031784c0d20..97ac77b2fc4 100644 --- a/public/app/features/provisioning/File/FilesView.tsx +++ b/public/app/features/provisioning/File/FilesView.tsx @@ -31,6 +31,10 @@ export function FilesView({ repo }: FilesViewProps) { sortType: 'string', cell: ({ row: { original } }: FileCell<'path'>) => { const { path } = original; + const isDotKeepFile = getIsDotKeepFile(path); + if (isDotKeepFile) { + return path; + } return {path}; }, }, @@ -44,6 +48,10 @@ export function FilesView({ repo }: FilesViewProps) { header: '', cell: ({ row: { original } }: FileCell<'path'>) => { const { path } = original; + const isDotKeepFile = getIsDotKeepFile(path); + if (isDotKeepFile) { + return null; + } return ( {(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && ( @@ -84,3 +92,8 @@ export function FilesView({ repo }: FilesViewProps) { ); } + +function getIsDotKeepFile(path: string): boolean { + // e.g. 'dashboards/.keep' → true, 'dashboards/example.keep.json' → false + return path.split('/').pop() === '.keep'; +} From 6c512dabdcfa83537101cf5f4536f5244ed4206d Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 12 Nov 2025 06:15:43 -0800 Subject: [PATCH 161/209] Secrets: Fix MariaDB syntax error due to unsupported CTE syntax (#111610) (#113690) * Secrets: fix MariaDB syntax error due to unsupported CTE syntax (#111610) * parametrize guid/created columns and re-generate test fixtures --------- Co-authored-by: Matheus Macabu --- .../data/secure_value_lease_inactive.sql | 27 +++++++++---------- ...re_value_lease_inactive-lease inactive.sql | 25 +++++++++-------- ...re_value_lease_inactive-lease inactive.sql | 25 +++++++++-------- ...re_value_lease_inactive-lease inactive.sql | 25 +++++++++-------- 4 files changed, 49 insertions(+), 53 deletions(-) diff --git a/pkg/storage/secret/metadata/data/secure_value_lease_inactive.sql b/pkg/storage/secret/metadata/data/secure_value_lease_inactive.sql index 3b95aed3000..cabaaed9aa2 100644 --- a/pkg/storage/secret/metadata/data/secure_value_lease_inactive.sql +++ b/pkg/storage/secret/metadata/data/secure_value_lease_inactive.sql @@ -1,20 +1,19 @@ -WITH to_update AS ( - SELECT guid FROM ( - SELECT - guid, - ROW_NUMBER() OVER (ORDER BY created ASC) AS rn - FROM {{ .Ident "secret_secure_value" }} - WHERE +UPDATE + {{ .Ident "secret_secure_value" }} +SET + {{ .Ident "lease_token" }} = {{ .Arg .LeaseToken }}, + {{ .Ident "lease_created" }} = {{ .Arg .Now }} +WHERE {{ .Ident "guid" }} IN ( + SELECT {{ .Ident "guid" }} FROM ( + SELECT + {{ .Ident "guid" }}, + ROW_NUMBER() OVER (ORDER BY {{ .Ident "created" }} ASC) AS rn + FROM {{ .Ident "secret_secure_value" }} + WHERE {{ .Ident "active" }} = FALSE AND {{ .Arg .Now }} - {{ .Ident "created" }} > {{ .Arg .MinAge }} AND {{ .Arg .Now }} - {{ .Ident "lease_created" }} > {{ .Arg .LeaseTTL }} ) AS sub WHERE rn <= {{ .Arg .MaxBatchSize }} ) -UPDATE - {{ .Ident "secret_secure_value" }} -SET - {{ .Ident "lease_token" }} = {{ .Arg .LeaseToken }}, - {{ .Ident "lease_created" }} = {{ .Arg .Now }} -WHERE guid IN (SELECT guid FROM to_update) -; \ No newline at end of file +; diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_lease_inactive-lease inactive.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_lease_inactive-lease inactive.sql index 52d84085dc8..b93f38c4b45 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_lease_inactive-lease inactive.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_lease_inactive-lease inactive.sql @@ -1,20 +1,19 @@ -WITH to_update AS ( - SELECT guid FROM ( - SELECT - guid, - ROW_NUMBER() OVER (ORDER BY created ASC) AS rn - FROM `secret_secure_value` - WHERE +UPDATE + `secret_secure_value` +SET + `lease_token` = 'token', + `lease_created` = 10 +WHERE `guid` IN ( + SELECT `guid` FROM ( + SELECT + `guid`, + ROW_NUMBER() OVER (ORDER BY `created` ASC) AS rn + FROM `secret_secure_value` + WHERE `active` = FALSE AND 10 - `created` > 300 AND 10 - `lease_created` > 30 ) AS sub WHERE rn <= 10 ) -UPDATE - `secret_secure_value` -SET - `lease_token` = 'token', - `lease_created` = 10 -WHERE guid IN (SELECT guid FROM to_update) ; diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_lease_inactive-lease inactive.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_lease_inactive-lease inactive.sql index 9c0d824458f..e490897ac97 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_lease_inactive-lease inactive.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_lease_inactive-lease inactive.sql @@ -1,20 +1,19 @@ -WITH to_update AS ( - SELECT guid FROM ( - SELECT - guid, - ROW_NUMBER() OVER (ORDER BY created ASC) AS rn - FROM "secret_secure_value" - WHERE +UPDATE + "secret_secure_value" +SET + "lease_token" = 'token', + "lease_created" = 10 +WHERE "guid" IN ( + SELECT "guid" FROM ( + SELECT + "guid", + ROW_NUMBER() OVER (ORDER BY "created" ASC) AS rn + FROM "secret_secure_value" + WHERE "active" = FALSE AND 10 - "created" > 300 AND 10 - "lease_created" > 30 ) AS sub WHERE rn <= 10 ) -UPDATE - "secret_secure_value" -SET - "lease_token" = 'token', - "lease_created" = 10 -WHERE guid IN (SELECT guid FROM to_update) ; diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_lease_inactive-lease inactive.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_lease_inactive-lease inactive.sql index 9c0d824458f..e490897ac97 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_lease_inactive-lease inactive.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_lease_inactive-lease inactive.sql @@ -1,20 +1,19 @@ -WITH to_update AS ( - SELECT guid FROM ( - SELECT - guid, - ROW_NUMBER() OVER (ORDER BY created ASC) AS rn - FROM "secret_secure_value" - WHERE +UPDATE + "secret_secure_value" +SET + "lease_token" = 'token', + "lease_created" = 10 +WHERE "guid" IN ( + SELECT "guid" FROM ( + SELECT + "guid", + ROW_NUMBER() OVER (ORDER BY "created" ASC) AS rn + FROM "secret_secure_value" + WHERE "active" = FALSE AND 10 - "created" > 300 AND 10 - "lease_created" > 30 ) AS sub WHERE rn <= 10 ) -UPDATE - "secret_secure_value" -SET - "lease_token" = 'token', - "lease_created" = 10 -WHERE guid IN (SELECT guid FROM to_update) ; From d83c35fd71f435dc0fdc83b6af43dd7bc3682d22 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 12 Nov 2025 15:32:21 +0100 Subject: [PATCH 162/209] Advisor: App installer setup (#113525) --- apps/advisor/README.md | 25 ---- apps/advisor/go.mod | 22 +--- apps/advisor/go.sum | 27 ----- apps/advisor/pkg/app/app.go | 4 + apps/advisor/pkg/app/authorizer.go | 18 ++- apps/advisor/pkg/app/authorizer_test.go | 9 ++ .../checkregistry/mockchecks/checkregistry.go | 59 --------- .../mockchecks/mocksvcs/datasourcesvc.go | 44 ------- .../mockchecks/mocksvcs/pluginclient.go | 19 --- .../mocksvcs/plugincontextprovider.go | 53 -------- .../mocksvcs/pluginerrorresolver.go | 19 --- .../mockchecks/mocksvcs/pluginrepo.go | 26 ---- .../mockchecks/mocksvcs/pluginstore.go | 114 ------------------ .../mockchecks/mocksvcs/updatechecker.go | 18 --- apps/advisor/pkg/standalone/server.go | 58 --------- .../src/types/featureToggles.gen.ts | 4 + pkg/registry/apps/advisor/appinstaller.go | 45 +++++++ pkg/registry/apps/apps.go | 6 + pkg/registry/apps/apps_test.go | 5 +- pkg/server/wire_gen.go | 12 +- pkg/server/wireexts_oss.go | 2 + pkg/services/featuremgmt/registry.go | 6 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 12 ++ 25 files changed, 125 insertions(+), 487 deletions(-) delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go delete mode 100644 apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go delete mode 100644 apps/advisor/pkg/standalone/server.go create mode 100644 pkg/registry/apps/advisor/appinstaller.go diff --git a/apps/advisor/README.md b/apps/advisor/README.md index 410a54f89e3..c7929d9db62 100644 --- a/apps/advisor/README.md +++ b/apps/advisor/README.md @@ -152,28 +152,3 @@ Check [`security_config_step.go`](./pkg/app/checks/configchecks/security_config_ ## Testing Create tests for your check and its steps to ensure they work as expected. Test both successful and failure scenarios. - -## Running the Standalone Mode - -To run the standalone mode, you can use the `make run` command. This will start the advisor app in standalone mode, which means it will not be running in a Kubernetes cluster. - -```bash -make etcd # Start etcd in a docker container -make run # Start the advisor app in standalone mode -``` - -This will start the advisor app on port 7445. You can then access the advisor app at `http://localhost:7445`. - -To see some sample checks, you can run the following command: - -```bash -make create-checks -``` - -Then you can see list in the URL: `http://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks` - -To delete all checks, you can run the following command: - -```bash -make delete-checks -``` diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index d646f81e055..834540f5f67 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -15,8 +15,6 @@ require ( github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.1 k8s.io/apiserver v0.34.1 - k8s.io/client-go v0.34.1 - k8s.io/component-base v0.34.1 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -45,7 +43,6 @@ replace github.com/grafana/grafana/apps/plugins => ../plugins replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 require ( - cel.dev/expr v0.24.0 // indirect cloud.google.com/go/compute/metadata v0.7.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect @@ -58,7 +55,6 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect @@ -89,7 +85,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect - github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -105,7 +100,6 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect @@ -148,7 +142,6 @@ require ( github.com/golang-migrate/migrate/v4 v4.7.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.26.1 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -168,7 +161,6 @@ require ( github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect - github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -183,7 +175,6 @@ require ( github.com/hashicorp/memberlist v0.5.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jaegertracing/jaeger-idl v0.5.0 // indirect github.com/jessevdk/go-flags v1.6.1 // indirect github.com/jmespath-community/go-jmespath v1.1.1 // indirect @@ -256,9 +247,7 @@ require ( github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wazero v1.8.2 // indirect github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect @@ -266,9 +255,6 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.etcd.io/etcd/api/v3 v3.6.4 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect - go.etcd.io/etcd/client/v3 v3.6.4 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect @@ -287,8 +273,6 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.6.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.43.0 // indirect @@ -311,25 +295,23 @@ require ( google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/telebot.v3 v3.3.8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 // indirect k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/client-go v0.34.1 // indirect + k8s.io/component-base v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kms v0.34.1 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.39.1 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // 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/advisor/go.sum b/apps/advisor/go.sum index 8d52f655960..b6232c51680 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -321,7 +321,6 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -446,8 +445,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= @@ -650,8 +647,6 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae h1:NLPwY3tIP0lg0g9wTRiMcypm6VRXW6W+MOLBsq8JSVA= github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= @@ -795,8 +790,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -1029,7 +1022,6 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= @@ -1047,7 +1039,6 @@ github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= @@ -1062,7 +1053,6 @@ github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57J github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= @@ -1089,7 +1079,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -1112,8 +1101,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -1127,7 +1114,6 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= @@ -1153,7 +1139,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -1170,8 +1155,6 @@ github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+Xq github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tjhop/slog-gokit v0.1.5 h1:ayloIUi5EK2QYB8eY4DOPO95/mRtMW42lUkp3quJohc= github.com/tjhop/slog-gokit v0.1.5/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= @@ -1189,8 +1172,6 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1215,12 +1196,6 @@ go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+ go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= go.etcd.io/etcd/client/v3 v3.6.4 h1:YOMrCfMhRzY8NgtzUsHl8hC2EBSnuqbR3dh84Uryl7A= go.etcd.io/etcd/client/v3 v3.6.4/go.mod h1:jaNNHCyg2FdALyKWnd7hxZXZxZANb0+KGY+YQaEMISo= -go.etcd.io/etcd/pkg/v3 v3.6.4 h1:fy8bmXIec1Q35/jRZ0KOes8vuFxbvdN0aAFqmEfJZWA= -go.etcd.io/etcd/pkg/v3 v3.6.4/go.mod h1:kKcYWP8gHuBRcteyv6MXWSN0+bVMnfgqiHueIZnKMtE= -go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= -go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= -go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= -go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= @@ -1373,7 +1348,6 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1785,7 +1759,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/apps/advisor/pkg/app/app.go b/apps/advisor/pkg/app/app.go index 0e423957b82..d4019631ef6 100644 --- a/apps/advisor/pkg/app/app.go +++ b/apps/advisor/pkg/app/app.go @@ -20,6 +20,10 @@ import ( ) func New(cfg app.Config) (app.App, error) { + // Needed until https://github.com/grafana/grafana-app-sdk/pull/1077 + if cfg.KubeConfig.APIPath == "" { + cfg.KubeConfig.APIPath = "apis" + } // Read config specificConfig, ok := cfg.SpecificConfig.(checkregistry.AdvisorAppConfig) if !ok { diff --git a/apps/advisor/pkg/app/authorizer.go b/apps/advisor/pkg/app/authorizer.go index 4d216ee9edd..576773330e3 100644 --- a/apps/advisor/pkg/app/authorizer.go +++ b/apps/advisor/pkg/app/authorizer.go @@ -3,6 +3,7 @@ package app import ( "context" + claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "k8s.io/apiserver/pkg/authorization/authorizer" ) @@ -15,7 +16,22 @@ func GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionNoOpinion, "", nil } - // require a user + // Check for service identity + if identity.IsServiceIdentity(ctx) { + return authorizer.DecisionAllow, "", nil + } + + // Check for access policy identity + info, ok := claims.AuthInfoFrom(ctx) + if ok && claims.IsIdentityType(info.GetIdentityType(), claims.TypeAccessPolicy) { + // For access policy identities, we need to use ResourceAuthorizer + // This requires an AccessClient, which should be provided by the API server + // For now, we'll use the default ResourceAuthorizer from the API server + // This will be set up by the API server's authorization chain + return authorizer.DecisionNoOpinion, "", nil + } + + // For regular Grafana users, check if they are admin u, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, "valid user is required", err diff --git a/apps/advisor/pkg/app/authorizer_test.go b/apps/advisor/pkg/app/authorizer_test.go index 1384176969e..de8c0fad2db 100644 --- a/apps/advisor/pkg/app/authorizer_test.go +++ b/apps/advisor/pkg/app/authorizer_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/stretchr/testify/assert" "k8s.io/apiserver/pkg/authorization/authorizer" @@ -79,4 +80,12 @@ func (m *mockUser) HasRole(role identity.RoleType) bool { return role == identity.RoleAdmin && m.isGrafanaAdmin } +func (m *mockUser) GetUID() string { + return "test-uid" +} + +func (m *mockUser) GetIdentityType() claims.IdentityType { + return claims.TypeUser +} + // Implement other methods of identity.Requester as needed diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go b/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go deleted file mode 100644 index e1b9cf88bf4..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go +++ /dev/null @@ -1,59 +0,0 @@ -package mockchecks - -import ( - "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs" - "github.com/grafana/grafana/apps/advisor/pkg/app/checks" - "github.com/grafana/grafana/apps/advisor/pkg/app/checks/datasourcecheck" - "github.com/grafana/grafana/apps/advisor/pkg/app/checks/plugincheck" - "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/repo" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" -) - -// mockchecks.CheckRegistry is a mock implementation of the checkregistry.CheckService interface -// TODO: Add mocked checks here -type CheckRegistry struct { - datasourceSvc datasources.DataSourceService - pluginStore pluginstore.Store - pluginClient plugins.Client - pluginRepo repo.Service - GrafanaVersion string - pluginContextProvider datasourcecheck.PluginContextProvider - updateChecker pluginchecker.PluginUpdateChecker - pluginErrorResolver plugins.ErrorResolver -} - -func (m *CheckRegistry) Checks() []checks.Check { - return []checks.Check{ - datasourcecheck.New( - m.datasourceSvc, - m.pluginStore, - m.pluginContextProvider, - m.pluginClient, - m.pluginRepo, - m.GrafanaVersion, - ), - plugincheck.New( - m.pluginStore, - m.pluginRepo, - m.updateChecker, - m.pluginErrorResolver, - m.GrafanaVersion, - ), - } -} - -func New() *CheckRegistry { - return &CheckRegistry{ - datasourceSvc: &mocksvcs.DatasourceSvc{}, - pluginStore: &mocksvcs.PluginStore{}, - pluginClient: &mocksvcs.PluginClient{}, - pluginRepo: &mocksvcs.PluginRepo{}, - pluginContextProvider: &mocksvcs.PluginContextProvider{}, - updateChecker: &mocksvcs.UpdateChecker{}, - pluginErrorResolver: &mocksvcs.PluginErrorResolver{}, - GrafanaVersion: "1.0.0", - } -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go deleted file mode 100644 index 73122e53adb..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/datasourcesvc.go +++ /dev/null @@ -1,44 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana/pkg/services/datasources" -) - -var dss = map[string]*datasources.DataSource{ - "prometheus-uid": { - ID: 1, - UID: "prometheus-uid", - Name: "Prometheus", - Type: "prometheus", - }, - "mysql-uid": { - ID: 2, - UID: "mysql-uid", - Name: "MySQL", - Type: "mysql", - }, - "unknown-uid": { - ID: 3, - UID: "unknown-uid", - Name: "Unknown", - Type: "unknown", - }, -} - -type DatasourceSvc struct { - datasources.DataSourceService -} - -func (m *DatasourceSvc) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) { - sources := make([]*datasources.DataSource, 0, len(dss)) - for _, ds := range dss { - sources = append(sources, ds) - } - return sources, nil -} - -func (m *DatasourceSvc) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { - return dss[query.UID], nil -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go deleted file mode 100644 index a8ce175a687..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginclient.go +++ /dev/null @@ -1,19 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/plugins" -) - -type PluginClient struct { - plugins.Client -} - -func (m *PluginClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - return &backend.CheckHealthResult{ - Status: backend.HealthStatusOk, - Message: "Plugin is healthy", - }, nil -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go deleted file mode 100644 index b5bb6f88e8e..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/plugincontextprovider.go +++ /dev/null @@ -1,53 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/datasources" -) - -type PluginContextProvider struct { -} - -// ACTUALLY USED by datasourcecheck -func (m *PluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) { - // Create a plugin context with sample data based on the datasource - pluginContext := backend.PluginContext{ - PluginID: pluginID, - PluginVersion: "1.0.0", - OrgID: 1, - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - ID: ds.ID, - UID: ds.UID, - Name: ds.Name, - URL: ds.URL, - JSONData: []byte(`{ - "httpMethod": "GET", - "timeout": "30s", - "keepCookies": [] - }`), - DecryptedSecureJSONData: map[string]string{ - "password": "sample-password", - "apiKey": "sample-api-key", - }, - }, - GrafanaConfig: backend.NewGrafanaCfg(map[string]string{ - "app_url": "http://localhost:3000", - "default_timezone": "UTC", - }), - } - - // Add user context if provided - if user != nil && !user.IsNil() { - pluginContext.User = &backend.User{ - Login: user.GetLogin(), - Name: user.GetName(), - Email: user.GetEmail(), - Role: string(user.GetOrgRole()), - } - } - - return pluginContext, nil -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go deleted file mode 100644 index db545827991..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginerrorresolver.go +++ /dev/null @@ -1,19 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana/pkg/plugins" -) - -type PluginErrorResolver struct { -} - -// Assume no plugin with errors -func (m *PluginErrorResolver) PluginErrors(ctx context.Context) []*plugins.Error { - return nil -} - -func (m *PluginErrorResolver) PluginError(ctx context.Context, pluginID string) *plugins.Error { - return nil -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go deleted file mode 100644 index 0ab8d225314..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginrepo.go +++ /dev/null @@ -1,26 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana/pkg/plugins/repo" -) - -type PluginRepo struct { - repo.Service -} - -func (m *PluginRepo) GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error) { - return []repo.PluginInfo{ - { - ID: 1, - Slug: "grafana-piechart-panel", - Version: "1.6.0", - }, - { - ID: 2, - Slug: "prometheus", - Version: "10.0.0", - }, - }, nil -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go deleted file mode 100644 index 782d93024f3..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/pluginstore.go +++ /dev/null @@ -1,114 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" -) - -type PluginStore struct { -} - -var ps = map[string]pluginstore.Plugin{ - "prometheus": { - JSONData: plugins.JSONData{ - ID: "prometheus", - Type: plugins.TypeDataSource, - Name: "Prometheus", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - }, - Version: "10.0.0", - }, - Category: "Time series databases", - State: plugins.ReleaseStateAlpha, - Backend: true, - Metrics: true, - Logs: true, - Alerting: true, - Explore: true, - }, - Class: plugins.ClassCore, - Signature: plugins.SignatureStatusInternal, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "grafana.com", - }, - "test-datasource": { - JSONData: plugins.JSONData{ - ID: "grafana-piechart-panel", - Type: plugins.TypePanel, - Name: "Pie Chart", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - }, - Version: "1.6.0", - }, - Category: "Visualization", - State: plugins.ReleaseStateAlpha, - }, - Class: plugins.ClassCore, - Signature: plugins.SignatureStatusInternal, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "grafana.com", - }, - "grafana-piechart-panel": { - JSONData: plugins.JSONData{ - ID: "prometheus", - Type: plugins.TypeDataSource, - Name: "Prometheus", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Grafana Labs", - }, - Version: "10.0.0", - }, - Category: "Time series databases", - State: plugins.ReleaseStateAlpha, - Backend: true, - Metrics: true, - Logs: true, - Alerting: true, - Explore: true, - }, - Class: plugins.ClassCore, - Signature: plugins.SignatureStatusInternal, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "grafana.com", - }, - "test-app": { - JSONData: plugins.JSONData{ - ID: "test-app", - Type: plugins.TypeApp, - Name: "Test App", - Info: plugins.Info{ - Author: plugins.InfoLink{ - Name: "Test Author", - }, - Version: "2.0.0", - }, - Category: "Application", - State: plugins.ReleaseStateAlpha, - AutoEnabled: true, - }, - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypeCommercial, - SignatureOrg: "test.com", - }, -} - -func (s *PluginStore) Plugin(ctx context.Context, pluginID string) (pluginstore.Plugin, bool) { - p, ok := ps[pluginID] - return p, ok -} - -func (s *PluginStore) Plugins(ctx context.Context, pluginTypes ...plugins.Type) []pluginstore.Plugin { - plugins := make([]pluginstore.Plugin, 0, len(ps)) - for _, p := range ps { - plugins = append(plugins, p) - } - return plugins -} diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go b/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go deleted file mode 100644 index efbeb217e74..00000000000 --- a/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs/updatechecker.go +++ /dev/null @@ -1,18 +0,0 @@ -package mocksvcs - -import ( - "context" - - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" -) - -type UpdateChecker struct { -} - -func (m *UpdateChecker) IsUpdatable(ctx context.Context, plugin pluginstore.Plugin) bool { - return true -} - -func (m *UpdateChecker) CanUpdate(pluginId string, currentVersion string, targetVersion string, onlyMinor bool) bool { - return true -} diff --git a/apps/advisor/pkg/standalone/server.go b/apps/advisor/pkg/standalone/server.go deleted file mode 100644 index 80dd82418ef..00000000000 --- a/apps/advisor/pkg/standalone/server.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "log/slog" - "os" - - "k8s.io/apiserver/pkg/admission" - genericapiserver "k8s.io/apiserver/pkg/server" - "k8s.io/client-go/rest" - "k8s.io/component-base/cli" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/k8s/apiserver" - "github.com/grafana/grafana-app-sdk/k8s/apiserver/cmd/server" - "github.com/grafana/grafana-app-sdk/logging" - "github.com/grafana/grafana-app-sdk/simple" - "github.com/grafana/grafana/apps/advisor/pkg/apis" - advisorapp "github.com/grafana/grafana/apps/advisor/pkg/app" - "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" - "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry/mockchecks" -) - -func main() { - logging.DefaultLogger = logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, - })) - provider := simple.NewAppProvider(apis.LocalManifest(), nil, advisorapp.New) - config := app.Config{ - KubeConfig: rest.Config{}, // this will be replaced by the apiserver loopback config - ManifestData: *apis.LocalManifest().ManifestData, - SpecificConfig: checkregistry.AdvisorAppConfig{ - CheckRegistry: mockchecks.New(), - PluginConfig: map[string]string{}, - StackID: "1", // Numeric stack ID for standalone mode - OrgService: nil, // Not needed when StackID is set - }, - } - installer, err := apiserver.NewDefaultAppInstaller(provider, config, &apis.GoTypeAssociator{}) - if err != nil { - panic(err) - } - ctx := genericapiserver.SetupSignalContext() - opts := apiserver.NewOptions([]apiserver.AppInstaller{installer}) - opts.RecommendedOptions.Authentication = nil - opts.RecommendedOptions.Authorization = nil - opts.RecommendedOptions.CoreAPI = nil - opts.RecommendedOptions.EgressSelector = nil - opts.RecommendedOptions.Admission.Plugins = admission.NewPlugins() - opts.RecommendedOptions.Admission.RecommendedPluginOrder = []string{} - opts.RecommendedOptions.Admission.EnablePlugins = []string{} - opts.RecommendedOptions.Features.EnablePriorityAndFairness = false - opts.RecommendedOptions.ExtraAdmissionInitializers = func(_ *genericapiserver.RecommendedConfig) ([]admission.PluginInitializer, error) { - return nil, nil - } - cmd := server.NewCommandStartServer(ctx, opts) - code := cli.Run(cmd) - os.Exit(code) -} diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a0f00b98b19..d52694ffaa1 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1222,6 +1222,10 @@ export interface FeatureToggles { */ dashboardTemplates?: boolean; /** + * Enables Advisor app installer + */ + grafanaAdvisorAppInstaller?: boolean; + /** * Enables app platform API for annotations * @default false */ diff --git a/pkg/registry/apps/advisor/appinstaller.go b/pkg/registry/apps/advisor/appinstaller.go new file mode 100644 index 00000000000..31afbed23c5 --- /dev/null +++ b/pkg/registry/apps/advisor/appinstaller.go @@ -0,0 +1,45 @@ +package advisor + +import ( + "github.com/grafana/grafana-app-sdk/app" + appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "github.com/grafana/grafana-app-sdk/simple" + advisorapi "github.com/grafana/grafana/apps/advisor/pkg/apis" + advisorapp "github.com/grafana/grafana/apps/advisor/pkg/app" + "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" + "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/rest" +) + +var ( + _ appsdkapiserver.AppInstaller = (*AdvisorAppInstaller)(nil) + _ appinstaller.AuthorizerProvider = (*AdvisorAppInstaller)(nil) +) + +type AdvisorAppInstaller struct { + appsdkapiserver.AppInstaller +} + +// GetAuthorizer returns the authorizer for the plugins app. +func (a *AdvisorAppInstaller) GetAuthorizer() authorizer.Authorizer { + return advisorapp.GetAuthorizer() +} + +func ProvideAppInstaller() (*AdvisorAppInstaller, error) { + provider := simple.NewAppProvider(advisorapi.LocalManifest(), nil, advisorapp.New) + specificConfig := checkregistry.AdvisorAppConfig{} + appConfig := app.Config{ + KubeConfig: rest.Config{}, + ManifestData: *advisorapi.LocalManifest().ManifestData, + SpecificConfig: specificConfig, + } + + installer := &AdvisorAppInstaller{} + i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, advisorapi.NewGoTypeAssociator()) + if err != nil { + return nil, err + } + installer.AppInstaller = i + return installer, nil +} diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index 5c039264a4b..db165c752f5 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -42,6 +42,7 @@ func ProvideAppInstallers( logsdrilldownAppInstaller *logsdrilldown.LogsDrilldownAppInstaller, annotationAppInstaller *annotation.AnnotationAppInstaller, exampleAppInstaller *example.ExampleAppInstaller, + advisorAppInstaller *advisor.AdvisorAppInstaller, ) []appsdkapiserver.AppInstaller { installers := []appsdkapiserver.AppInstaller{ playlistAppInstaller, @@ -71,6 +72,10 @@ func ProvideAppInstallers( if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAnnotations) { installers = append(installers, annotationAppInstaller) } + //nolint:staticcheck // not yet migrated to OpenFeature + if features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) && features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisorAppInstaller) { + installers = append(installers, advisorAppInstaller) + } return installers } @@ -118,6 +123,7 @@ func ProvideBuilderRunners( } //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) && + !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisorAppInstaller) && !slices.Contains(grafanaCfg.DisablePlugins, "grafana-advisor-app") { providers = append(providers, advisorAppProvider) } diff --git a/pkg/registry/apps/apps_test.go b/pkg/registry/apps/apps_test.go index 3c92bda62ad..5f46fd349be 100644 --- a/pkg/registry/apps/apps_test.go +++ b/pkg/registry/apps/apps_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/registry/apps/advisor" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -23,7 +24,7 @@ func TestProvideAppInstallers_Table(t *testing.T) { notificationsAppInstaller := ¬ifications.AlertingNotificationsAppInstaller{} annotationAppInstaller := &annotation.AnnotationAppInstaller{} exampleAppInstaller := &example.ExampleAppInstaller{} - + advisorAppInstaller := &advisor.AdvisorAppInstaller{} tests := []struct { name string flags []any @@ -39,7 +40,7 @@ func TestProvideAppInstallers_Table(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { features := featuremgmt.WithFeatures(tt.flags...) - got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller) + got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) if tt.expectRulesApp { require.Contains(t, got, tt.rulesInst) } else { diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 75aca8fcc9a..b10e7b81e3d 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -807,7 +807,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller) + advisorAppInstaller, err := advisor2.ProvideAppInstaller() + if err != nil { + return nil, err + } + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { @@ -1445,7 +1449,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller) + advisorAppInstaller, err := advisor2.ProvideAppInstaller() + if err != nil { + return nil, err + } + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller, appInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 1152431b320..b2b134ee4a9 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -20,6 +20,7 @@ import ( gsmKMSProviders "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/kmsproviders" "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper" secretService "github.com/grafana/grafana/pkg/registry/apis/secret/service" + "github.com/grafana/grafana/pkg/registry/apps/advisor" "github.com/grafana/grafana/pkg/registry/backgroundsvcs" "github.com/grafana/grafana/pkg/registry/usagestatssvcs" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -150,6 +151,7 @@ var wireExtsBasicSet = wire.NewSet( secret.ProvideSecureValueClient, provisioningExtras, configProviderExtras, + advisor.ProvideAppInstaller, ) var wireExtsSet = wire.NewSet( diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index cf7202d4120..b614d7ab63f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2120,6 +2120,12 @@ var ( Owner: grafanaSharingSquad, FrontendOnly: false, }, + { + Name: "grafanaAdvisorAppInstaller", + Description: "Enables Advisor app installer", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + }, { Name: "kubernetesAnnotations", Description: "Enables app platform API for annotations", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 317eff8c6bc..2865eb650c1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -272,4 +272,5 @@ pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,f onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false +grafanaAdvisorAppInstaller,experimental,@grafana/plugins-platform-backend,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 758d42e01ef..84e88452a10 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1098,6 +1098,10 @@ const ( // Enable template dashboards FlagDashboardTemplates = "dashboardTemplates" + // FlagGrafanaAdvisorAppInstaller + // Enables Advisor app installer + FlagGrafanaAdvisorAppInstaller = "grafanaAdvisorAppInstaller" + // FlagKubernetesAnnotations // Enables app platform API for annotations FlagKubernetesAnnotations = "kubernetesAnnotations" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4011467144c..7642f454ce3 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1808,6 +1808,18 @@ "codeowner": "@grafana/plugins-platform-backend" } }, + { + "metadata": { + "name": "grafanaAdvisorAppInstaller", + "resourceVersion": "1762790554324", + "creationTimestamp": "2025-11-10T16:02:34Z" + }, + "spec": { + "description": "Enables Advisor app installer", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, { "metadata": { "name": "grafanaAssistantInProfilesDrilldown", From 4b1fbcbd040b9cc1ce9f9942889dfaaee46705a3 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 12 Nov 2025 09:47:44 -0500 Subject: [PATCH 163/209] Cleanup: Remove CSV drag-and-drop snapshot query feature (#113645) * Chore: Remove editPanelCSVDragAndDrop feature * update i18n * fix issues from PR --- .../src/types/featureToggles.gen.ts | 4 - pkg/services/featuremgmt/registry.go | 7 -- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 1 + .../features/dataframe-import/constants.ts | 9 -- .../picker/DataSourceModal.test.tsx | 64 +--------- .../components/picker/DataSourceModal.tsx | 51 +------- .../picker/DataSourcePicker.test.tsx | 14 --- .../components/picker/DataSourcePicker.tsx | 47 +------ .../features/query/components/QueryGroup.tsx | 1 - .../grafana/components/QueryEditor.tsx | 115 +----------------- .../app/plugins/datasource/grafana/types.ts | 10 -- .../app/plugins/datasource/grafana/utils.ts | 20 +-- public/locales/en-US/grafana.json | 3 - 15 files changed, 17 insertions(+), 334 deletions(-) delete mode 100644 public/app/features/dataframe-import/constants.ts diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d52694ffaa1..93995707655 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -84,10 +84,6 @@ export interface FeatureToggles { */ alertingBacktesting?: boolean; /** - * Enables drag and drop for CSV and Excel files - */ - editPanelCSVDragAndDrop?: boolean; - /** * Allow datasource to provide custom UI for context view * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b614d7ab63f..0c06fe161bf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -124,13 +124,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, }, - { - Name: "editPanelCSVDragAndDrop", - Description: "Enables drag and drop for CSV and Excel files", - FrontendOnly: true, - Stage: FeatureStageExperimental, - Owner: grafanaDatavizSquad, - }, { Name: "logsContextDatasourceUi", Description: "Allow datasource to provide custom UI for context view", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 2865eb650c1..25b5520d042 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -14,7 +14,6 @@ cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,false,false,false showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,false,false,false mysqlAnsiQuotes,experimental,@grafana/search-and-storage,false,false,false alertingBacktesting,experimental,@grafana/alerting-squad,false,false,false -editPanelCSVDragAndDrop,experimental,@grafana/dataviz-squad,false,false,true logsContextDatasourceUi,GA,@grafana/observability-logs,false,false,true lokiShardSplitting,experimental,@grafana/observability-logs,false,false,true lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 84e88452a10..0b6fea3c1b4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -67,10 +67,6 @@ const ( // Rule backtesting API for alerting FlagAlertingBacktesting = "alertingBacktesting" - // FlagEditPanelCSVDragAndDrop - // Enables drag and drop for CSV and Excel files - FlagEditPanelCSVDragAndDrop = "editPanelCSVDragAndDrop" - // FlagLogsContextDatasourceUi // Allow datasource to provide custom UI for context view FlagLogsContextDatasourceUi = "logsContextDatasourceUi" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 7642f454ce3..c7217d11f9c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1357,6 +1357,7 @@ "name": "editPanelCSVDragAndDrop", "resourceVersion": "1762783224740", "creationTimestamp": "2023-01-24T09:43:44Z", + "deletionTimestamp": "2025-11-10T16:31:10Z", "annotations": { "grafana.app/updatedTimestamp": "2025-11-10 14:00:24.740459 +0000 UTC" } diff --git a/public/app/features/dataframe-import/constants.ts b/public/app/features/dataframe-import/constants.ts deleted file mode 100644 index 9753ce71f08..00000000000 --- a/public/app/features/dataframe-import/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Accept } from 'react-dropzone'; - -export const acceptedFiles: Accept = { - 'text/plain': ['.csv', '.txt'], - 'application/json': ['.json'], -}; - -//This should probably set from grafana conf -export const maxFileSize = 500000; diff --git a/public/app/features/datasources/components/picker/DataSourceModal.test.tsx b/public/app/features/datasources/components/picker/DataSourceModal.test.tsx index 39785614bdf..7ab78dda2ca 100644 --- a/public/app/features/datasources/components/picker/DataSourceModal.test.tsx +++ b/public/app/features/datasources/components/picker/DataSourceModal.test.tsx @@ -1,4 +1,4 @@ -import { queryByTestId, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -9,7 +9,6 @@ import { PluginType, locationUtil, } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { DataSourceModal, DataSourceModalProps } from './DataSourceModal'; @@ -106,44 +105,6 @@ describe('DataSourceDropdown', () => { }); }); - it('only displays the file drop area when the ff is enabled', async () => { - const defaultValue = config.featureToggles.editPanelCSVDragAndDrop; - config.featureToggles.editPanelCSVDragAndDrop = true; - setup({ uploadFile: true }); - - expect(await screen.queryByTestId('file-drop-zone-default-children')).toBeInTheDocument(); - config.featureToggles.editPanelCSVDragAndDrop = defaultValue; - }); - - it('does not show the file drop area when the ff is disabled', async () => { - const defaultValue = config.featureToggles.editPanelCSVDragAndDrop; - config.featureToggles.editPanelCSVDragAndDrop = false; - - setup({ uploadFile: true }); - expect(await screen.queryByTestId('file-drop-zone-default-children')).toBeNull(); - - config.featureToggles.editPanelCSVDragAndDrop = defaultValue; - }); - - it('should not display the drop zone by default', async () => { - const defaultValue = config.featureToggles.editPanelCSVDragAndDrop; - config.featureToggles.editPanelCSVDragAndDrop = true; - - const component = setup(); - - expect(queryByTestId(component.container, 'file-drop-zone-default-children')).toBeNull(); - config.featureToggles.editPanelCSVDragAndDrop = defaultValue; - }); - - it('should display the drop zone when uploadFile is enabled', async () => { - const defaultValue = config.featureToggles.editPanelCSVDragAndDrop; - config.featureToggles.editPanelCSVDragAndDrop = true; - setup({ uploadFile: true }); - - expect(await screen.queryByTestId('file-drop-zone-default-children')).toBeInTheDocument(); - config.featureToggles.editPanelCSVDragAndDrop = defaultValue; - }); - it('should fetch the DS applying the correct filters consistently across lists', async () => { const filters = { mixed: true, @@ -197,29 +158,6 @@ describe('DataSourceDropdown', () => { expect(await screen.findByText('No data sources found')).toBeInTheDocument(); }); - //Skipping this test as it's flaky on drone - it.skip('calls the onChange with the default query containing the file', async () => { - const user = userEvent.setup(); - config.featureToggles.editPanelCSVDragAndDrop = true; - const onChange = jest.fn(); - setup({ onChange, uploadFile: true }); - - const fileInput = ( - await screen.queryByTestId('file-drop-zone-default-children')! - ).parentElement!.parentElement!.querySelector('input'); - const file = new File([''], 'test.csv', { type: 'text/plain' }); - expect(fileInput).toBeInTheDocument(); - await user.upload(fileInput!, file); - const defaultQuery = onChange.mock.lastCall[1][0]; - expect(defaultQuery).toMatchObject({ - refId: 'A', - datasource: { type: 'grafana', uid: 'grafana' }, - queryType: 'snapshot', - file: { path: 'test.csv' }, - }); - config.featureToggles.editPanelCSVDragAndDrop = false; - }); - it('should call the onChange handler with the correct datasource', async () => { const user = userEvent.setup(); const onChange = jest.fn(); diff --git a/public/app/features/datasources/components/picker/DataSourceModal.tsx b/public/app/features/datasources/components/picker/DataSourceModal.tsx index 8bbf5f367a5..af40a1596d9 100644 --- a/public/app/features/datasources/components/picker/DataSourceModal.tsx +++ b/public/app/features/datasources/components/picker/DataSourceModal.tsx @@ -4,22 +4,12 @@ import { useEffect, useMemo, useState } from 'react'; import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { config, reportInteraction, useFavoriteDatasources } from '@grafana/runtime'; +import { reportInteraction, useFavoriteDatasources } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { - Modal, - FileDropzone, - FileDropzoneDefaultChildren, - useStyles2, - Input, - Icon, - ScrollContainer, -} from '@grafana/ui'; -import { acceptedFiles, maxFileSize } from 'app/features/dataframe-import/constants'; +import { Modal, useStyles2, Input, Icon, ScrollContainer } from '@grafana/ui'; import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; -import { getFileDropToQueryHandler } from 'app/plugins/datasource/grafana/utils'; -import { useDatasource, useDatasources } from '../../hooks'; +import { useDatasources } from '../../hooks'; import { AddNewDataSourceButton } from './AddNewDataSourceButton'; import { BuiltInDataSourceList } from './BuiltInDataSourceList'; @@ -29,7 +19,6 @@ import { matchDataSourceWithSearch } from './utils'; const INTERACTION_EVENT_NAME = 'dashboards_dspickermodal_clicked'; const INTERACTION_ITEM = { SELECT_DS: 'select_ds', - UPLOAD_FILE: 'upload_file', CONFIG_NEW_DS: 'config_new_ds', CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state', SEARCH: 'search', @@ -56,7 +45,6 @@ export interface DataSourceModalProps { alerting?: boolean; pluginId?: string; logs?: boolean; - uploadFile?: boolean; } export function DataSourceModal({ @@ -70,7 +58,6 @@ export function DataSourceModal({ alerting, pluginId, logs, - uploadFile, filter, onChange, current, @@ -96,8 +83,6 @@ export function DataSourceModal({ }); }; - const grafanaDS = useDatasource('-- Grafana --'); - // Get all datasources to report total_configured count const dataSources = useDatasources({ tracing, @@ -134,22 +119,6 @@ export function DataSourceModal({ [analyticsInteractionSrc] ); - const onFileDrop = getFileDropToQueryHandler((query, fileRejections) => { - if (!grafanaDS) { - return; - } - onChange(grafanaDS, [query]); - - reportInteraction(INTERACTION_EVENT_NAME, { - item: INTERACTION_ITEM.UPLOAD_FILE, - src: analyticsInteractionSrc, - }); - - if (fileRejections.length < 1) { - onDismiss(); - } - }); - // Built-in data sources used twice because of mobile layout adjustments // In movile the list is appended to the bottom of the DS list const BuiltInList = ({ className }: { className?: string }) => { @@ -231,20 +200,6 @@ export function DataSourceModal({
- {uploadFile && config.featureToggles.editPanelCSVDragAndDrop && ( - undefined} - options={{ - maxSize: maxFileSize, - multiple: false, - accept: acceptedFiles, - onDrop: onFileDrop, - }} - > - - - )}
diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx index eeef14c9532..ec55592a480 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx @@ -11,8 +11,6 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { ModalRoot, ModalsProvider } from '@grafana/ui'; -import config from 'app/core/config'; -import { defaultFileUploadQuery } from 'app/plugins/datasource/grafana/types'; import { DataSourcePicker, DataSourcePickerProps } from './DataSourcePicker'; import * as utils from './utils'; @@ -290,18 +288,6 @@ describe('DataSourcePicker', () => { expect(screen.getByRole('link')).toHaveAttribute('href', '/my-sub-path/connections/datasources/new'); }); - it('should call onChange with the default query when add csv is clicked', async () => { - config.featureToggles.editPanelCSVDragAndDrop = true; - const onChange = jest.fn(); - await setupOpenDropdown(user, { onChange, uploadFile: true }); - - await user.click(await screen.findByText('Add csv or spreadsheet')); - - expect(onChange.mock.lastCall[1]).toEqual([defaultFileUploadQuery]); - expect(screen.queryByText('Open advanced data source picker')).toBeNull(); //Drop down is closed - config.featureToggles.editPanelCSVDragAndDrop = false; - }); - it('should open the modal when open advanced is clicked', async () => { const props = { onChange: jest.fn(), current: mockDS1.name }; render( diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.tsx index fef92a8e0c0..fdc8d245e31 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.tsx @@ -14,9 +14,8 @@ import { Trans, t } from '@grafana/i18n'; import { FavoriteDatasources, reportInteraction, useFavoriteDatasources } from '@grafana/runtime'; import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; import { Button, floatingUtils, Icon, Input, ModalsController, Portal, ScrollContainer, useStyles2 } from '@grafana/ui'; -import config from 'app/core/config'; import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; -import { defaultFileUploadQuery, GrafanaQuery } from 'app/plugins/datasource/grafana/types'; +import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { useDatasource, useDatasources } from '../../hooks'; @@ -30,7 +29,6 @@ export const INTERACTION_ITEM = { SEARCH: 'search', OPEN_DROPDOWN: 'open_dspicker', SELECT_DS: 'select_ds', - ADD_FILE: 'add_file', OPEN_ADVANCED_DS_PICKER: 'open_advanced_ds_picker', CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state', TOGGLE_FAVORITE: 'toggle_favorite', @@ -58,7 +56,6 @@ export interface DataSourcePickerProps { alerting?: boolean; pluginId?: string; logs?: boolean; - uploadFile?: boolean; filter?: (ds: DataSourceInstanceSettings) => boolean; } @@ -99,7 +96,6 @@ export function DataSourcePicker(props: DataSourcePickerProps) { // Used to move the focus to the footer when tabbing from the input const [footerRef, setFooterRef] = useState(); const currentDataSourceInstanceSettings = useDatasource(current); - const grafanaDS = useDatasource('-- Grafana --'); const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings; const prefixIcon = filterTerm && isOpen ? : ; @@ -178,14 +174,6 @@ export function DataSourcePicker(props: DataSourcePickerProps) { markerElement?.focus(); } - function onClickAddCSV() { - if (!grafanaDS) { - return; - } - - onChange(grafanaDS, [defaultFileUploadQuery]); - } - function onKeyDownInput(keyEvent: React.KeyboardEvent) { // From the input, it navigates to the footer if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) { @@ -302,7 +290,6 @@ export function DataSourcePicker(props: DataSourcePickerProps) { } }} onClose={onClose} - onClickAddCSV={onClickAddCSV} onDismiss={onClose} onNavigateOutsiteFooter={onNavigateOutsiteFooter} dataSources={dataSources} @@ -335,7 +322,6 @@ function getStylesDropdown(theme: GrafanaTheme2, props: DataSourcePickerProps) { } export interface PickerContentProps extends DataSourcePickerProps { - onClickAddCSV?: () => void; keyboardEvents: Observable; style: React.CSSProperties; filterTerm?: string; @@ -348,7 +334,7 @@ export interface PickerContentProps extends DataSourcePickerProps { } const PickerContent = React.forwardRef((props, ref) => { - const { filterTerm, onChange, onClose, onClickAddCSV, current, filter, dataSources, favoriteDataSources } = props; + const { filterTerm, onChange, current, filter, dataSources, favoriteDataSources } = props; const changeCallback = useCallback( (ds: DataSourceInstanceSettings) => { @@ -357,12 +343,6 @@ const PickerContent = React.forwardRef((prop [onChange] ); - const clickAddCSVCallback = useCallback(() => { - onClickAddCSV?.(); - onClose(); - reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.ADD_FILE }); - }, [onClickAddCSV, onClose]); - const styles = useStyles2(getStylesPickerContent); return ( @@ -385,12 +365,7 @@ const PickerContent = React.forwardRef((prop > -
+
); @@ -427,20 +402,14 @@ function getStylesPickerContent(theme: GrafanaTheme2) { export interface FooterProps extends PickerContentProps {} -function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) { +function Footer({ onClose, onChange, ...props }: FooterProps) { const styles = useStyles2(getStylesFooter); - const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop; const onKeyDownLastButton = (e: React.KeyboardEvent) => { if (e.key === 'Tab') { props.onNavigateOutsiteFooter(e); } }; - const onKeyDownFirstButton = (e: React.KeyboardEvent) => { - if (e.key === 'Tab' && e.shiftKey) { - props.onNavigateOutsiteFooter(e); - } - }; return (
@@ -465,7 +434,6 @@ function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) { pluginId: props.pluginId, logs: props.logs, filter: props.filter, - uploadFile: props.uploadFile, current: props.current, onDismiss: hideModal, onChange: (ds, defaultQueries) => { @@ -477,18 +445,13 @@ function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) { reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER }); }} ref={props.footerRef} - onKeyDown={isUploadFileEnabled ? onKeyDownFirstButton : onKeyDownLastButton} + onKeyDown={onKeyDownLastButton} > Open advanced data source picker )} - {isUploadFileEnabled && ( - - )}
); } diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx index 9a3b7dc4908..e4d4f746ef8 100644 --- a/public/app/features/query/components/QueryGroup.tsx +++ b/public/app/features/query/components/QueryGroup.tsx @@ -499,7 +499,6 @@ function DataSourcePickerWithPrompt({ options, onChange, ...otherProps }: DataSo dashboard: true, variables: true, current: options.dataSource, - uploadFile: true, onChange: async (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => { await onChange(ds, defaultQueries); setIsDataSourceModalOpen(false); diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index 8afe9701082..4b98ef2bf84 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -1,39 +1,20 @@ -import { css } from '@emotion/css'; import pluralize from 'pluralize'; -import { PureComponent } from 'react'; import * as React from 'react'; -import { DropEvent, FileRejection } from 'react-dropzone'; -import { - QueryEditorProps, - SelectableValue, - rangeUtil, - DataQueryRequest, - DataFrameJSON, - dataFrameToJSON, - GrafanaTheme2, - getValueFormat, - formattedValueToString, - Field, -} from '@grafana/data'; -import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { QueryEditorProps, SelectableValue, rangeUtil, DataQueryRequest, Field } from '@grafana/data'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { InlineField, Select, Alert, Input, InlineFieldRow, - InlineLabel, - FileDropzone, - FileDropzoneDefaultChildren, - DropzoneFile, Themeable2, withTheme2, Stack, + InlineLabel, } from '@grafana/ui'; import { hasAlphaPanels } from 'app/core/config'; -import { acceptedFiles, maxFileSize } from 'app/features/dataframe-import/constants'; -import { filesToDataframes } from 'app/features/dataframe-import/utils'; import { getManagedChannelInfo } from 'app/features/live/info'; import { SearchQuery } from 'app/features/search/service/types'; @@ -53,7 +34,7 @@ interface State { folders?: Array>; } -export class UnthemedQueryEditor extends PureComponent { +export class UnthemedQueryEditor extends React.PureComponent { state: State = { channels: [], channelFields: {} }; queryTypes: Array> = [ @@ -91,13 +72,6 @@ export class UnthemedQueryEditor extends PureComponent { description: 'Search for grafana resources', }); } - if (config.featureToggles.editPanelCSVDragAndDrop) { - this.queryTypes.push({ - label: 'Spreadsheet or snapshot', - value: GrafanaQueryType.Snapshot, - description: 'Query an uploaded spreadsheet or a snapshot', - }); - } } loadChannelInfo() { @@ -356,43 +330,8 @@ export class UnthemedQueryEditor extends PureComponent { ); } - // Skip rendering the file list as we're handling that in this component instead. - fileListRenderer = (file: DropzoneFile, removeFile: (file: DropzoneFile) => void) => { - return null; - }; - - onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => { - filesToDataframes(acceptedFiles).subscribe((next) => { - const snapshot: DataFrameJSON[] = []; - next.dataFrames.forEach((df) => { - const dataframeJson = dataFrameToJSON(df); - snapshot.push(dataframeJson); - }); - this.props.onChange({ - ...this.props.query, - file: { name: next.file.name, size: next.file.size }, - queryType: GrafanaQueryType.Snapshot, - snapshot, - }); - this.props.onRunQuery(); - - reportInteraction('grafana_datasource_drop_files', { - number_of_files: fileRejections.length + acceptedFiles.length, - accepted_files: acceptedFiles.map((a) => { - return { type: a.type, size: a.size }; - }), - rejected_files: fileRejections.map((r) => { - return { type: r.file.type, size: r.file.size }; - }), - }); - }); - }; - renderSnapshotQuery() { - const { query, theme } = this.props; - const file = query.file; - const styles = getStyles(theme); - const fileSize = getValueFormat('decbytes')(file ? file.size : 0); + const { query } = this.props; return ( <> @@ -401,32 +340,6 @@ export class UnthemedQueryEditor extends PureComponent { {pluralize('frame', query.snapshot?.length ?? 0, true)} - {config.featureToggles.editPanelCSVDragAndDrop && ( - <> - - - - {file && ( -
- {file?.name} - - {formattedValueToString(fileSize)} - -
- )} - - )} ); } @@ -466,7 +379,7 @@ export class UnthemedQueryEditor extends PureComponent { // Only show "snapshot" when it already exists let queryTypes = this.queryTypes; - if (queryType === GrafanaQueryType.Snapshot && !config.featureToggles.editPanelCSVDragAndDrop) { + if (queryType === GrafanaQueryType.Snapshot) { queryTypes = [ ...this.queryTypes, { @@ -511,19 +424,3 @@ export class UnthemedQueryEditor extends PureComponent { } export const QueryEditor = withTheme2(UnthemedQueryEditor); - -function getStyles(theme: GrafanaTheme2) { - return { - file: css({ - width: '100%', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: theme.spacing(2), - border: `1px dashed ${theme.colors.border.medium}`, - backgroundColor: theme.colors.background.secondary, - marginTop: theme.spacing(1), - }), - }; -} diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts index d6efe49bc4f..fb86a9a1f21 100644 --- a/public/app/plugins/datasource/grafana/types.ts +++ b/public/app/plugins/datasource/grafana/types.ts @@ -53,16 +53,6 @@ export const defaultQuery: GrafanaQuery = { queryType: GrafanaQueryType.RandomWalk, }; -export const defaultFileUploadQuery: GrafanaQuery = { - refId: 'A', - datasource: { - type: 'grafana', - uid: 'grafana', - }, - queryType: GrafanaQueryType.Snapshot, - snapshot: [], -}; - //---------------------------------------------- // Annotations //---------------------------------------------- diff --git a/public/app/plugins/datasource/grafana/utils.ts b/public/app/plugins/datasource/grafana/utils.ts index e907d285d13..fad762c10f9 100644 --- a/public/app/plugins/datasource/grafana/utils.ts +++ b/public/app/plugins/datasource/grafana/utils.ts @@ -1,13 +1,10 @@ -import { DropEvent, FileRejection } from 'react-dropzone'; - import { DataFrame, DataFrameJSON, dataFrameToJSON } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { filesToDataframes } from 'app/features/dataframe-import/utils'; import { ShowConfirmModalEvent } from 'app/types/events'; -import { defaultFileUploadQuery, GrafanaQuery, GrafanaQueryType } from './types'; +import { GrafanaQuery, GrafanaQueryType } from './types'; /** * Will show a confirm modal if the current panel does not have a snapshot query. @@ -57,18 +54,3 @@ function updateSnapshotData(frames: DataFrame[], panel: PanelModel) { panel.refresh(); } - -export function getFileDropToQueryHandler( - onFileLoaded: (query: GrafanaQuery, fileRejections: FileRejection[]) => void -) { - return (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => { - filesToDataframes(acceptedFiles).subscribe(async (next) => { - const snapshot: DataFrameJSON[] = []; - next.dataFrames.forEach((df: DataFrame) => { - const dataframeJson = dataFrameToJSON(df); - snapshot.push(dataframeJson); - }); - onFileLoaded({ ...defaultFileUploadQuery, ...{ snapshot: snapshot, file: next.file } }, fileRejections); - }); - }; -} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0a6e66ac0f7..b59d15da0bd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "More details about the error" }, - "footer": { - "add-csv-or-spreadsheet": "Add csv or spreadsheet" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics datasource", From 16ddd536ab42cc1f9e6b37ad1a555e32450a4964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Wed, 12 Nov 2025 15:55:59 +0100 Subject: [PATCH 164/209] fix(dashboard): proper check uint32 size (#113760) --- apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index d47fbbc5ec1..587e7500e49 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "strconv" "strings" @@ -1726,7 +1727,7 @@ func buildAnnotationFilter(filterMap map[string]interface{}) *dashv2alpha1.Dashb uintIds = append(uintIds, uint32(v)) } case int: - if v >= 0 && v <= int(^uint32(0)) { + if v >= 0 && v <= math.MaxUint32 { uintIds = append(uintIds, uint32(v)) } } From 0392bf57247e4b6871869db68609fc2bc749f9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 12 Nov 2025 16:04:35 +0100 Subject: [PATCH 165/209] ExternalPlugins: Restore backward compatability for util function (#113735) * ExternalPlugins: Restore backward compatability for util function * tweak * fix withValue => withTitle --- packages/grafana-ui/src/options/builder/text.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/options/builder/text.tsx b/packages/grafana-ui/src/options/builder/text.tsx index 79ec1bbb8b8..4bd507aaf10 100644 --- a/packages/grafana-ui/src/options/builder/text.tsx +++ b/packages/grafana-ui/src/options/builder/text.tsx @@ -9,8 +9,13 @@ import { OptionsWithTextFormatting } from '@grafana/schema'; */ export function addTextSizeOptions( builder: PanelOptionsEditorBuilder, - options: { withValue?: boolean; withTitle?: boolean; withPercentChange?: boolean } + options: { withValue?: boolean; withTitle?: boolean; withPercentChange?: boolean } = { withTitle: true } ) { + // if called from old plugins when parameter was withTitle boolean + if (typeof options === 'boolean') { + options = { withTitle: options }; + } + const category = [t('grafana-ui.builder.text.category-text-size', 'Text size')]; if (options.withTitle) { builder.addNumberInput({ From d1d8aa7c14fcbd1a6740f94226b0454d8840e854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 12 Nov 2025 16:04:54 +0100 Subject: [PATCH 166/209] PanelEdit: Remove double border when viz picker is open (#113729) --- .../dashboard-scene/panel-edit/PanelVizTypePicker.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index 8166b82d071..a57aac9d427 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -138,10 +138,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: theme.spacing(2, 1), height: '100%', gap: theme.spacing(2), - border: `1px solid ${theme.colors.border.weak}`, - borderRight: 'none', - borderBottom: 'none', - borderTopLeftRadius: theme.shape.radius.default, }), searchRow: css({ display: 'flex', From a8dda428ceea4b61d5d292468cf067128180e692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Wed, 12 Nov 2025 16:35:38 +0100 Subject: [PATCH 167/209] fix(dashboard): proper check int size (#113766) --- apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 587e7500e49..c514728509e 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1727,7 +1727,7 @@ func buildAnnotationFilter(filterMap map[string]interface{}) *dashv2alpha1.Dashb uintIds = append(uintIds, uint32(v)) } case int: - if v >= 0 && v <= math.MaxUint32 { + if v >= 0 && uint64(v) <= math.MaxUint32 { uintIds = append(uintIds, uint32(v)) } } From 1f558b1e066ed124c408ed52e95ce60bccfc135b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 12 Nov 2025 16:40:46 +0100 Subject: [PATCH 168/209] PanelChrome: Feature toggle increased panel header height and padding (#112613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PanelChrome: Feature toggle for increase header and content panel padding * Update * Update to panel menu * Fix lint * Revert theme feature ttoggle changes Signed-off-by: Torkel Ödegaard * Update * fix storybook * Update --------- Signed-off-by: Torkel Ödegaard --- .../src/types/featureToggles.gen.ts | 5 ++++ packages/grafana-runtime/src/config.ts | 2 ++ .../components/PanelChrome/PanelChrome.tsx | 13 ++++++--- .../src/components/PanelChrome/PanelMenu.tsx | 8 +++--- .../components/PanelChrome/PanelStatus.tsx | 8 +++--- .../src/components/PanelChrome/TitleItem.tsx | 2 +- pkg/services/featuremgmt/registry.go | 8 ++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 +++ pkg/services/featuremgmt/toggles_gen.json | 27 +++++++++++++++++++ 10 files changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 93995707655..ca060edf289 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1205,6 +1205,11 @@ export interface FeatureToggles { */ pluginStoreServiceLoading?: boolean; /** + * Increases panel padding globally + * @default false + */ + newPanelPadding?: boolean; + /** * When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission * @default true */ diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index d2911bf7770..4c7a2714cd6 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -282,6 +282,8 @@ export class GrafanaBootConfig { overrideFeatureTogglesFromUrl(this); overrideFeatureTogglesFromLocalStorage(this); + this.bootData.settings.featureToggles = this.featureToggles; + // Creating theme after applying feature toggle overrides in case we need to toggle anything this.theme2 = getThemeById(this.bootData.user.theme); this.bootData.user.lightTheme = this.theme2.isLight; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 05d3ae406e5..3dcfa47c36c 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -436,6 +436,10 @@ const itemsRenderer = (items: ReactNode[] | ReactNode, renderer: (items: ReactNo const getHeaderHeight = (theme: GrafanaTheme2, hasHeader: boolean) => { if (hasHeader) { + if (getFeatureToggle('newPanelPadding')) { + return theme.spacing.gridSize * 5; + } + return theme.spacing.gridSize * theme.components.panel.headerHeight; } @@ -477,7 +481,8 @@ const getContentStyle = ( }; const getStyles = (theme: GrafanaTheme2) => { - const { background, borderColor, padding } = theme.components.panel; + const { background, borderColor } = theme.components.panel; + const newPanelPadding = getFeatureToggle('newPanelPadding'); return { container: css({ @@ -552,6 +557,9 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'panel-header', display: 'flex', alignItems: 'center', + // remove logic after newPanelPadding feature toggle is removed + padding: newPanelPadding ? theme.spacing(0, 1, 0, 1.5) : theme.spacing(0, 0.5, 0, 1), + gap: theme.spacing(1), }), pointer: css({ cursor: 'pointer', @@ -568,7 +576,6 @@ const getStyles = (theme: GrafanaTheme2) => { title: css({ label: 'panel-title', display: 'flex', - padding: theme.spacing(0, padding), minWidth: 0, '& > h2': { minWidth: 0, @@ -602,7 +609,6 @@ const getStyles = (theme: GrafanaTheme2) => { }), rightActions: css({ display: 'flex', - padding: theme.spacing(0, padding), gap: theme.spacing(1), }), rightAligned: css({ @@ -614,6 +620,7 @@ const getStyles = (theme: GrafanaTheme2) => { titleItems: css({ display: 'flex', height: '100%', + alignItems: 'center', }), clearButtonStyles: css({ alignItems: 'center', diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx index 5be2a7913f3..9da428b22ef 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelMenu.tsx @@ -4,8 +4,8 @@ import { ReactElement, useCallback } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; +import { Button } from '../Button/Button'; import { Dropdown } from '../Dropdown/Dropdown'; -import { ToolbarButton } from '../ToolbarButton/ToolbarButton'; import { TooltipPlacement } from '../Tooltip/types'; interface PanelMenuProps { @@ -40,12 +40,12 @@ export function PanelMenu({ return ( - diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx index 7eac0235289..0d925147575 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx @@ -31,17 +31,15 @@ export function PanelStatus({ message, onClick, ariaLabel = 'status' }: Props) { } const getStyles = (theme: GrafanaTheme2) => { - const { headerHeight, padding } = theme.components.panel; - return { buttonStyles: css({ label: 'panel-header-state-button', display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: theme.spacing(padding), - width: theme.spacing(headerHeight), - height: theme.spacing(headerHeight), + padding: theme.spacing(1), + width: theme.spacing(theme.components.height.md), + height: theme.spacing(theme.components.height.md), borderRadius: theme.shape.radius.default, }), }; diff --git a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx index 18fee0039ad..008ae1d6d48 100644 --- a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx @@ -68,7 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => { border: 'none', borderRadius: `${theme.shape.radius.default}`, padding: `${theme.spacing(0, 1)}`, - height: `${theme.spacing(theme.components.panel.headerHeight)}`, + height: `${theme.spacing(theme.components.height.md)}`, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 0c06fe161bf..e36d0a179b7 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2085,6 +2085,14 @@ var ( Owner: grafanaPluginsPlatformSquad, Expression: "false", }, + { + Name: "newPanelPadding", + Description: "Increases panel padding globally", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaDashboardsSquad, + Expression: "false", + }, { Name: "onlyStoreActionSets", Description: "When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 25b5520d042..5af160f478d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -268,6 +268,7 @@ newGauge,experimental,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false +newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0b6fea3c1b4..805e9e25f7b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1082,6 +1082,10 @@ const ( // Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded FlagPluginStoreServiceLoading = "pluginStoreServiceLoading" + // FlagNewPanelPadding + // Increases panel padding globally + FlagNewPanelPadding = "newPanelPadding" + // FlagOnlyStoreActionSets // When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission FlagOnlyStoreActionSets = "onlyStoreActionSets" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index c7217d11f9c..66991f3e102 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2853,6 +2853,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "newPanelPadding", + "resourceVersion": "1760780310038", + "creationTimestamp": "2025-10-18T09:38:30Z" + }, + "spec": { + "description": "Increases panel padding globally", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "expression": "false" + } + }, { "metadata": { "name": "newShareReportDrawer", @@ -2954,6 +2967,20 @@ "expression": "true" } }, + { + "metadata": { + "name": "panelPadding", + "resourceVersion": "1760779980125", + "creationTimestamp": "2025-10-18T09:33:00Z", + "deletionTimestamp": "2025-10-18T09:38:30Z" + }, + "spec": { + "description": "Increases panel padding globally", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "expression": "false" + } + }, { "metadata": { "name": "panelTimeSettings", From 06954b7b0ad3b9ab0611c1cd1112bf6e3401bf49 Mon Sep 17 00:00:00 2001 From: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:49:28 +0000 Subject: [PATCH 169/209] Caching: Remove tsl memcached feature toggle (#113715) * Remove tsl memcached feature toggle * Remove feature flag from docs --- .../configure-grafana/enterprise-configuration/index.md | 4 ---- .../configure-grafana/feature-toggles/index.md | 1 - packages/grafana-data/src/types/featureToggles.gen.ts | 5 ----- pkg/services/featuremgmt/registry.go | 8 -------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- 7 files changed, 2 insertions(+), 24 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md b/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md index 82eec98249a..601a258764f 100644 --- a/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md +++ b/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md @@ -555,10 +555,6 @@ A space-separated list of memcached servers. Example: `memcached-server-1:11211 The default is `"localhost:11211"`. -{{< admonition type="note" >}} -The following memcached configuration requires the `tlsMemcached` feature toggle. -{{< /admonition >}} - ### tls_enabled Enables TLS authentication for memcached. Defaults to `false`. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 3dc9644a371..a060cc4f750 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -52,7 +52,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes | | `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes | | `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes | -| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes | | `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes | | `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ca060edf289..003915fdf0b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -501,11 +501,6 @@ export interface FeatureToggles { */ newPDFRendering?: boolean; /** - * Use TLS-enabled memcached in the enterprise caching feature - * @default true - */ - tlsMemcached?: boolean; - /** * Enable grafana's embedded kube-aggregator */ kubernetesAggregator?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e36d0a179b7..4e9ae6ad99b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -846,14 +846,6 @@ var ( Owner: grafanaOperatorExperienceSquad, Expression: "true", // enabled by default, }, - { - Name: "tlsMemcached", - Description: "Use TLS-enabled memcached in the enterprise caching feature", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOperatorExperienceSquad, - Expression: "true", - AllowSelfServe: false, // the non-tls implementation is slated for removal - }, { Name: "kubernetesAggregator", Description: "Enable grafana's embedded kube-aggregator", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 5af160f478d..c9feefaf19c 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -111,7 +111,6 @@ sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,fa sqlExpressionsColumnAutoComplete,experimental,@grafana/datapro,false,false,true groupToNestedTableTransformation,GA,@grafana/datapro,false,false,true newPDFRendering,GA,@grafana/grafana-operator-experience-squad,false,false,false -tlsMemcached,GA,@grafana/grafana-operator-experience-squad,false,false,false kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesAggregatorCapTokenAuth,experimental,@grafana/grafana-app-platform-squad,false,true,false groupByVariable,experimental,@grafana/dashboards-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 805e9e25f7b..1e09c590853 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -455,10 +455,6 @@ const ( // New implementation for the dashboard-to-PDF rendering FlagNewPDFRendering = "newPDFRendering" - // FlagTlsMemcached - // Use TLS-enabled memcached in the enterprise caching feature - FlagTlsMemcached = "tlsMemcached" - // FlagKubernetesAggregator // Enable grafana's embedded kube-aggregator FlagKubernetesAggregator = "kubernetesAggregator" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 66991f3e102..d6131bfe044 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4047,7 +4047,8 @@ "metadata": { "name": "tlsMemcached", "resourceVersion": "1753448760331", - "creationTimestamp": "2024-05-09T19:12:08Z" + "creationTimestamp": "2024-05-09T19:12:08Z", + "deletionTimestamp": "2025-11-11T13:50:51Z" }, "spec": { "description": "Use TLS-enabled memcached in the enterprise caching feature", From 5ca48bc73d67894a767066fc600dad289d58debe Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 12 Nov 2025 17:03:02 +0100 Subject: [PATCH 170/209] folder-operator: use new zanzana write APIs (#113732) --- apps/iam/pkg/reconcilers/zanzana_service.go | 115 +++++--------------- 1 file changed, 29 insertions(+), 86 deletions(-) diff --git a/apps/iam/pkg/reconcilers/zanzana_service.go b/apps/iam/pkg/reconcilers/zanzana_service.go index 10897a446c7..fd666023228 100644 --- a/apps/iam/pkg/reconcilers/zanzana_service.go +++ b/apps/iam/pkg/reconcilers/zanzana_service.go @@ -5,12 +5,13 @@ import ( "fmt" "strings" - authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/grafana/grafana/pkg/services/authz/zanzana" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" ) type ZanzanaPermissionStore struct { @@ -34,44 +35,22 @@ func (c *ZanzanaPermissionStore) SetFolderParent(ctx context.Context, namespace, ) defer span.End() - err := c.DeleteFolderParents(ctx, namespace, folderUID) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to delete existing folder parents") - return err - } - - if parentUID == "" { - // Setting the parent to empty means the folder is at root which Zanzana doesn't care about. - return nil - } - - user, err := toFolderTuple(parentUID) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to create parent tuple") - return err - } - - object, err := toFolderTuple(folderUID) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to create folder tuple") - return err - } - - if err := c.zanzanaClient.Write(ctx, &authzextv1.WriteRequest{ + if err := c.zanzanaClient.Mutate(ctx, &authzextv1.MutateRequest{ Namespace: namespace, - Writes: &authzextv1.WriteRequestWrites{ - TupleKeys: []*authzextv1.TupleKey{{ - User: user, - Relation: zanzana.RelationParent, - Object: object, - }}, + Operations: []*authzextv1.MutateOperation{ + { + Operation: &authzextv1.MutateOperation_SetFolderParent{ + SetFolderParent: &authzextv1.SetFolderParentOperation{ + Folder: folderUID, + Parent: parentUID, + DeleteExisting: true, + }, + }, + }, }, }); err != nil { span.RecordError(err) - span.SetStatus(codes.Error, "failed to write parent tuple to zanzana") + span.SetStatus(codes.Error, "failed to update folder parent in zanzana") return err } @@ -133,24 +112,23 @@ func (c *ZanzanaPermissionStore) DeleteFolderParents(ctx context.Context, namesp ) defer span.End() - tuples, err := c.listFolderParentRelations(ctx, namespace, folderUID) - if err != nil { + if err := c.zanzanaClient.Mutate(ctx, &authzextv1.MutateRequest{ + Namespace: namespace, + Operations: []*authzextv1.MutateOperation{ + { + Operation: &authzextv1.MutateOperation_DeleteFolder{ + DeleteFolder: &authzextv1.DeleteFolderOperation{ + Folder: folderUID, + DeleteExisting: true, + }, + }, + }, + }, + }); err != nil { span.RecordError(err) - span.SetStatus(codes.Error, "failed to list folder parent relations") + span.SetStatus(codes.Error, "failed to delete folder parents in zanzana") return err } - - span.SetAttributes(attribute.Int("tuples.toDelete.count", len(tuples))) - - if len(tuples) > 0 { - err = c.deleteTuples(ctx, namespace, tuples) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to delete tuples") - return err - } - } - return nil } @@ -212,41 +190,6 @@ func (c *ZanzanaPermissionStore) listFolderParentRelations(ctx context.Context, return list.Tuples, nil } -func (c *ZanzanaPermissionStore) deleteTuples(ctx context.Context, namespace string, tuples []*authzextv1.Tuple) error { - tracer := otel.GetTracerProvider().Tracer("zanzana-folder-reconciler") - ctx, span := tracer.Start(ctx, "zanzana-permission-store.delete-tuples", - trace.WithAttributes( - attribute.String("namespace", namespace), - attribute.Int("tuples.count", len(tuples)), - ), - ) - defer span.End() - - tupleKeys := make([]*authzextv1.TupleKeyWithoutCondition, 0, len(tuples)) - for _, t := range tuples { - tupleKeys = append(tupleKeys, &authzextv1.TupleKeyWithoutCondition{ - User: t.Key.User, - Relation: t.Key.Relation, - Object: t.Key.Object, - }) - } - - err := c.zanzanaClient.Write(ctx, &authzextv1.WriteRequest{ - Namespace: namespace, - Deletes: &authzextv1.WriteRequestDeletes{ - TupleKeys: tupleKeys, - }, - }) - - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to delete tuples in zanzana") - return err - } - - return nil -} - func toFolderTuple(UID string) (string, error) { if strings.ContainsAny(UID, "#:") { return "", fmt.Errorf("UID contains invalid characters: %s", UID) From d35524915cc95289a428c43c5781943c3c80ff3b Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Wed, 12 Nov 2025 17:06:09 +0100 Subject: [PATCH 171/209] BackendSrv: Remove extra `console.logs` in `chunked` (#113446) BackendSrv: Remove extra `console.logs` --- public/app/core/services/backend_srv.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 2f6aed7e115..2918263b6e4 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -173,7 +173,6 @@ export class BackendSrv implements BackendService { .subscribe(this.getChunkedResponseObserver({ controller, observer, options, requestId })); return function unsubscribe() { - console.log(requestId, 'unsubscribe'); controller.abort('unsubscribe'); sub.unsubscribe(); }; @@ -218,7 +217,6 @@ export class BackendSrv implements BackendService { // Setup onabort callback so that we can cancel the reader properly controller.signal.onabort = () => { reader.cancel(controller.signal.reason); - console.log(requestId, 'signal.aborted'); }; async function process() { @@ -230,13 +228,11 @@ export class BackendSrv implements BackendService { }); if (chunk.done) { done = true; - console.log(requestId, 'done'); } } } process() .then(() => { - console.log(requestId, 'complete'); observer.complete(); }) // runs in background .catch((e) => { From 6eabb9b2e48e0fad12c503c6dc12aa910b3cac6a Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:13:31 +0100 Subject: [PATCH 172/209] Dashboard Library: Implement analytics tracking for Suggested Dashboards (#113417) Implement analytics tracking for Suggested Dashboard * loaded - Tracks when library content becomes available * searchPerformed - Tracks search behavior (privacy-preserving, no query text) * itemClicked - Tracks dashboard selection * mappingFormShown - Tracks when datasource mapping form is displayed * mappingFormCompleted - Tracks successful mapping form completion --------- Co-authored-by: Juan Cabanas Co-authored-by: nmarrs --- .../dashboard-scene/utils/tracking.ts | 9 +- .../BasicProvisionedDashboardsEmptyPage.tsx | 20 ++-- .../CommunityDashboardMappingForm.tsx | 55 +++++++++-- .../CommunityDashboardSection.tsx | 54 +++++++---- .../DashboardLibrarySection.tsx | 36 ++++--- .../DashboardLibrary/SuggestedDashboards.tsx | 54 ++++++++--- .../SuggestedDashboardsModal.tsx | 13 ++- .../api/dashboardLibraryApi.ts | 19 +++- .../dashgrid/DashboardLibrary/interactions.ts | 76 +++++++++++++-- .../utils/autoMapDatasources.ts | 10 +- .../utils/communityDashboardHelpers.ts | 93 +++++++++++++++---- 11 files changed, 341 insertions(+), 98 deletions(-) diff --git a/public/app/features/dashboard-scene/utils/tracking.ts b/public/app/features/dashboard-scene/utils/tracking.ts index 1b625612274..0be4cdd05ed 100644 --- a/public/app/features/dashboard-scene/utils/tracking.ts +++ b/public/app/features/dashboard-scene/utils/tracking.ts @@ -1,5 +1,6 @@ import { store } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { extractDatasourceTypesFromUrl } from 'app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers'; import { DashboardScene } from '../scene/DashboardScene'; import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; @@ -55,16 +56,18 @@ export function trackDashboardSceneCreatedOrSaved( ) { // url values for dashboard library experiment const urlParams = new URLSearchParams(window.location.search); - const pluginId = urlParams.get('pluginId') || undefined; const sourceEntryPoint = urlParams.get('sourceEntryPoint') || undefined; - const libraryItemId = urlParams.get('libraryItemId') || undefined; + // For community dashboards, use gnetId as libraryItemId if libraryItemId is not present + const libraryItemId = urlParams.get('libraryItemId') || urlParams.get('gnetId') || undefined; const creationOrigin = urlParams.get('creationOrigin') || undefined; + // Extract datasourceTypes from URL params (supports both community and provisioned dashboards) + const datasourceTypes = extractDatasourceTypesFromUrl(); const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation(); const dashboardLibraryProperties = config.featureToggles.dashboardLibrary ? { - datasourceTypes: [pluginId], + datasourceTypes, sourceEntryPoint, libraryItemId, creationOrigin, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx index de2145c2750..91ed79019fe 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx @@ -11,7 +11,14 @@ import { PluginDashboard } from 'app/types/plugins'; import { DASHBOARD_LIBRARY_ROUTES } from '../types'; import { fetchProvisionedDashboards } from './api/dashboardLibraryApi'; -import { DashboardLibraryInteractions } from './interactions'; +import { + CONTENT_KINDS, + CREATION_ORIGINS, + DashboardLibraryInteractions, + DISCOVERY_METHODS, + EVENT_LOCATIONS, + SOURCE_ENTRY_POINTS, +} from './interactions'; import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; interface Props { @@ -42,12 +49,13 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) => const onImportDashboardClick = async (dashboard: PluginDashboard) => { DashboardLibraryInteractions.itemClicked({ - contentKind: 'datasource_dashboard', + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, datasourceTypes: [dashboard.pluginId], libraryItemId: dashboard.uid, libraryItemTitle: dashboard.title, - sourceEntryPoint: 'datasource_page', - eventLocation: 'empty_dashboard', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + discoveryMethod: DISCOVERY_METHODS.BROWSE, }); const params = new URLSearchParams({ @@ -56,9 +64,9 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) => pluginId: dashboard.pluginId, path: dashboard.path, // tracking event purpose values - sourceEntryPoint: 'datasource_page', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, libraryItemId: dashboard.uid, - creationOrigin: 'dashboard_library_datasource_dashboard', + creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD, }); const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx index 27b0a352ec1..97c1e1d4de3 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; @@ -7,14 +7,20 @@ import { Stack, Text, Button, Alert, Field, Input, Box } from '@grafana/ui'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; +import { ContentKind, DashboardLibraryInteractions, EventLocation, SOURCE_ENTRY_POINTS } from './interactions'; import { InputMapping, mapConstantInputs, mapUserSelectedDatasources } from './utils/autoMapDatasources'; interface Props { - unmappedInputs: DataSourceInput[]; + unmappedDsInputs: DataSourceInput[]; constantInputs: DashboardInput[]; existingMappings: InputMapping[]; onBack: () => void; onPreview: (allMappings: InputMapping[]) => void; + dashboardName: string; + libraryItemId: string; + eventLocation: EventLocation; + contentKind: ContentKind; + datasourceTypes: string[]; } interface UserSelectedDatasourceMappings { @@ -24,16 +30,36 @@ interface UserSelectedDatasourceMappings { } export const CommunityDashboardMappingForm = ({ - unmappedInputs, + unmappedDsInputs, constantInputs, existingMappings, onBack, onPreview, + dashboardName, + libraryItemId, + eventLocation, + contentKind, + datasourceTypes, }: Props) => { + // Track mapping form shown on mount + useEffect(() => { + DashboardLibraryInteractions.mappingFormShown({ + contentKind, + datasourceTypes, + libraryItemId, + libraryItemTitle: dashboardName, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation, + unmappedDsInputsCount: unmappedDsInputs.length, + constantInputsCount: constantInputs.length, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [userSelectedDsMappings, setUserSelectedDsMappings] = useState>( () => { // Initialize with existing unmapped inputs - return unmappedInputs.reduce>((acc, input) => { + return unmappedDsInputs.reduce>((acc, input) => { const unmappedInput = { name: input.name, pluginId: input.pluginId, @@ -71,12 +97,25 @@ export const CommunityDashboardMappingForm = ({ }; const onPreviewClick = () => { + // Track mapping form completion + DashboardLibraryInteractions.mappingFormCompleted({ + contentKind, + datasourceTypes, + libraryItemId, + libraryItemTitle: dashboardName, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation, + userMappedCount: unmappedDsInputs.length, + autoMappedCount: existingMappings.length, + }); + // Combine all mappings: // 1. Existing auto-mapped datasources // 2. User-selected datasources // 3. Constant values (user-edited or defaults) - const userSelectedDatasources = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings); + const userSelectedDatasources = mapUserSelectedDatasources(unmappedDsInputs, userSelectedDsMappings); + const constantMappings = mapConstantInputs(constantInputs, constantValues); const allMappings = [...existingMappings, ...userSelectedDatasources, ...constantMappings]; @@ -85,7 +124,7 @@ export const CommunityDashboardMappingForm = ({ // Check if all unmapped datasource inputs have been mapped by user // Constants are optional (have default values) - const allDatasourcesMapped = unmappedInputs.every((input) => userSelectedDsMappings[input.name]?.datasource); + const allDatasourcesMapped = unmappedDsInputs.every((input) => userSelectedDsMappings[input.name]?.datasource); return ( @@ -116,14 +155,14 @@ export const CommunityDashboardMappingForm = ({ )} - {unmappedInputs.length > 0 && ( + {unmappedDsInputs.length > 0 && ( Datasource Configuration - {unmappedInputs.map((input) => { + {unmappedDsInputs.map((input) => { const selectedDatasource = userSelectedDsMappings[input.name]?.datasource; return ( diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx index 791add79d32..2c70c354fbe 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { useAsync, useDebounce } from 'react-use'; @@ -11,7 +11,13 @@ import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterI import { DashboardCard } from './DashboardCard'; import { MappingContext } from './SuggestedDashboardsModal'; import { fetchCommunityDashboards } from './api/dashboardLibraryApi'; -import { DashboardLibraryInteractions } from './interactions'; +import { + CONTENT_KINDS, + DashboardLibraryInteractions, + DISCOVERY_METHODS, + EVENT_LOCATIONS, + SOURCE_ENTRY_POINTS, +} from './interactions'; import { GnetDashboard } from './types'; import { getThumbnailUrl, @@ -38,6 +44,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); const [currentPage, setCurrentPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); + const hasTrackedLoaded = useRef(false); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); useDebounce( @@ -81,6 +88,17 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro filter: debouncedSearchQuery.trim() || undefined, }); + // Track search if query is present + if (debouncedSearchQuery.trim()) { + DashboardLibraryInteractions.searchPerformed({ + datasourceTypes: [ds.type], + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + hasResults: apiResponse.dashboards.length > 0, + resultCount: apiResponse.dashboards.length, + }); + } + return { dashboards: apiResponse.dashboards, pages: apiResponse.pages, @@ -93,25 +111,18 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro }, [datasourceUid, currentPage, debouncedSearchQuery]); // Track analytics only once on first successful load - const hasTrackedRef = useRef(false); useEffect(() => { - if ( - !loading && - !hasTrackedRef.current && - currentPage === 1 && - response?.dashboards && - response.dashboards.length > 0 - ) { + if (!loading && !hasTrackedLoaded.current && response?.dashboards && response.dashboards.length > 0) { DashboardLibraryInteractions.loaded({ numberOfItems: response.dashboards.length, - contentKinds: ['community_dashboard'], + contentKinds: [CONTENT_KINDS.COMMUNITY_DASHBOARD], datasourceTypes: [response.datasourceType], - sourceEntryPoint: 'datasource_page', - eventLocation: 'suggested_dashboards_modal_community_tab', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, }); - hasTrackedRef.current = true; + hasTrackedLoaded.current = true; } - }, [loading, currentPage, response]); + }, [loading, response]); const styles = useStyles2(getStyles); @@ -126,11 +137,22 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro return; } + // Track item click + DashboardLibraryInteractions.itemClicked({ + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: [response.datasourceType], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE, + }); + onUseCommunityDashboard({ dashboard, datasourceUid: datasourceUid || '', datasourceType: response.datasourceType, - eventLocation: 'suggested_dashboards_modal_community_tab', + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, onShowMapping, }); }; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx index 6c31bb7bc83..a672d3eb04f 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useMemo, useState, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { useAsync } from 'react-use'; @@ -13,7 +13,14 @@ import { DASHBOARD_LIBRARY_ROUTES } from '../types'; import { DashboardCard } from './DashboardCard'; import { fetchProvisionedDashboards } from './api/dashboardLibraryApi'; -import { DashboardLibraryInteractions } from './interactions'; +import { + CONTENT_KINDS, + CREATION_ORIGINS, + DashboardLibraryInteractions, + DISCOVERY_METHODS, + EVENT_LOCATIONS, + SOURCE_ENTRY_POINTS, +} from './interactions'; import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; // Constants for datasource-provided dashboards pagination @@ -24,6 +31,7 @@ export const DashboardLibrarySection = () => { const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); const [currentPage, setCurrentPage] = useState(1); + const hasTrackedLoaded = useRef(false); // Get datasource info for empty state const datasourceType = useMemo(() => { @@ -49,17 +57,16 @@ export const DashboardLibrarySection = () => { }, [datasourceUid]); // Track analytics only once on first successful load - const hasTrackedRef = useRef(false); useEffect(() => { - if (!loading && !hasTrackedRef.current && templateDashboards && templateDashboards.length > 0) { + if (!loading && !hasTrackedLoaded.current && templateDashboards && templateDashboards.length > 0) { DashboardLibraryInteractions.loaded({ numberOfItems: templateDashboards.length, - contentKinds: ['datasource_dashboard'], + contentKinds: [CONTENT_KINDS.DATASOURCE_DASHBOARD], datasourceTypes: [datasourceType], - sourceEntryPoint: 'datasource_page', - eventLocation: 'suggested_dashboards_modal_provisioned_tab', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_PROVISIONED_TAB, }); - hasTrackedRef.current = true; + hasTrackedLoaded.current = true; } }, [loading, templateDashboards, datasourceType]); @@ -77,12 +84,13 @@ export const DashboardLibrarySection = () => { const onUseProvisionedDashboard = async (dashboard: PluginDashboard) => { DashboardLibraryInteractions.itemClicked({ - contentKind: 'datasource_dashboard', + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, datasourceTypes: [dashboard.pluginId], libraryItemId: dashboard.uid, libraryItemTitle: dashboard.title, - sourceEntryPoint: 'datasource_page', - eventLocation: 'suggested_dashboards_modal_provisioned_tab', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_PROVISIONED_TAB, + discoveryMethod: DISCOVERY_METHODS.BROWSE, }); const params = new URLSearchParams({ @@ -91,9 +99,11 @@ export const DashboardLibrarySection = () => { pluginId: dashboard.pluginId, path: dashboard.path, // tracking event purpose values - sourceEntryPoint: 'datasource_page', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, libraryItemId: dashboard.uid, - creationOrigin: 'dashboard_library_datasource_dashboard', + creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD, + eventLocation: EVENT_LOCATIONS.MODAL_PROVISIONED_TAB, + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, }); const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx index ecd5e258cf7..8e0389525b0 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useMemo, useState, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { useAsync } from 'react-use'; @@ -12,7 +12,14 @@ import { PluginDashboard } from 'app/types/plugins'; import { DashboardCard } from './DashboardCard'; import { MappingContext, SuggestedDashboardsModal } from './SuggestedDashboardsModal'; import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi'; -import { DashboardLibraryInteractions } from './interactions'; +import { + CONTENT_KINDS, + CREATION_ORIGINS, + DashboardLibraryInteractions, + DISCOVERY_METHODS, + EVENT_LOCATIONS, + SOURCE_ENTRY_POINTS, +} from './interactions'; import { GnetDashboard } from './types'; import { getThumbnailUrl, @@ -44,6 +51,7 @@ const INCLUDE_LOGO = true; export const SuggestedDashboards = ({ datasourceUid }: Props) => { const styles = useStyles2(getStyles); + const hasTrackedLoaded = useRef(false); const [searchParams, setSearchParams] = useSearchParams(); const showLibraryModal = searchParams.get('dashboardLibraryModal') === 'open'; @@ -144,22 +152,24 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { }, [result, loading]); // Track analytics only once on first successful load - const hasTrackedRef = useRef(false); useEffect(() => { - if (!loading && !hasTrackedRef.current && result && result.dashboards.length > 0) { - const contentKinds: Array<'datasource_dashboard' | 'community_dashboard'> = [ + if (!loading && !hasTrackedLoaded.current && result && result.dashboards.length > 0) { + const contentKinds = [ ...new Set( - result.dashboards.map((m) => (m.type === 'provisioned' ? 'datasource_dashboard' : 'community_dashboard')) + result.dashboards.map((m) => + m.type === 'provisioned' ? CONTENT_KINDS.DATASOURCE_DASHBOARD : CONTENT_KINDS.COMMUNITY_DASHBOARD + ) ), ]; + DashboardLibraryInteractions.loaded({ numberOfItems: result.dashboards.length, contentKinds, datasourceTypes: [datasourceType], - sourceEntryPoint: 'datasource_page', - eventLocation: 'empty_dashboard', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, }); - hasTrackedRef.current = true; + hasTrackedLoaded.current = true; } }, [loading, result, datasourceType]); @@ -198,12 +208,13 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { } DashboardLibraryInteractions.itemClicked({ - contentKind: 'datasource_dashboard', + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, datasourceTypes: [ds.type], libraryItemId: dashboard.uid, libraryItemTitle: dashboard.title, - sourceEntryPoint: 'datasource_page', - eventLocation: 'empty_dashboard', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + discoveryMethod: DISCOVERY_METHODS.BROWSE, }); // Navigate to template route (existing flow) @@ -212,9 +223,11 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { title: dashboard.title || 'Template', pluginId: dashboard.pluginId, path: dashboard.path, - sourceEntryPoint: 'datasource_page', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, libraryItemId: dashboard.uid, - creationOrigin: 'dashboard_library_datasource_dashboard', + creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, }); locationService.push(`/dashboard/template?${params.toString()}`); @@ -230,11 +243,22 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { return; } + // Track item click + DashboardLibraryInteractions.itemClicked({ + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: [ds.type], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + discoveryMethod: DISCOVERY_METHODS.BROWSE, + }); + onUseCommunityDashboard({ dashboard, datasourceUid, datasourceType: ds.type, - eventLocation: 'empty_dashboard', + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, onShowMapping: onShowMapping, }); }; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx index e6b78d35e5f..5e1de6cf4dc 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx @@ -12,6 +12,7 @@ import { DashboardJson } from 'app/features/manage-dashboards/types'; import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm'; import { CommunityDashboardSection } from './CommunityDashboardSection'; import { DashboardLibrarySection } from './DashboardLibrarySection'; +import { ContentKind, EventLocation } from './interactions'; import { InputMapping } from './utils/autoMapDatasources'; interface SuggestedDashboardsModalProps { @@ -26,10 +27,13 @@ type ModalView = 'datasource' | 'community' | 'mapping'; export interface MappingContext { dashboardName: string; dashboardJson: DashboardJson; - unmappedInputs: DataSourceInput[]; + unmappedDsInputs: DataSourceInput[]; constantInputs: DashboardInput[]; existingMappings: InputMapping[]; onInterpolateAndNavigate: (mappings: InputMapping[]) => void; + // Tracking context for analytics + eventLocation: EventLocation; + contentKind: ContentKind; } export const SuggestedDashboardsModal = ({ @@ -142,13 +146,18 @@ export const SuggestedDashboardsModal = ({ )} {activeView === 'mapping' && mappingContext && ( { mappingContext.onInterpolateAndNavigate(allMappings); }} + dashboardName={mappingContext.dashboardName} + libraryItemId={String(mappingContext.dashboardJson.gnetId || '')} + eventLocation={mappingContext.eventLocation} + contentKind={mappingContext.contentKind} + datasourceTypes={[datasourceInfo.type]} /> )} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts index d856fd996f0..b97568a8ab9 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -2,7 +2,7 @@ import { getBackendSrv } from '@grafana/runtime'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { PluginDashboard } from 'app/types/plugins'; -import { GnetDashboardsResponse } from '../types'; +import { GnetDashboardsResponse, Link } from '../types'; /** * Parameters for fetching community dashboards from Grafana.com @@ -18,11 +18,28 @@ export interface FetchCommunityDashboardsParams { filter?: string; } +/** + * Dependency item from Grafana.com dashboard API + */ +export interface GnetDashboardDependency { + pluginSlug: string; + pluginTypeCode: 'app' | 'panel' | 'datasource' | 'grafana'; + pluginName?: string; + pluginVersion?: string; + [key: string]: unknown; +} + /** * Response from the Gnet API when fetching a single dashboard */ export interface GnetDashboardResponse { json: DashboardJson; + dependencies?: { + items?: GnetDashboardDependency[]; + direction?: 'asc' | 'desc'; + orderBy?: string; + links?: Link[]; + }; [key: string]: unknown; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts index 6b1a07c9e43..714e53145e9 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -2,18 +2,40 @@ import { reportInteraction } from '@grafana/runtime'; const SCHEMA_VERSION = 1; -type ContentKind = 'datasource_dashboard' | 'community_dashboard'; -// in future this could also include "template_dashboard" if/when items become templates -// | 'template_dashboard'; +// Constant values for tracking events +export const EVENT_LOCATIONS = { + EMPTY_DASHBOARD: 'empty_dashboard', + MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab', + MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab', +} as const; -type SourceEntryPoint = 'datasource_page'; -// possible future flows onboarding, create-dashboard, empty states -// | 'create_dashboard' | 'empty_state'; +export const CONTENT_KINDS = { + DATASOURCE_DASHBOARD: 'datasource_dashboard', + COMMUNITY_DASHBOARD: 'community_dashboard', + // in future this could also include "TEMPLATE_DASHBOARD" if/when items become templates +} as const; -type EventLocation = - | 'empty_dashboard' - | 'suggested_dashboards_modal_provisioned_tab' - | 'suggested_dashboards_modal_community_tab'; +export const SOURCE_ENTRY_POINTS = { + DATASOURCE_PAGE: 'datasource_page', + // possible future flows: CREATE_DASHBOARD, EMPTY_STATE +} as const; + +export const DISCOVERY_METHODS = { + SEARCH: 'search', + BROWSE: 'browse', +} as const; + +export const CREATION_ORIGINS = { + DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD: 'dashboard_library_datasource_dashboard', + DASHBOARD_LIBRARY_COMMUNITY_DASHBOARD: 'dashboard_library_community_dashboard', +} as const; + +// Derive types from constant maps for single source of truth +export type EventLocation = (typeof EVENT_LOCATIONS)[keyof typeof EVENT_LOCATIONS]; +export type ContentKind = (typeof CONTENT_KINDS)[keyof typeof CONTENT_KINDS]; +export type SourceEntryPoint = (typeof SOURCE_ENTRY_POINTS)[keyof typeof SOURCE_ENTRY_POINTS]; +export type DiscoveryMethod = (typeof DISCOVERY_METHODS)[keyof typeof DISCOVERY_METHODS]; +export type CreationOrigin = (typeof CREATION_ORIGINS)[keyof typeof CREATION_ORIGINS]; export const DashboardLibraryInteractions = { loaded: (properties: { @@ -25,6 +47,15 @@ export const DashboardLibraryInteractions = { }) => { reportDashboardLibraryInteraction('loaded', properties); }, + searchPerformed: (properties: { + datasourceTypes: string[]; + sourceEntryPoint: SourceEntryPoint; + eventLocation: EventLocation; + hasResults: boolean; + resultCount: number; + }) => { + reportDashboardLibraryInteraction('search_performed', properties); + }, itemClicked: (properties: { contentKind: ContentKind; datasourceTypes: string[]; @@ -32,9 +63,34 @@ export const DashboardLibraryInteractions = { libraryItemTitle: string; sourceEntryPoint: SourceEntryPoint; eventLocation: EventLocation; + discoveryMethod: DiscoveryMethod; }) => { reportDashboardLibraryInteraction('item_clicked', properties); }, + mappingFormShown: (properties: { + contentKind: ContentKind; + datasourceTypes: string[]; + libraryItemId: string; + libraryItemTitle: string; + sourceEntryPoint: SourceEntryPoint; + eventLocation: EventLocation; + unmappedDsInputsCount: number; + constantInputsCount: number; + }) => { + reportDashboardLibraryInteraction('mapping_form_shown', properties); + }, + mappingFormCompleted: (properties: { + contentKind: ContentKind; + datasourceTypes: string[]; + libraryItemId: string; + libraryItemTitle: string; + sourceEntryPoint: SourceEntryPoint; + eventLocation: EventLocation; + userMappedCount: number; + autoMappedCount: number; + }) => { + reportDashboardLibraryInteraction('mapping_form_completed', properties); + }, }; const reportDashboardLibraryInteraction = (name: string, properties?: Record) => { diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts index 856c99daefc..20084231e61 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts @@ -20,7 +20,7 @@ export function isDataSourceInput(input: Input): input is Input & DataSourceInpu export interface AutoMapResult { allMapped: boolean; mappings: InputMapping[]; - unmappedInputs: DataSourceInput[]; + unmappedDsInputs: DataSourceInput[]; } /** @@ -35,7 +35,7 @@ export interface AutoMapResult { */ export function tryAutoMapDatasources(inputs: DataSourceInput[], currentDatasourceUid: string): AutoMapResult { const mappings: InputMapping[] = []; - const unmappedInputs: DataSourceInput[] = []; + const unmappedDsInputs: DataSourceInput[] = []; for (const input of inputs) { // Get all datasources compatible with this input's plugin type @@ -70,14 +70,14 @@ export function tryAutoMapDatasources(inputs: DataSourceInput[], currentDatasour value: selectedDs, }); } else { - unmappedInputs.push(input); + unmappedDsInputs.push(input); } } return { - allMapped: unmappedInputs.length === 0, + allMapped: unmappedDsInputs.length === 0, mappings, - unmappedInputs, + unmappedDsInputs, }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index 83b7dd47d78..fed08c7ae73 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -3,12 +3,40 @@ import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; import { DASHBOARD_LIBRARY_ROUTES } from '../../types'; import { MappingContext } from '../SuggestedDashboardsModal'; -import { fetchCommunityDashboard } from '../api/dashboardLibraryApi'; -import { DashboardLibraryInteractions } from '../interactions'; +import { fetchCommunityDashboard, GnetDashboardDependency } from '../api/dashboardLibraryApi'; +import { CONTENT_KINDS, ContentKind, CREATION_ORIGINS, EventLocation, SOURCE_ENTRY_POINTS } from '../interactions'; import { GnetDashboard, Link } from '../types'; import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources'; +/** + * Extract datasource types from URL parameters for tracking purposes. + * Supports two formats: + * - datasourceTypes: JSON array of datasource types (for community dashboards) + * - pluginId: Single datasource type (legacy format for provisioned dashboards) + * + * @returns Array of datasource type strings, or undefined if not available + */ +export function extractDatasourceTypesFromUrl(): string[] | undefined { + const params = locationService.getSearchObject(); + const datasourceTypesParam = params.datasourceTypes; + const pluginIdParam = params.pluginId; + + if (datasourceTypesParam && typeof datasourceTypesParam === 'string') { + try { + return JSON.parse(datasourceTypesParam); + } catch { + // If parsing fails, return undefined + return undefined; + } + } else if (pluginIdParam && typeof pluginIdParam === 'string') { + // Fallback to legacy pluginId for provisioned dashboards + return [pluginIdParam]; + } + + return undefined; +} + /** * Extract thumbnail URL from dashboard screenshots */ @@ -86,17 +114,27 @@ export function navigateToTemplate( dashboardTitle: string, gnetId: number, datasourceUid: string, - mappings: InputMapping[] + mappings: InputMapping[], + eventLocation: EventLocation, + contentKind: ContentKind, + datasourceTypes?: string[] ): void { const searchParams = new URLSearchParams({ datasource: datasourceUid, title: dashboardTitle, gnetId: String(gnetId), - sourceEntryPoint: 'datasource_page', - creationOrigin: 'dashboard_library_community_dashboard', + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_COMMUNITY_DASHBOARD, + contentKind, + eventLocation, mappings: JSON.stringify(mappings), }); + // Add datasource types for tracking if available + if (datasourceTypes && datasourceTypes.length > 0) { + searchParams.set('datasourceTypes', JSON.stringify(datasourceTypes)); + } + locationService.push({ pathname: DASHBOARD_LIBRARY_ROUTES.Template, search: searchParams.toString(), @@ -125,16 +163,8 @@ export async function onUseCommunityDashboard({ eventLocation, onShowMapping, }: UseCommunityDashboardParams): Promise { - // Track analytics - DashboardLibraryInteractions.itemClicked({ - contentKind: 'community_dashboard', - datasourceTypes: [datasourceType], - libraryItemId: String(dashboard.id), - libraryItemTitle: dashboard.name, - sourceEntryPoint: 'datasource_page', - eventLocation, - }); - + // Note: item_clicked tracking is done by the caller (CommunityDashboardSection or SuggestedDashboards) + // with the correct discoveryMethod before calling this function try { // Fetch full dashboard from Gcom, this is the JSON with __inputs const fullDashboard = await fetchCommunityDashboard(dashboard.id); @@ -143,6 +173,13 @@ export async function onUseCommunityDashboard({ // Parse datasource requirements from __inputs const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || []; + // Extract datasource types for tracking purposes from dependencies + const datasourceTypes = + fullDashboard.dependencies?.items + ?.filter((dep: GnetDashboardDependency) => dep.pluginTypeCode === 'datasource') + .map((dep: GnetDashboardDependency) => dep.pluginSlug) + .filter(Boolean) || []; + // Parse constant inputs - these always need user review const constantInputs = parseConstantInputs(dashboardJson.__inputs || []); @@ -151,22 +188,40 @@ export async function onUseCommunityDashboard({ // Decide whether to show mapping form or navigate directly // Show mapping form if: (a) there are unmapped datasources OR (b) there are constants - const needsMapping = mappingResult.unmappedInputs.length > 0 || constantInputs.length > 0; + const needsMapping = mappingResult.unmappedDsInputs.length > 0 || constantInputs.length > 0; if (!needsMapping) { // No mapping needed - all datasources auto-mapped, no constants - navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappingResult.mappings); + navigateToTemplate( + dashboard.name, + dashboard.id, + datasourceUid, + mappingResult.mappings, + eventLocation, + CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes + ); } else { // Show mapping form for unmapped datasources and/or constants if (onShowMapping) { onShowMapping({ dashboardName: dashboard.name, dashboardJson, - unmappedInputs: mappingResult.unmappedInputs, + unmappedDsInputs: mappingResult.unmappedDsInputs, constantInputs, existingMappings: mappingResult.mappings, + eventLocation, + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, onInterpolateAndNavigate: (mappings) => - navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappings), + navigateToTemplate( + dashboard.name, + dashboard.id, + datasourceUid, + mappings, + eventLocation, + CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes + ), }); } } From 5162988fa127b176c0911e6c21be039ec368b6bf Mon Sep 17 00:00:00 2001 From: Andres Torres Date: Wed, 12 Nov 2025 11:33:26 -0500 Subject: [PATCH 173/209] feat(semconv): Add grafana namespace name attribute (#113767) --- pkg/semconv/attributes.go | 20 ++++++++++++++++++++ pkg/semconv/model/registry/namespace.yml | 12 ++++++++++++ pkg/semconv/model/trace/namespace.yaml | 7 +++++++ 3 files changed, 39 insertions(+) create mode 100644 pkg/semconv/model/registry/namespace.yml create mode 100644 pkg/semconv/model/trace/namespace.yaml diff --git a/pkg/semconv/attributes.go b/pkg/semconv/attributes.go index 07fc5fa6fd6..c759ab2e9c0 100644 --- a/pkg/semconv/attributes.go +++ b/pkg/semconv/attributes.go @@ -81,6 +81,26 @@ func K8sDataplaneserviceName(val string) attribute.KeyValue { return k8sDataplaneserviceNameKey.String(val) } +// Describes Grafana app platform namespace attributes. +const ( + // GrafanaNamespaceNameKey is the attribute Key conforming to the + // "grafana.namespace.name" semantic conventions. It represents the + // namespace name. + // + // Type: string + // RequirementLevel: Optional + // Stability: stable + // Examples: 'stacks-99999' + grafanaNamespaceNameKey = attribute.Key("grafana.namespace.name") +) + +// GrafanaNamespaceName returns an attribute KeyValue conforming to the +// "grafana.namespace.name" semantic conventions. It represents the namespace +// name. +func GrafanaNamespaceName(val string) attribute.KeyValue { + return grafanaNamespaceNameKey.String(val) +} + // Describes Grafana plugin attributes. const ( // GrafanaPluginIdKey is the attribute Key conforming to the diff --git a/pkg/semconv/model/registry/namespace.yml b/pkg/semconv/model/registry/namespace.yml new file mode 100644 index 00000000000..8fbc257c21c --- /dev/null +++ b/pkg/semconv/model/registry/namespace.yml @@ -0,0 +1,12 @@ +groups: + - id: registry.grafana.namespace + type: attribute_group + display_name: Grafana Namespace Attributes + brief: "Describes Grafana app platform namespace attributes." + attributes: + - id: grafana.namespace.name + type: string + brief: The namespace name. + examples: + - "stacks-99999" + stability: stable diff --git a/pkg/semconv/model/trace/namespace.yaml b/pkg/semconv/model/trace/namespace.yaml new file mode 100644 index 00000000000..dd3f5cb14bd --- /dev/null +++ b/pkg/semconv/model/trace/namespace.yaml @@ -0,0 +1,7 @@ +groups: + - id: trace.grafana.namespace + type: span + brief: 'Semantic Convention for Grafana app platform namespaces' + stability: stable + attributes: + - ref: grafana.namespace.name From 4bca10195e680ef3aef6c1a99c69ce6141d5eebd Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 12 Nov 2025 17:48:48 +0100 Subject: [PATCH 174/209] Zanzana: Fix shadow client metric (#113771) --- pkg/services/authz/zanzana/client/metrics.go | 2 +- pkg/services/authz/zanzana/client/shadow_client.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/services/authz/zanzana/client/metrics.go b/pkg/services/authz/zanzana/client/metrics.go index 33f9a83c13e..3fe7b1dd590 100644 --- a/pkg/services/authz/zanzana/client/metrics.go +++ b/pkg/services/authz/zanzana/client/metrics.go @@ -33,7 +33,7 @@ func newShadowClientMetrics(reg prometheus.Registerer) *metrics { ), compileSeconds: promauto.With(reg).NewHistogramVec( prometheus.HistogramOpts{ - Name: "compile_seconds", + Name: "engine_compile_seconds", Help: "Histogram for item checker compilation time for the specific access control engine (RBAC and zanzana).", Namespace: metricsNamespace, Subsystem: metricsSubSystem, diff --git a/pkg/services/authz/zanzana/client/shadow_client.go b/pkg/services/authz/zanzana/client/shadow_client.go index f2f0ec7d4f1..93fbfd10b4d 100644 --- a/pkg/services/authz/zanzana/client/shadow_client.go +++ b/pkg/services/authz/zanzana/client/shadow_client.go @@ -40,13 +40,12 @@ func (c *ShadowClient) Check(ctx context.Context, id authlib.AuthInfo, req authl } timer := prometheus.NewTimer(c.metrics.evaluationsSeconds.WithLabelValues("zanzana")) - defer timer.ObserveDuration() - zanzanaCtx := context.WithoutCancel(ctx) res, err := c.zanzanaClient.Check(zanzanaCtx, id, req, folder) if err != nil { c.logger.Error("Failed to run zanzana check", "error", err) } + timer.ObserveDuration() acRes := <-acResChan acErr := <-acErrChan From 67f811b6d8cdb8d0b16ea027ab6e8b48ad4f1d6b Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:49:50 -0600 Subject: [PATCH 175/209] alerting: mute timing clarification (#113129) * alerting: mute timing clarification clarify that mute timing takes precedence over active timing * alerting docs: best practices addition draft content for best practices re: recording rules * wrong branch * alerting: best practices docs best practices addition re: recording rules * smh --- docs/sources/alerting/configure-notifications/mute-timings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/alerting/configure-notifications/mute-timings.md b/docs/sources/alerting/configure-notifications/mute-timings.md index f9cb3bd5086..58636eb1965 100644 --- a/docs/sources/alerting/configure-notifications/mute-timings.md +++ b/docs/sources/alerting/configure-notifications/mute-timings.md @@ -46,7 +46,7 @@ A mute timing is a recurring interval that stops notifications for one or multip Use mute timings to temporarily pause notifications for a specific recurring period, such as a regular maintenance window or weekends. -The active time interval provide the opposite functionality, where alerts handled by a notification policy are suppressed unless the notification happens at a time that matches the time interval. Use active time intervals for periods where you want to reduce alert noise. +The active time interval provide the opposite functionality, where alerts handled by a notification policy are suppressed unless the notification happens at a time that matches the time interval. Use active time intervals for periods where you want to reduce alert noise. Mute timings take precedence over active time intervals when they overlap. {{< admonition type="note" >}} Mute timings and active time intervals are assigned to a [specific Alertmanager](ref:alertmanager-architecture) and only suppress notifications for alerts managed by that Alertmanager. From 1f14f1447f4fcda37d1be88ae7d320584afbf99b Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:51:13 -0600 Subject: [PATCH 176/209] alerting docs: target data source clarification (#113126) adding information to create grafana managed recording rules doc per support request 16892 --- .../create-grafana-managed-recording-rules.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sources/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules.md b/docs/sources/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules.md index 224ec27ea82..746d2a0084d 100644 --- a/docs/sources/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules.md +++ b/docs/sources/alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules.md @@ -70,6 +70,10 @@ If a rule does not explicitly specify a target data source for writing (for exam default_datasource_uid = my-uid ``` +{{< admonition type="note" >}} +Grafana Cloud: If you leave **Target data source** blank when creating a recording rule, Grafana writes the results to your managed Prometheus data source named `grafanacloud-prom` by default. This may be different from the default data source you use in dashboards. To write to a different backend, explicitly select a target data source. In self-managed Grafana, you can set the default fallback with `default_datasource_uid` in the `[recording_rules]` section of the configuration. +{{< /admonition >}} + If you previously configured recording rules using the `url` and `basic_auth_*` configuration options, these are no longer supported. You must either: - Set `default_datasource_uid` in the `[recording_rules]` section of the configuration file to point to the target data source From 4cb290e4ccaa79453a9fab96569893c74877b147 Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:29:51 +0000 Subject: [PATCH 177/209] chore(deps): update dependency @formatjs/intl-durationformat to v0.7.6 (#113495) | datasource | package | from | to | | ---------- | ----------------------------- | ----- | ----- | | npm | @formatjs/intl-durationformat | 0.7.4 | 0.7.6 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- yarn.lock | 127 ++++++++++++++++++------------------------------------ 1 file changed, 41 insertions(+), 86 deletions(-) diff --git a/yarn.lock b/yarn.lock index f5eebf42336..99e1d05c535 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2324,15 +2324,15 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.3.4": - version: 2.3.4 - resolution: "@formatjs/ecma402-abstract@npm:2.3.4" +"@formatjs/ecma402-abstract@npm:2.3.6": + version: 2.3.6 + resolution: "@formatjs/ecma402-abstract@npm:2.3.6" dependencies: "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/intl-localematcher": "npm:0.6.1" + "@formatjs/intl-localematcher": "npm:0.6.2" decimal.js: "npm:^10.4.3" tslib: "npm:^2.8.0" - checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2 + checksum: 10/30b1b5cd6b62ba46245f934429936592df5500bc1b089dc92dd49c826757b873dd92c305dcfe370701e4df6b057bf007782113abb9b65db550d73be4961718bc languageName: node linkType: hard @@ -2376,13 +2376,13 @@ __metadata: linkType: hard "@formatjs/intl-durationformat@npm:^0.7.0": - version: 0.7.4 - resolution: "@formatjs/intl-durationformat@npm:0.7.4" + version: 0.7.6 + resolution: "@formatjs/intl-durationformat@npm:0.7.6" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" - "@formatjs/intl-localematcher": "npm:0.6.1" + "@formatjs/ecma402-abstract": "npm:2.3.6" + "@formatjs/intl-localematcher": "npm:0.6.2" tslib: "npm:^2.8.0" - checksum: 10/d62273ecd635475ca91e9b501301f3f396403fa91b584c550734b19b2d194ba1316b27303fed985c1d42ae933d54eb220da6540edfdf376b0d9371ecfd0d4e15 + checksum: 10/442236ba85bcd9cb7296c43a708271fa09f110b1ca9d5899066d00812fc2965eaeaec6b5240be421b80daba62860352131088449ba0fcd2061f671cec6240f0b languageName: node linkType: hard @@ -2395,12 +2395,12 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.6.1": - version: 0.6.1 - resolution: "@formatjs/intl-localematcher@npm:0.6.1" +"@formatjs/intl-localematcher@npm:0.6.2": + version: 0.6.2 + resolution: "@formatjs/intl-localematcher@npm:0.6.2" dependencies: tslib: "npm:^2.8.0" - checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998 + checksum: 10/eb12a7f5367bbecdfafc20d7f005559ce840f420e970f425c5213d35e94e86dfe75bde03464971a26494bf8427d4961269db22ecad2834f2a19d888b5d9cc064 languageName: node linkType: hard @@ -4118,20 +4118,13 @@ __metadata: languageName: node linkType: hard -"@inquirer/figures@npm:^1.0.14": +"@inquirer/figures@npm:^1.0.14, @inquirer/figures@npm:^1.0.3": version: 1.0.14 resolution: "@inquirer/figures@npm:1.0.14" checksum: 10/39df361eb607cea5a020d457e25f9c6aee3a1de8975c6295a4b3bfe86ba7e7f7bfbefa6a52b145b1790f2690e5c8f10eb822e5bc764aff7ba00a6cd24eec5a25 languageName: node linkType: hard -"@inquirer/figures@npm:^1.0.3": - version: 1.0.11 - resolution: "@inquirer/figures@npm:1.0.11" - checksum: 10/357ddd2e83718bc3c9189d518b93fd69099af9c860354df9a5ac0ec024cb5df1228ae4608d2de7625624d2adcd047db813f29426a610eaae7b9e449f8c753c6b - languageName: node - linkType: hard - "@inquirer/input@npm:^4.2.5": version: 4.2.5 resolution: "@inquirer/input@npm:4.2.5" @@ -9037,7 +9030,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:1.13.19": +"@swc/core@npm:1.13.19, @swc/core@npm:^1.10.8, @swc/core@npm:^1.5.22": version: 1.13.19 resolution: "@swc/core@npm:1.13.19" dependencies: @@ -9083,7 +9076,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:1.13.3, @swc/core@npm:^1.10.8, @swc/core@npm:^1.5.22": +"@swc/core@npm:1.13.3": version: 1.13.3 resolution: "@swc/core@npm:1.13.3" dependencies: @@ -10274,12 +10267,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:22.17.0, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=13.7.4": - version: 22.17.0 - resolution: "@types/node@npm:22.17.0" +"@types/node@npm:*, @types/node@npm:24.9.2, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=13.7.4": + version: 24.9.2 + resolution: "@types/node@npm:24.9.2" dependencies: - undici-types: "npm:~6.21.0" - checksum: 10/f77b0e1c3c00e438b56c726d6b1170d4969c600cc8d4ecf2c2aa7692243a8ff455a3d530760da95e0b6aab059c4605a384b43d18f96646c745ff133c00b84875 + undici-types: "npm:~7.16.0" + checksum: 10/3e76ad89cca317c0886deedab0245b6b2a04ef6c47362bd3918020296f3e9630334795af9cee8c6633eae774c85d848ff2e6bed5a7c3133fc94968364fc3ee36 languageName: node linkType: hard @@ -10290,12 +10283,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:24.9.2": - version: 24.9.2 - resolution: "@types/node@npm:24.9.2" +"@types/node@npm:22.17.0": + version: 22.17.0 + resolution: "@types/node@npm:22.17.0" dependencies: - undici-types: "npm:~7.16.0" - checksum: 10/3e76ad89cca317c0886deedab0245b6b2a04ef6c47362bd3918020296f3e9630334795af9cee8c6633eae774c85d848ff2e6bed5a7c3133fc94968364fc3ee36 + undici-types: "npm:~6.21.0" + checksum: 10/f77b0e1c3c00e438b56c726d6b1170d4969c600cc8d4ecf2c2aa7692243a8ff455a3d530760da95e0b6aab059c4605a384b43d18f96646c745ff133c00b84875 languageName: node linkType: hard @@ -13234,7 +13227,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:5.6.2, chalk@npm:^5.6.2": +"chalk@npm:5.6.2, chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 @@ -13272,13 +13265,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1": - version: 5.4.1 - resolution: "chalk@npm:5.4.1" - checksum: 10/29df3ffcdf25656fed6e95962e2ef86d14dfe03cd50e7074b06bad9ffbbf6089adbb40f75c00744d843685c8d008adaf3aed31476780312553caf07fa86e5bc7 - languageName: node - linkType: hard - "chance@npm:^1.1.13": version: 1.1.13 resolution: "chance@npm:1.1.13" @@ -13882,7 +13868,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:14.0.1": +"commander@npm:14.0.1, commander@npm:~14.0.0": version: 14.0.1 resolution: "commander@npm:14.0.1" checksum: 10/783115e9403caeca29c0fcbd4e0358f70c67760e4e4933f3453fcdd5ddba2ec44173c8da5213d7ce5e404f51c7e71203a42c548164dbe27b668b32a8981577f1 @@ -13959,13 +13945,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:~14.0.0": - version: 14.0.0 - resolution: "commander@npm:14.0.0" - checksum: 10/c05418bfc35a3e8b5c67bd9f75f5b773f386f9b85f83e70e7c926047f270929cb06cf13cd68f387dd6e7e23c6157de8171b28ba606abd3e6256028f1f789becf - languageName: node - linkType: hard - "comment-parser@npm:1.4.1": version: 1.4.1 resolution: "comment-parser@npm:1.4.1" @@ -16204,16 +16183,7 @@ __metadata: languageName: node linkType: hard -"enquirer@npm:^2.3.6, enquirer@npm:~2.3.6": - version: 2.3.6 - resolution: "enquirer@npm:2.3.6" - dependencies: - ansi-colors: "npm:^4.1.1" - checksum: 10/751d14f037eb7683997e696fb8d5fe2675e0b0cde91182c128cf598acf3f5bd9005f35f7c2a9109e291140af496ebec237b6dac86067d59a9b44f3688107f426 - languageName: node - linkType: hard - -"enquirer@npm:^2.4.1": +"enquirer@npm:^2.3.6, enquirer@npm:^2.4.1": version: 2.4.1 resolution: "enquirer@npm:2.4.1" dependencies: @@ -16223,6 +16193,15 @@ __metadata: languageName: node linkType: hard +"enquirer@npm:~2.3.6": + version: 2.3.6 + resolution: "enquirer@npm:2.3.6" + dependencies: + ansi-colors: "npm:^4.1.1" + checksum: 10/751d14f037eb7683997e696fb8d5fe2675e0b0cde91182c128cf598acf3f5bd9005f35f7c2a9109e291140af496ebec237b6dac86067d59a9b44f3688107f426 + languageName: node + linkType: hard + "ensure-posix-path@npm:^1.1.0": version: 1.1.1 resolution: "ensure-posix-path@npm:1.1.1" @@ -26426,16 +26405,7 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.54.1, playwright-core@npm:>=1.2.0": - version: 1.54.1 - resolution: "playwright-core@npm:1.54.1" - bin: - playwright-core: cli.js - checksum: 10/c0acfb7ecb48e9fb6c22f71298b966244bd4f8659093a798cc7f9a509deee22109560e44f2d507124e7718779640e0b4cb05a05c068b034405418d8740ec31ff - languageName: node - linkType: hard - -"playwright-core@npm:1.56.1": +"playwright-core@npm:1.56.1, playwright-core@npm:>=1.2.0": version: 1.56.1 resolution: "playwright-core@npm:1.56.1" bin: @@ -26444,7 +26414,7 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.56.1": +"playwright@npm:1.56.1, playwright@npm:^1.14.0": version: 1.56.1 resolution: "playwright@npm:1.56.1" dependencies: @@ -26459,21 +26429,6 @@ __metadata: languageName: node linkType: hard -"playwright@npm:^1.14.0": - version: 1.54.1 - resolution: "playwright@npm:1.54.1" - dependencies: - fsevents: "npm:2.3.2" - playwright-core: "npm:1.54.1" - dependenciesMeta: - fsevents: - optional: true - bin: - playwright: cli.js - checksum: 10/ebad2924b589a4cfedf48288e67b4afc81047222e607321c9452a37610025e6e249bdfeff010c1c70cf9a9d35c0b9e5cb7934a58986f67928dd469d9fde1a035 - languageName: node - linkType: hard - "plop@npm:^4.0.1": version: 4.0.1 resolution: "plop@npm:4.0.1" From 59ff606106b75ae8fc240f001e13d8591fee7caa Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:30:26 +0000 Subject: [PATCH 178/209] chore(deps): update dependency @openfeature/core to v1.9.1 (#113496) | datasource | package | from | to | | ---------- | ----------------- | ----- | ----- | | npm | @openfeature/core | 1.9.0 | 1.9.1 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 99e1d05c535..2055cfc9cdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6096,9 +6096,9 @@ __metadata: linkType: hard "@openfeature/core@npm:^1.9.0": - version: 1.9.0 - resolution: "@openfeature/core@npm:1.9.0" - checksum: 10/c6d20edc09053afd99752fe46d8328158680950bca4b86679f67f79249d7226eea127b31fffdc38e26ecb729f2bab5a4a5a7c1db708ae76b7fbbac68cd56f094 + version: 1.9.1 + resolution: "@openfeature/core@npm:1.9.1" + checksum: 10/6099e16b1b4cd6e3c45c05ab4acd44c9cb4ab501b676ab6f3e77f0be1b56abc7506c5629187381333a02975d315d831e0905582435b356d9676255e72a465899 languageName: node linkType: hard From 97c8121785c8c52d0d2aa2edfae3b05b462de75e Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:31:43 +0000 Subject: [PATCH 179/209] chore(deps): update grafana/alloy docker tag to v1.11.3 (#113498) | datasource | package | from | to | | ---------- | ------------- | ------- | ------- | | docker | grafana/alloy | v1.11.2 | v1.11.3 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- devenv/frontend-service/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index 74ff93c2dd4..42d326267f1 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -86,7 +86,7 @@ services: - 'alloy.logs=true' alloy: - image: grafana/alloy:v1.11.2@sha256:6ab34b8201f0e8b0c4346be4934c9965723af3f7f21dd9a65fd73f270f69b451 + image: grafana/alloy:v1.11.3@sha256:8c7256f412feb9f5f48f9f6f9394dc97ca887f63dea9304f347970ecc1787669 volumes: - ./configs/alloy:/alloy-config - /var/run/docker.sock:/var/run/docker.sock # To scrape Docker container logs From c9e381b96f5473399e1e9fe84149e0a2f8794664 Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:32:15 +0000 Subject: [PATCH 180/209] chore(deps): update prom/prometheus docker tag to v3.7.3 (#113515) | datasource | package | from | to | | ---------- | --------------- | ------ | ------ | | docker | prom/prometheus | v3.7.2 | v3.7.3 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- devenv/frontend-service/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index 42d326267f1..cce40084189 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -104,7 +104,7 @@ services: - 'alloy.logs=true' prometheus: - image: prom/prometheus:v3.7.2@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7 + image: prom/prometheus:v3.7.3@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038 volumes: - prometheus-data:/prometheus command: From ec9f39d54a0489ca36a9059ad1b31ab7a58ca20a Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Wed, 12 Nov 2025 13:51:23 -0500 Subject: [PATCH 181/209] AWS Datasources: add toggle for http proxy (#113777) --- .../grafana-data/src/types/featureToggles.gen.ts | 5 +++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 16 ++++++++++++++++ 5 files changed, 33 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 003915fdf0b..fa9205b7bc4 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1226,4 +1226,9 @@ export interface FeatureToggles { * @default false */ kubernetesAnnotations?: boolean; + /** + * Enables http proxy settings for aws datasources + * @default false + */ + awsDatasourcesHttpProxy?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4e9ae6ad99b..68409eab390 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2126,6 +2126,13 @@ var ( Owner: grafanaBackendServicesSquad, Expression: "false", }, + { + Name: "awsDatasourcesHttpProxy", + Description: "Enables http proxy settings for aws datasources", + Stage: FeatureStageExperimental, + Owner: awsDatasourcesSquad, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c9feefaf19c..c88b2ae7ec6 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -273,3 +273,4 @@ panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false grafanaAdvisorAppInstaller,experimental,@grafana/plugins-platform-backend,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false +awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1e09c590853..bdab6514db0 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1101,4 +1101,8 @@ const ( // FlagKubernetesAnnotations // Enables app platform API for annotations FlagKubernetesAnnotations = "kubernetesAnnotations" + + // FlagAwsDatasourcesHttpProxy + // Enables http proxy settings for aws datasources + FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index d6131bfe044..23d85c2484b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -762,6 +762,22 @@ "expression": "true" } }, + { + "metadata": { + "name": "awsDatasourcesHttpProxy", + "resourceVersion": "1762964349996", + "creationTimestamp": "2025-11-12T16:15:47Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 16:19:09.996919 +0000 UTC" + } + }, + "spec": { + "description": "Enables http proxy settings for aws datasources", + "stage": "experimental", + "codeowner": "@grafana/aws-datasources", + "expression": "false" + } + }, { "metadata": { "name": "awsDatasourcesTempCredentials", From a194219365400e6dd5929453b17d212d1efd386c Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:26:29 -0600 Subject: [PATCH 182/209] VizSuggestions: Add new feature toggle (#113549) --- .../configure-grafana/feature-toggles/index.md | 1 + .../grafana-data/src/types/featureToggles.gen.ts | 5 +++++ pkg/services/featuremgmt/registry.go | 8 ++++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 14 ++++++++++++++ 6 files changed, 33 insertions(+) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index a060cc4f750..efd94d714db 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -107,6 +107,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `logsPanelControls` | Enables a control component for the logs panel in Explore | | `interactiveLearning` | Enables the interactive learning app | | `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | +| `newVizSuggestions` | Enable new visualization suggestions | | `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index fa9205b7bc4..f75588d2f77 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1186,6 +1186,11 @@ export interface FeatureToggles { */ newGauge?: boolean; /** + * Enable new visualization suggestions + * @default false + */ + newVizSuggestions?: boolean; + /** * Restrict PanelChrome contents with overflow: hidden; * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 68409eab390..a1c30560996 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2055,6 +2055,14 @@ var ( Owner: grafanaDatavizSquad, Expression: "false", }, + { + Name: "newVizSuggestions", + Description: "Enable new visualization suggestions", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Expression: "false", + }, { Name: "preventPanelChromeOverflow", Description: "Restrict PanelChrome contents with overflow: hidden;", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c88b2ae7ec6..7f4c7408a27 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -264,6 +264,7 @@ cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,f cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false newGauge,experimental,@grafana/dataviz-squad,false,false,true +newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bdab6514db0..64faddf160c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1066,6 +1066,10 @@ const ( // Enable new gauge visualization FlagNewGauge = "newGauge" + // FlagNewVizSuggestions + // Enable new visualization suggestions + FlagNewVizSuggestions = "newVizSuggestions" + // FlagPreventPanelChromeOverflow // Restrict PanelChrome contents with overflow: hidden; FlagPreventPanelChromeOverflow = "preventPanelChromeOverflow" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 23d85c2484b..f09d9464884 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2896,6 +2896,20 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "newVizSuggestions", + "resourceVersion": "1762456851857", + "creationTimestamp": "2025-11-06T19:20:51Z" + }, + "spec": { + "description": "Enable new visualization suggestions", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "oauthRequireSubClaim", From a67fad47349bffacb95a6301e43842940f9cbd31 Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:26:51 -0600 Subject: [PATCH 183/209] alerting: best practices docs update (#113188) * alerting: best practices docs update best practices docs update re: recording rules * Update _index.md --- docs/sources/alerting/best-practices/_index.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/sources/alerting/best-practices/_index.md b/docs/sources/alerting/best-practices/_index.md index 41251a25994..4e08dd8ef37 100644 --- a/docs/sources/alerting/best-practices/_index.md +++ b/docs/sources/alerting/best-practices/_index.md @@ -47,4 +47,11 @@ Designing and configuring an alert management set up that works takes time. Here - Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts. - Continually review your thresholds and evaluation rules. +**How should you configure recording rules?** + +- Use frequent evaluation intervals. It is recommended to set a frequent evaluation interval for recording rules. Long intervals, such as an hour, can cause the recorded metric to be stale and lead to misaligned alert rule evaluations, especially when combined with a long pending period. +- Understand query types. Grafana Alerting uses both **Instant** and **Range** queries. Instant queries fetch a single data point, while Range queries fetch a series of data points over time. When using a Range query in an alert condition, you must use a Reduce expression to aggregate the series into a single value. +- Align alert evaluation with recording frequency. The evaluation interval of an alert rule that depends on a recorded metric should be aligned with the recording rule's interval. If a recording rule runs every 3 minutes, the alert rule should also be evaluated at a similar frequency to ensure it acts on fresh data. +- Use `_over_time` functions for instant queries. Since all alert rules are ultimately executed as an instant query, you can use functions like `max_over_time(my_metric[1h])` as an instant query. This allows you to get an aggregated value over a period without using a range query and a reduce expression. + {{< /shared >}} From cbd794d0b86298e5c6c7d2faaa8cfc00137d44b4 Mon Sep 17 00:00:00 2001 From: Charandas <542168+charandas@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:21:28 -0800 Subject: [PATCH 184/209] Provisioning: fix regression with webhook authz failing in MT (#113793) --- pkg/registry/apis/provisioning/register.go | 29 ++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 13104e3e2f5..e2030ef0818 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -287,6 +287,16 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionAllow, "", nil } + // Check if any extra authorizer has a decision. + // Since the move to access checker when useExclusivelyAccessCheckerForAuthz=true, extra authorizers + // need to run first because access checker is not aware of the extras logic + for _, extra := range b.extras { + decision, reason, err := extra.Authorize(ctx, a) + if decision != authorizer.DecisionNoOpinion { + return decision, reason, err + } + } + info, ok := authlib.AuthInfoFrom(ctx) // when running as standalone API server, the identity type may not always match TypeAccessPolicy // so we allow it to use the access checker if there is any auth info available @@ -310,6 +320,12 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionAllow, "", nil } + + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } + // Different routes may need different permissions. // * Reading and modifying a repository's configuration requires administrator privileges. // * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges. @@ -322,19 +338,6 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { // * Testing a repository configuration requires administrator privileges. // * Viewing a repository's history requires editor privileges. - id, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "failed to find requester", err - } - - // Check if any extra authorizer has a decision. - for _, extra := range b.extras { - decision, reason, err := extra.Authorize(ctx, a) - if decision != authorizer.DecisionNoOpinion { - return decision, reason, err - } - } - switch a.GetResource() { case provisioning.RepositoryResourceInfo.GetName(): // TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise. From 3e31f7b7138c0e8f683cfa19f9efd05de68d35d3 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 12 Nov 2025 16:31:33 -0500 Subject: [PATCH 185/209] Suggestions: Update ownership of core files and improve some types (#113254) --- .github/CODEOWNERS | 2 + eslint-suppressions.json | 2 +- packages/grafana-data/src/index.ts | 12 ++- .../grafana-data/src/panel/PanelPlugin.ts | 2 +- packages/grafana-data/src/types/panel.ts | 96 ----------------- .../grafana-data/src/types/suggestions.ts | 100 ++++++++++++++++++ .../VisualizationSuggestions.tsx | 2 +- .../getAllSuggestions.test.ts | 3 +- .../getAllSuggestions.ts | 0 .../app/plugins/panel/bargauge/suggestions.ts | 10 +- public/app/plugins/panel/gauge/suggestions.ts | 7 +- .../plugins/panel/radialbar/suggestions.ts | 9 +- public/app/plugins/panel/stat/suggestions.ts | 8 +- 13 files changed, 132 insertions(+), 121 deletions(-) create mode 100644 packages/grafana-data/src/types/suggestions.ts rename public/app/features/panel/{state => suggestions}/getAllSuggestions.test.ts (98%) rename public/app/features/panel/{state => suggestions}/getAllSuggestions.ts (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 234789dfa4c..0ccf1ab5333 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -558,6 +558,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-data/src/transformations/ @grafana/datapro /packages/grafana-data/src/types/ @grafana/grafana-frontend-platform /packages/grafana-data/src/types/scopes.ts @grafana/grafana-operator-experience-squad +/packages/grafana-data/src/types/suggestions.ts @grafana/dataviz-squad /packages/grafana-data/src/utils/__snapshots__/ @grafanabot /packages/grafana-data/src/utils/anyToNumber.ts @grafana/grafana-frontend-platform /packages/grafana-data/src/utils/arrayUtils* @grafana/grafana-frontend-platform @@ -942,6 +943,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform /public/app/features/notifications/ @grafana/grafana-search-navigate-organise /public/app/features/org/ @grafana/grafana-search-navigate-organise /public/app/features/panel/ @grafana/dashboards-squad +/public/app/features/panel/suggestions/ @grafana/dataviz-squad /public/app/features/playlist/ @grafana/dashboards-squad /public/app/features/plugins/ @grafana/plugins-platform-frontend /public/app/features/profile/ @grafana/grafana-frontend-platform diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 57b56b49744..bcb6d105d35 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -237,7 +237,7 @@ }, "packages/grafana-data/src/types/panel.ts": { "@typescript-eslint/no-explicit-any": { - "count": 13 + "count": 11 } }, "packages/grafana-data/src/types/plugin.ts": { diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index a3ef43c91e8..06b7f19444e 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -651,12 +651,7 @@ export { type PanelMenuItem, type AngularPanelMenuItem, type PanelPluginDataSupport, - type VisualizationSuggestion, - type VisualizationSuggestionsSupplier, VizOrientation, - VisualizationSuggestionScore, - VisualizationSuggestionsBuilder, - VisualizationSuggestionsListAppender, } from './types/panel'; export { type DataSourcePluginOptionsEditorProps, @@ -717,6 +712,13 @@ export { type ApplyFieldOverrideOptions, FieldConfigProperty, } from './types/fieldOverrides'; +export { + type VisualizationSuggestion, + type VisualizationSuggestionsSupplier, + VisualizationSuggestionScore, + VisualizationSuggestionsBuilder, + VisualizationSuggestionsListAppender, +} from './types/suggestions'; export { type MatcherConfig, type DataTransformContext, diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index 4822d4c6acb..e9119a37b52 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -7,7 +7,6 @@ import { PanelModel } from '../types/dashboard'; import { FieldConfigProperty, FieldConfigSource } from '../types/fieldOverrides'; import { PanelPluginMeta, - VisualizationSuggestionsSupplier, PanelProps, PanelEditorProps, PanelMigrationHandler, @@ -15,6 +14,7 @@ import { PanelPluginDataSupport, } from '../types/panel'; import { GrafanaPlugin } from '../types/plugin'; +import { VisualizationSuggestionsSupplier } from '../types/suggestions'; import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; import { deprecationWarning } from '../utils/deprecationWarning'; diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 426c52abadf..2664d0bec72 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -1,8 +1,5 @@ -import { defaultsDeep } from 'lodash'; - import { EventBus } from '../events/types'; import { StandardEditorProps } from '../field/standardFieldConfigEditorRegistry'; -import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary'; import { Registry } from '../utils/Registry'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; @@ -17,7 +14,6 @@ import { IconName } from './icon'; import { OptionEditorConfig } from './options'; import { PluginMeta } from './plugin'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; -import { DataTransformerConfig } from './transformations'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -219,95 +215,3 @@ export interface PanelPluginDataSupport { annotations: boolean; alertStates: boolean; } - -/** - * @alpha - */ -export interface VisualizationSuggestion { - /** Name of suggestion */ - name: string; - /** Description */ - description?: string; - /** Panel plugin id */ - pluginId: string; - /** Panel plugin options */ - options?: Partial; - /** Panel plugin field options */ - fieldConfig?: FieldConfigSource>; - /** Data transformations */ - transformations?: DataTransformerConfig[]; - /** Options for how to render suggestion card */ - cardOptions?: { - /** Tweak for small preview */ - previewModifier?: (suggestion: VisualizationSuggestion) => void; - icon?: string; - imgSrc?: string; - }; - /** A value between 0-100 how suitable suggestion is */ - score?: VisualizationSuggestionScore; -} - -/** - * @alpha - */ -export enum VisualizationSuggestionScore { - /** We are pretty sure this is the best possible option */ - Best = 100, - /** Should be a really good option */ - Good = 70, - /** Can be visualized but there are likely better options. If no score is set this score is assumed */ - OK = 50, -} - -/** - * @alpha - */ -export class VisualizationSuggestionsBuilder { - /** Current data */ - data?: PanelData; - /** Current panel & options */ - panel?: PanelModel; - /** Summary stats for current data */ - dataSummary: PanelDataSummary; - - private list: VisualizationSuggestion[] = []; - - constructor(data?: PanelData, panel?: PanelModel) { - this.data = data; - this.panel = panel; - this.dataSummary = getPanelDataSummary(this.data?.series); - } - - getListAppender(defaults: VisualizationSuggestion) { - return new VisualizationSuggestionsListAppender(this.list, defaults); - } - - getList() { - return this.list; - } -} - -/** - * @alpha - */ -export type VisualizationSuggestionsSupplier = { - /** - * Adds good suitable suggestions for the current data - */ - getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; -}; - -/** - * Helps with typings and defaults - * @alpha - */ -export class VisualizationSuggestionsListAppender { - constructor( - private list: VisualizationSuggestion[], - private defaults: VisualizationSuggestion - ) {} - - append(overrides: Partial>) { - this.list.push(defaultsDeep(overrides, this.defaults)); - } -} diff --git a/packages/grafana-data/src/types/suggestions.ts b/packages/grafana-data/src/types/suggestions.ts new file mode 100644 index 00000000000..a7b2611a990 --- /dev/null +++ b/packages/grafana-data/src/types/suggestions.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { defaultsDeep } from 'lodash'; + +import { DataTransformerConfig } from '@grafana/schema'; + +import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary'; + +import { PanelModel } from './dashboard'; +import { FieldConfigSource } from './fieldOverrides'; +import { PanelData } from './panel'; + +/** + * @alpha + */ +export interface VisualizationSuggestion { + /** Name of suggestion */ + name: string; + /** Description */ + description?: string; + /** Panel plugin id */ + pluginId: string; + /** Panel plugin options */ + options?: Partial; + /** Panel plugin field options */ + fieldConfig?: FieldConfigSource>; + /** Data transformations */ + transformations?: DataTransformerConfig[]; + /** Options for how to render suggestion card */ + cardOptions?: { + /** Tweak for small preview */ + previewModifier?: (suggestion: VisualizationSuggestion) => void; + icon?: string; + imgSrc?: string; + }; + /** A value between 0-100 how suitable suggestion is */ + score?: VisualizationSuggestionScore; +} + +/** + * @alpha + */ +export enum VisualizationSuggestionScore { + /** We are pretty sure this is the best possible option */ + Best = 100, + /** Should be a really good option */ + Good = 70, + /** Can be visualized but there are likely better options. If no score is set this score is assumed */ + OK = 50, +} + +/** + * @alpha + */ +export class VisualizationSuggestionsBuilder { + /** Summary stats for current data */ + dataSummary: PanelDataSummary; + private list: VisualizationSuggestion[] = []; + + constructor( + /** Current data */ + public data?: PanelData, + /** Current panel & options */ + public panel?: PanelModel + ) { + this.dataSummary = getPanelDataSummary(data?.series); + } + + getListAppender(defaults: VisualizationSuggestion) { + return new VisualizationSuggestionsListAppender(this.list, defaults); + } + + getList() { + return this.list; + } +} + +/** + * @alpha + */ +export type VisualizationSuggestionsSupplier = { + /** + * Adds good suitable suggestions for the current data + */ + getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; +}; + +/** + * Helps with typings and defaults + * @alpha + */ +export class VisualizationSuggestionsListAppender { + constructor( + private list: VisualizationSuggestion[], + private defaults: VisualizationSuggestion + ) {} + + append(overrides: Partial>) { + this.list.push(defaultsDeep(overrides, this.defaults)); + } +} diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 98e6fd7f980..d78b56f89a5 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -7,7 +7,7 @@ import { GrafanaTheme2, PanelData, PanelModel, VisualizationSuggestion } from '@ import { Trans } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; -import { getAllSuggestions } from '../../state/getAllSuggestions'; +import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { VizTypeChangeDetails } from './types'; diff --git a/public/app/features/panel/state/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts similarity index 98% rename from public/app/features/panel/state/getAllSuggestions.test.ts rename to public/app/features/panel/suggestions/getAllSuggestions.test.ts index 0ce616f9309..e0b194e1901 100644 --- a/public/app/features/panel/state/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -8,6 +8,7 @@ import { toDataFrame, VisualizationSuggestion, } from '@grafana/data'; +import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema'; import { config } from 'app/core/config'; import { SuggestionName } from 'app/types/suggestions'; @@ -32,7 +33,7 @@ config.panels['text'] = { class ScenarioContext { data: DataFrame[] = []; - suggestions: VisualizationSuggestion[] = []; + suggestions: Array> = []; setData(scenarioData: DataFrame[]) { this.data = scenarioData; diff --git a/public/app/features/panel/state/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts similarity index 100% rename from public/app/features/panel/state/getAllSuggestions.ts rename to public/app/features/panel/suggestions/getAllSuggestions.ts diff --git a/public/app/plugins/panel/bargauge/suggestions.ts b/public/app/plugins/panel/bargauge/suggestions.ts index 9acbad2451b..61d48ce104a 100644 --- a/public/app/plugins/panel/bargauge/suggestions.ts +++ b/public/app/plugins/panel/bargauge/suggestions.ts @@ -1,4 +1,4 @@ -import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; +import { FieldColorModeId, VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; import { BarGaugeDisplayMode } from '@grafana/ui'; import { SuggestionName } from 'app/types/suggestions'; @@ -44,7 +44,7 @@ export class BarGaugeSuggestionsSupplier { fieldConfig: { defaults: { color: { - mode: 'continuous-GrYlRd', + mode: FieldColorModeId.ContinuousGrYlRd, }, }, overrides: [], @@ -64,7 +64,7 @@ export class BarGaugeSuggestionsSupplier { fieldConfig: { defaults: { color: { - mode: 'continuous-GrYlRd', + mode: FieldColorModeId.ContinuousGrYlRd, }, }, overrides: [], @@ -84,7 +84,7 @@ export class BarGaugeSuggestionsSupplier { fieldConfig: { defaults: { color: { - mode: 'continuous-GrYlRd', + mode: FieldColorModeId.ContinuousGrYlRd, }, }, overrides: [], @@ -104,7 +104,7 @@ export class BarGaugeSuggestionsSupplier { fieldConfig: { defaults: { color: { - mode: 'continuous-GrYlRd', + mode: FieldColorModeId.ContinuousGrYlRd, }, }, overrides: [], diff --git a/public/app/plugins/panel/gauge/suggestions.ts b/public/app/plugins/panel/gauge/suggestions.ts index dd1d4ad0c82..5f5ebc8f40e 100644 --- a/public/app/plugins/panel/gauge/suggestions.ts +++ b/public/app/plugins/panel/gauge/suggestions.ts @@ -1,4 +1,5 @@ import { ThresholdsMode, VisualizationSuggestionsBuilder } from '@grafana/data'; +import { GraphFieldConfig } from '@grafana/ui'; import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; @@ -16,7 +17,7 @@ export class GaugeSuggestionsSupplier { return; } - const list = builder.getListAppender({ + const list = builder.getListAppender({ name: SuggestionName.Gauge, pluginId: 'gauge', options: {}, @@ -36,8 +37,8 @@ export class GaugeSuggestionsSupplier { }, cardOptions: { previewModifier: (s) => { - if (s.options!.reduceOptions.values) { - s.options!.reduceOptions.limit = 2; + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 2; } }, }, diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 468451be9f6..8471dc27cbe 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -1,5 +1,6 @@ import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen'; +import { FieldColorModeId } from '@grafana/schema'; +import { GraphFieldConfig } from '@grafana/ui'; import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; @@ -17,7 +18,7 @@ export class GaugeSuggestionsSupplier { return; } - const list = builder.getListAppender({ + const list = builder.getListAppender({ name: SuggestionName.Gauge, pluginId: 'gauge', options: {}, @@ -27,8 +28,8 @@ export class GaugeSuggestionsSupplier { }, cardOptions: { previewModifier: (s) => { - if (s.options!.reduceOptions.values) { - s.options!.reduceOptions.limit = 2; + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 2; } }, }, diff --git a/public/app/plugins/panel/stat/suggestions.ts b/public/app/plugins/panel/stat/suggestions.ts index c07fa1d1b07..00af3220135 100644 --- a/public/app/plugins/panel/stat/suggestions.ts +++ b/public/app/plugins/panel/stat/suggestions.ts @@ -1,5 +1,5 @@ import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { BigValueColorMode, BigValueGraphMode } from '@grafana/schema'; +import { BigValueColorMode, BigValueGraphMode, GraphFieldConfig } from '@grafana/schema'; import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; @@ -12,7 +12,7 @@ export class StatSuggestionsSupplier { return; } - const list = builder.getListAppender({ + const list = builder.getListAppender({ name: SuggestionName.Stat, pluginId: 'stat', options: {}, @@ -25,8 +25,8 @@ export class StatSuggestionsSupplier { }, cardOptions: { previewModifier: (s) => { - if (s.options!.reduceOptions.values) { - s.options!.reduceOptions.limit = 1; + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 1; } }, }, From e60ad6f1959b0f4cd244283b5e2c507f3e23201e Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 00:40:07 +0000 Subject: [PATCH 186/209] I18n: Download translations from Crowdin (#113805) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 8 +++++--- public/locales/de-DE/grafana.json | 8 +++++--- public/locales/es-ES/grafana.json | 8 +++++--- public/locales/fr-FR/grafana.json | 8 +++++--- public/locales/hu-HU/grafana.json | 8 +++++--- public/locales/id-ID/grafana.json | 8 +++++--- public/locales/it-IT/grafana.json | 8 +++++--- public/locales/ja-JP/grafana.json | 8 +++++--- public/locales/ko-KR/grafana.json | 8 +++++--- public/locales/nl-NL/grafana.json | 8 +++++--- public/locales/pl-PL/grafana.json | 8 +++++--- public/locales/pt-BR/grafana.json | 8 +++++--- public/locales/pt-PT/grafana.json | 8 +++++--- public/locales/ru-RU/grafana.json | 8 +++++--- public/locales/sv-SE/grafana.json | 8 +++++--- public/locales/tr-TR/grafana.json | 8 +++++--- public/locales/zh-Hans/grafana.json | 8 +++++--- public/locales/zh-Hant/grafana.json | 8 +++++--- 18 files changed, 90 insertions(+), 54 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index bb5e3e4dedb..9c098a1df95 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -6798,9 +6798,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Další podrobnosti o chybě" }, - "footer": { - "add-csv-or-spreadsheet": "Přidat soubor CSV nebo tabulku" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Zdroj dat Adobe Analytics", @@ -11869,6 +11866,11 @@ "path-label": "Cesta k úložišti", "path-required": "Cesta k úložišti je povinná" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Po nastavení bude vytvořena nová složka Grafany a synchronizována s externím úložištěm. Pokud jsou v externím úložišti přítomny nějaké zdroje, budou zajištěny v této nové složce. Všechny nové zdroje vytvořené v této složce budou uloženy a upraveny v externím úložišti.", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e3fae8d9105..1b9cccb64ba 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Weitere Details zum Fehler" }, - "footer": { - "add-csv-or-spreadsheet": "CSV oder Tabelle hinzufügen" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-Datenquelle", @@ -11763,6 +11760,11 @@ "path-label": "Repository-Pfad", "path-required": "Repository-Pfad erforderlich" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Nach der Einrichtung wird ein neuer Grafana-Ordner erstellt und mit dem externen Speicher synchronisiert. Wenn im externen Speicher Ressourcen vorhanden sind, werden sie in diesem neuen Ordner bereitgestellt. Alle in diesem Ordner erstellten neuen Ressourcen werden im externen Speicher gespeichert und versioniert.", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index a3be9fc5549..9b4353f5c12 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Más detalles sobre el error" }, - "footer": { - "add-csv-or-spreadsheet": "Añadir CSV u hoja de cálculo" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Fuente de datos de Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Ruta del repositorio", "path-required": "La ruta del repositorio es obligatoria" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Después de la configuración, se creará una nueva carpeta de Grafana y se sincronizará con el almacenamiento externo. Si hay recursos presentes en el almacenamiento externo, se aprovisionarán en esta nueva carpeta. Todos los recursos nuevos creados en esta carpeta se almacenarán y versionarán en un almacenamiento externo.", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7f571ea83cd..79a47ec9cd3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Plus de détails sur l’erreur" }, - "footer": { - "add-csv-or-spreadsheet": "Ajouter un fichier CSV ou une feuille de calcul" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Source de données Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Chemin du dépôt", "path-required": "Le chemin du dépôt est requis" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Après la configuration, un nouveau dossier Grafana sera créé et synchronisé avec un stockage externe. Si des ressources sont présentes dans le stockage externe, elles seront mises en service dans ce nouveau dossier. Toutes les nouvelles ressources créées dans ce dossier seront stockées et versionnées dans un stockage externe.", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 4867f45641f..f1696f4a38d 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "További részletek a hibáról" }, - "footer": { - "add-csv-or-spreadsheet": "CSV vagy táblázat hozzáadása" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-adatforrás", @@ -11763,6 +11760,11 @@ "path-label": "Adattár elérési útja", "path-required": "Az adattár elérési útvonalának megadása kötelező" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "A beállítás után egy új Grafana-mappa jön létre, és szinkronizálódik a külső tárolóval. Ha vannak erőforrások a külső tárolóban, azok ebbe az új mappába kerülnek. A mappában létrehozott összes új erőforrás külső tárolóban lesz tárolva és verziószámozva.", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 64918e98bed..3d1eb7ba91f 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Detail lebih lanjut tentang kesalahan" }, - "footer": { - "add-csv-or-spreadsheet": "Tambahkan csv atau spreadsheet" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Sumber data Adobe Analytics", @@ -11710,6 +11707,11 @@ "path-label": "Jalur Repositori", "path-required": "Jalur repositori wajib diisi" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Setelah penyiapan, folder Grafana baru akan dibuat dan disinkronkan dengan penyimpanan eksternal. Jika ada sumber daya yang ada di penyimpanan eksternal, sumber daya akan disediakan ke folder baru ini. Semua sumber daya baru yang dibuat dalam folder ini akan disimpan dan diberi versi dalam penyimpanan eksternal.", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 637ec3ef191..a969743972d 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Maggiori dettagli sull'errore" }, - "footer": { - "add-csv-or-spreadsheet": "Aggiungi csv o foglio di calcolo" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Origine dati Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Percorso repository", "path-required": "Il percorso del repository è obbligatorio" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Dopo la configurazione, verrà creata una nuova cartella Grafana, che sarà sincronizzata con la memoria esterna. Se sono presenti risorse nella memoria esterna, verranno fornite a questa nuova cartella. Tutte le nuove risorse create in questa cartella saranno salvate e riceveranno un numero di versione in una memoria esterna.", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 9d065377154..c781e2ec4a1 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "エラーの詳細" }, - "footer": { - "add-csv-or-spreadsheet": "CSVまたはスプレッドシートを追加" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analyticsデータソース", @@ -11710,6 +11707,11 @@ "path-label": "リポジトリパス", "path-required": "リポジトリパスが必要です" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "セットアップ後、新しいGrafanaフォルダが作成され、外部ストレージと同期されます。外部ストレージにリソースがある場合、それらはこの新しいフォルダにプロビジョニングされます。このフォルダで作成された新しいリソースはすべて、外部ストレージに保存されバージョン管理されます。", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index b1daa0f19a2..40b1b458cb9 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "오류에 대한 자세한 정보" }, - "footer": { - "add-csv-or-spreadsheet": "CSV 또는 스프레드시트 추가" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics 데이터 소스", @@ -11710,6 +11707,11 @@ "path-label": "리포지토리 경로", "path-required": "리포지토리 경로는 필수 항목입니다" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "설정 후 새 Grafana 폴더가 생성되고 외부 스토리지와 동기화됩니다. 외부 스토리지에 리소스가 있는 경우 해당 리소스가 이 새 폴더에 프로비저닝됩니다. 이 폴더에 생성된 모든 새 리소스는 외부 스토리지에 저장되고 버전이 관리됩니다.", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index c399b5f60d2..80dda357e6c 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Meer informatie over de fout" }, - "footer": { - "add-csv-or-spreadsheet": "Csv of spreadsheet toevoegen" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-gegevensbron", @@ -11763,6 +11760,11 @@ "path-label": "Repository-pad", "path-required": "Repository-pad is vereist" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Na het instellen wordt een nieuwe Grafana-map gemaakt en gesynchroniseerd met externe opslag. Als er bronnen aanwezig zijn in externe opslag, worden deze in deze nieuwe map ingericht. Alle nieuwe bronnen die in deze map worden gemaakt, worden opgeslagen en in versies beheerd in externe opslag.", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index a4a11d378cf..ab9d515dadf 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -6798,9 +6798,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Więcej informacji o błędzie" }, - "footer": { - "add-csv-or-spreadsheet": "Dodaj plik CSV lub arkusz kalkulacyjny" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Źródło danych Adobe Analytics", @@ -11869,6 +11866,11 @@ "path-label": "Ścieżka do repozytorium", "path-required": "Ścieżka do repozytorium jest wymagana" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Po konfiguracji utworzony zostanie nowy folder Grafany, który zostanie zsynchronizowany z zewnętrzną pamięcią masową. Jeśli w zewnętrznej pamięci masowej są dostępne zasoby, zostaną one przydzielone do tego nowego folderu. Wszystkie nowe zasoby utworzone w tym folderze będą przechowywane i wersjonowane w zewnętrznej pamięci masowej.", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index b878fecb6aa..4d6c3429b55 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Mais detalhes sobre o erro" }, - "footer": { - "add-csv-or-spreadsheet": "Adicionar csv ou planilha" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Fonte de dados do Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Diretório do repositório", "path-required": "O diretório do repositório é obrigatório" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Após a configuração, uma nova pasta Grafana será criada e sincronizada com o armazenamento externo. Se algum recurso estiver presente no armazenamento externo, ele será provisionado para esta nova pasta. Todos os novos recursos criados nesta pasta serão armazenados e versionados no armazenamento externo.", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 81a7c3b856e..df86672dd25 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Mais detalhes sobre o erro" }, - "footer": { - "add-csv-or-spreadsheet": "Adicionar csv ou folha de cálculo" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Origem de dados do Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Caminho do repositório", "path-required": "O caminho do repositório é obrigatório" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Após a configuração, uma nova pasta Grafana será criada e sincronizada com o armazenamento externo. Se houver recursos presentes no armazenamento externo, estes serão aprovisionados para esta nova pasta. Todos os novos recursos criados nesta pasta serão armazenados e versionados no armazenamento externo.", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 528cfeaba88..5864941ec5c 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -6798,9 +6798,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Подробнее об ошибке" }, - "footer": { - "add-csv-or-spreadsheet": "Добавить CSV-файл или электронную таблицу" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Источник данных Adobe Analytics", @@ -11869,6 +11866,11 @@ "path-label": "Путь к репозиторию", "path-required": "Требуется указать путь к репозиторию" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "После настройки будет создана новая папка Grafana, которая синхронизируется с внешним хранилищем. Если во внешнем хранилище есть ресурсы, они будут перемещены в эту новую папку. Все новые ресурсы, созданные в этой папке, будут храниться с присвоением версии во внешнем хранилище.", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 19e5aa10a9b..9a9260ef5a2 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Mer information om felet" }, - "footer": { - "add-csv-or-spreadsheet": "Lägg till csv eller kalkylblad" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-datakälla", @@ -11763,6 +11760,11 @@ "path-label": "Sökväg till lagringsplats", "path-required": "Sökväg till lagringsplats krävs" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Efter installationen kommer en ny Grafana-mapp att skapas och synkroniseras med extern lagring. Om några resurser finns i extern lagring kommer de att provisioneras till den här nya mappen. Alla nya resurser som skapas i den här mappen kommer att sparas och versionshanteras i extern lagring.", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 8e8b0e9b36b..f027d72a81e 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Hata hakkında daha fazla bilgi" }, - "footer": { - "add-csv-or-spreadsheet": "CSV veya elektronik tablo ekle" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics veri kaynağı", @@ -11763,6 +11760,11 @@ "path-label": "Depo Yolu", "path-required": "Depo yolu gereklidir" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Kurulumdan sonra yeni bir Grafana klasörü oluşturulacak ve harici depolama ile senkronize edilecek. Harici depolamada bulunan kaynaklar bu yeni klasöre sağlanacak. Bu klasörde oluşturulan tüm yeni kaynaklar haricî depolamada saklanacak ve sürümlendirilecektir.", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index b1dcd4ccf2f..0e66d9ee83b 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "有关错误的更多详情" }, - "footer": { - "add-csv-or-spreadsheet": "添加 csv 或电子表格" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics 数据源", @@ -11710,6 +11707,11 @@ "path-label": "存储库路径", "path-required": "存储库路径为必填项" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "设置后,将创建一个新的 Grafana 文件夹,并与外部存储同步。如果外部存储中存在任何资源,它们将被预配到此新文件夹中。在此文件夹中创建的所有新资源都将存储在外部存储中并进行版本控制。", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index a0bbdcef7de..8eecca2042f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "有關錯誤的更多詳細資料" }, - "footer": { - "add-csv-or-spreadsheet": "新增 CSV 或試算表" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics 資料來源", @@ -11710,6 +11707,11 @@ "path-label": "儲存庫路徑", "path-required": "儲存庫路徑為必填" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "設定後,將建立新的 Grafana 資料夾,並與外部儲存空間同步。如果外部儲存空間中存在任何資源,則會將其佈建到此新資料夾。在此資料夾中建立的所有新資源都將儲存在外部儲存空間中,並進行版本控制。", From 6c8c4c32b5544b3ca899c9538339be57cdb582e0 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 13 Nov 2025 03:31:18 +0000 Subject: [PATCH 187/209] Chore: Cleanup FEP feature toggles (#113772) * reassign some feature toggles * remove templateVariablesUsesCombobox toggle as it's unused --- .../src/types/featureToggles.gen.ts | 4 -- pkg/services/featuremgmt/registry.go | 19 ++----- pkg/services/featuremgmt/toggles_gen.csv | 13 ++--- pkg/services/featuremgmt/toggles_gen.go | 4 -- pkg/services/featuremgmt/toggles_gen.json | 57 ++++++++++++------- 5 files changed, 50 insertions(+), 47 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f75588d2f77..9b85c451ecf 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -839,10 +839,6 @@ export interface FeatureToggles { */ teamHttpHeadersTempo?: boolean; /** - * Use new **Combobox** component for template variables - */ - templateVariablesUsesCombobox?: boolean; - /** * Enables Advisor app */ grafanaAdvisor?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a1c30560996..0921c25d5ce 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -169,7 +169,7 @@ var ( Description: "populate star status from apiserver", Stage: FeatureStageExperimental, FrontendOnly: true, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, AllowSelfServe: false, HideFromDocs: true, }, @@ -1001,7 +1001,7 @@ var ( Name: "pinNavItems", Description: "Enables pinning of nav items", Stage: FeatureStageGeneralAvailability, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, Expression: "true", // enabled by default }, { @@ -1394,7 +1394,7 @@ var ( Name: "unifiedHistory", Description: "Displays the navigation history so the user can navigate back to previous pages", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, FrontendOnly: true, }, { @@ -1442,13 +1442,6 @@ var ( FrontendOnly: false, Owner: identityAccessTeam, }, - { - Name: "templateVariablesUsesCombobox", - Description: "Use new **Combobox** component for template variables", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, - FrontendOnly: true, - }, { Name: "grafanaAdvisor", Description: "Enables Advisor app", @@ -1517,7 +1510,7 @@ var ( Name: "useScopesNavigationEndpoint", Description: "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaOperatorExperienceSquad, FrontendOnly: true, HideFromDocs: true, HideFromAdminPage: true, @@ -1526,7 +1519,7 @@ var ( Name: "scopeSearchAllLevels", Description: "Enable scope search to include all levels of the scope node tree", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaOperatorExperienceSquad, HideFromDocs: true, HideFromAdminPage: true, }, @@ -1779,7 +1772,7 @@ var ( Name: "restoreDashboards", Description: "Enables restore deleted dashboards feature", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, HideFromAdminPage: true, Expression: "false", }, diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 7f4c7408a27..26f593dfcd3 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -19,7 +19,7 @@ lokiShardSplitting,experimental,@grafana/observability-logs,false,false,true lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true individualCookiePreferences,experimental,@grafana/grafana-backend-group,false,false,false influxdbBackendMigration,GA,@grafana/partner-datasources,false,false,true -starsFromAPIServer,experimental,@grafana/grafana-frontend-platform,false,false,true +starsFromAPIServer,experimental,@grafana/grafana-search-navigate-organise,false,false,true kubernetesStars,experimental,@grafana/grafana-app-platform-squad,false,true,false influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false @@ -130,7 +130,7 @@ preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,fals alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false -pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false +pinNavItems,GA,@grafana/grafana-search-navigate-organise,false,false,false authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false ssoSettingsLDAP,GA,@grafana/identity-access-team,false,true,false zanzana,experimental,@grafana/identity-access-team,false,false,false @@ -181,14 +181,13 @@ alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false -unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true +unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false -templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true @@ -197,8 +196,8 @@ newLogsPanel,GA,@grafana/observability-logs,false,false,true grafanaconThemes,GA,@grafana/grafana-frontend-platform,false,true,false alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true alertingUseNewSimplifiedRoutingHashAlgorithm,preview,@grafana/alerting-squad,false,true,false -useScopesNavigationEndpoint,experimental,@grafana/grafana-frontend-platform,false,false,true -scopeSearchAllLevels,experimental,@grafana/grafana-frontend-platform,false,false,false +useScopesNavigationEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true +scopeSearchAllLevels,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true newShareReportDrawer,preview,@grafana/grafana-operator-experience-squad,false,false,false rendererDisableAppPluginsPreload,experimental,@grafana/grafana-operator-experience-squad,false,false,true @@ -230,7 +229,7 @@ kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false, kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false -restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false +restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false alertEnrichment,experimental,@grafana/alerting-squad,false,false,false alertEnrichmentMultiStep,experimental,@grafana/alerting-squad,false,false,false alertEnrichmentConditional,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 64faddf160c..1decb0d9f6e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -763,10 +763,6 @@ const ( // Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams FlagTeamHttpHeadersTempo = "teamHttpHeadersTempo" - // FlagTemplateVariablesUsesCombobox - // Use new **Combobox** component for template variables - FlagTemplateVariablesUsesCombobox = "templateVariablesUsesCombobox" - // FlagGrafanaAdvisor // Enables Advisor app FlagGrafanaAdvisor = "grafanaAdvisor" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index f09d9464884..48ca4bb3f18 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3077,13 +3077,16 @@ { "metadata": { "name": "pinNavItems", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-06-10T11:40:03Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-06-10T11:40:03Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Enables pinning of nav items", "stage": "GA", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "expression": "true" } }, @@ -3603,13 +3606,16 @@ { "metadata": { "name": "restoreDashboards", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-05-23T14:35:54Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-05-23T14:35:54Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Enables restore deleted dashboards feature", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "hideFromAdminPage": true, "expression": "false" } @@ -3704,13 +3710,16 @@ { "metadata": { "name": "scopeSearchAllLevels", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-04-14T07:42:16Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-04-14T07:42:16Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Enable scope search to include all levels of the scope node tree", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-operator-experience-squad", "hideFromAdminPage": true, "hideFromDocs": true } @@ -3860,13 +3869,16 @@ { "metadata": { "name": "starsFromAPIServer", - "resourceVersion": "1758276055065", - "creationTimestamp": "2025-09-19T10:00:55Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-09-19T10:00:55Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "populate star status from apiserver", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "frontend": true, "hideFromDocs": true } @@ -3993,7 +4005,8 @@ "metadata": { "name": "templateVariablesUsesCombobox", "resourceVersion": "1753448760331", - "creationTimestamp": "2025-01-31T09:53:13Z" + "creationTimestamp": "2025-01-31T09:53:13Z", + "deletionTimestamp": "2025-11-12T14:40:39Z" }, "spec": { "description": "Use new **Combobox** component for template variables", @@ -4105,13 +4118,16 @@ { "metadata": { "name": "unifiedHistory", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-12-13T10:41:18Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-12-13T10:41:18Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Displays the navigation history so the user can navigate back to previous pages", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "frontend": true } }, @@ -4339,13 +4355,16 @@ { "metadata": { "name": "useScopesNavigationEndpoint", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-31T15:20:00Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-03-31T15:20:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-operator-experience-squad", "frontend": true, "hideFromAdminPage": true, "hideFromDocs": true From 3563388d4d0579054e6a1dd8a43fab9e833dd687 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 13 Nov 2025 03:32:14 +0000 Subject: [PATCH 188/209] Frontend Service: Client side redirect to custom domain (#113717) * handle redirect in frontend service * add comments --- pkg/middleware/validate_host.go | 10 ++++++++++ pkg/services/frontend/index.html | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/pkg/middleware/validate_host.go b/pkg/middleware/validate_host.go index eb077b8e06c..ebdf96b4274 100644 --- a/pkg/middleware/validate_host.go +++ b/pkg/middleware/validate_host.go @@ -32,6 +32,16 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler { } if !strings.EqualFold(h, cfg.Domain) { + // the normal redirecting logic doesn't work when running as frontend service, since it has no knowledge of the custom domain. + // instead, we modify the single tenant `/bootdata` call with a 204 response and a header indicating the domain to redirect to + // this is safe because only the frontend service calls `/bootdata` + // the redirect is then handled client side. + // see pkg/services/frontend/index.html + if c.Req.URL.Path == "/bootdata" { + c.Resp.Header().Set("Redirect-Domain", cfg.Domain) + c.Resp.WriteHeader(204) + return + } hostRedirectCounter.Inc() c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain) c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301) diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index f937a987ca4..c8ba00870b9 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -220,6 +220,16 @@ } const resp = await fetch(bootDataUrl); + + // manual redirect for custom domains + // see pkg/middleware/validate_host.go + if (resp.status === 204) { + const redirectDomain = resp.headers.get('Redirect-Domain'); + if (redirectDomain) { + window.location.hostname = redirectDomain; + return; + } + } const textResponse = await resp.text(); let rawBootData; From a5b8038f3583bafc68db838e5ffb74e5943d37be Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:33:43 +0000 Subject: [PATCH 189/209] chore(deps): update dependency nanoid to v5.1.6 (#113497) | datasource | package | from | to | | ---------- | ------- | ----- | ----- | | npm | nanoid | 5.1.5 | 5.1.6 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2055cfc9cdc..7f374311937 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24298,11 +24298,11 @@ __metadata: linkType: hard "nanoid@npm:^5.0.9": - version: 5.1.5 - resolution: "nanoid@npm:5.1.5" + version: 5.1.6 + resolution: "nanoid@npm:5.1.6" bin: nanoid: bin/nanoid.js - checksum: 10/6de2d006b51c983be385ef7ee285f7f2a57bd96f8c0ca881c4111461644bd81fafc2544f8e07cb834ca0f3e0f3f676c1fe78052183f008b0809efe6e273119f5 + checksum: 10/4109dbcf596d7f297a9b42f459b8f01694a03ebbdd2f41408d963ad54e5ec7234cbe7b4acad137751f31add11bb4fb3415a3e688082516745812811f05570014 languageName: node linkType: hard From 55a29bd2d1699d4750b40bcb641f80ce8cb3d1b3 Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 03:37:48 +0000 Subject: [PATCH 190/209] fix(deps): update dependency ol-ext to v4.0.36 (#113788) | datasource | package | from | to | | ---------- | ------- | ------ | ------ | | npm | ol-ext | 4.0.35 | 4.0.36 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index f626ebc1486..fc861e15f3c 100644 --- a/package.json +++ b/package.json @@ -382,7 +382,7 @@ "nanoid": "^5.0.9", "node-forge": "^1.3.1", "ol": "10.6.1", - "ol-ext": "4.0.35", + "ol-ext": "4.0.36", "ol-mapbox-style": "^13.0.1", "pluralize": "^8.0.0", "prismjs": "1.30.0", diff --git a/yarn.lock b/yarn.lock index 7f374311937..0b2b2b7654f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19107,7 +19107,7 @@ __metadata: node-notifier: "npm:10.0.1" nx: "npm:21.3.11" ol: "npm:10.6.1" - ol-ext: "npm:4.0.35" + ol-ext: "npm:4.0.36" ol-mapbox-style: "npm:^13.0.1" open: "npm:^10.2.0" ora: "npm:^9.0.0" @@ -25281,12 +25281,12 @@ __metadata: languageName: node linkType: hard -"ol-ext@npm:4.0.35": - version: 4.0.35 - resolution: "ol-ext@npm:4.0.35" +"ol-ext@npm:4.0.36": + version: 4.0.36 + resolution: "ol-ext@npm:4.0.36" peerDependencies: ol: ">= 5.3.0" - checksum: 10/d6adcbbb4b21a785ee1113e50c6c6e5d8da8f9209019de420ff2634ca7fd5c0028aac2628ea6ce4056a9d9ef54edace0a5d3ea478fed87661a044fe32d8fa0be + checksum: 10/071ce9be427dce8be6fb8e4fbdc972038ace08090c21f6dce7ae0929fe5263518a1a80d7b205e0fd208ac337d49e3782ead805be19f74f0474c291dea8c8d01e languageName: node linkType: hard From 00e4d98ca309af10db96b5af6c88ff8b718b100f Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 05:29:11 +0000 Subject: [PATCH 191/209] chore(deps): update dependency ol-mapbox-style to v13.1.1 (#113785) | datasource | package | from | to | | ---------- | --------------- | ------ | ------ | | npm | ol-mapbox-style | 13.1.0 | 13.1.1 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0b2b2b7654f..b6c13125673 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25291,14 +25291,14 @@ __metadata: linkType: hard "ol-mapbox-style@npm:^13.0.1": - version: 13.1.0 - resolution: "ol-mapbox-style@npm:13.1.0" + version: 13.1.1 + resolution: "ol-mapbox-style@npm:13.1.1" dependencies: "@maplibre/maplibre-gl-style-spec": "npm:^23.1.0" mapbox-to-css-font: "npm:^3.2.0" peerDependencies: ol: "*" - checksum: 10/a6dba04dcfc9e0344fed9ad1a76b875eb1a1286d2b6c0f9d9d6004bb5376227fa7dee15bbe38966429ab479bec3109b39e92541a20e0e175e8205cc78c039ed2 + checksum: 10/9182bebfee63881465a9dbeecaeab361b70f4fe3abf406d755adde02cf50c765b6e5de072716e627b777ffb2b98fc72dde12bee470996139438a6b1133c370f3 languageName: node linkType: hard From 7ee61a9277cbc41b237a7acc0fa89f09c2efc5ba Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 05:29:42 +0000 Subject: [PATCH 192/209] fix(deps): update dependency @grafana/assistant to v0.1.4 (#113786) | datasource | package | from | to | | ---------- | ------------------ | ----- | ----- | | npm | @grafana/assistant | 0.1.1 | 0.1.4 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index fc861e15f3c..1d39e0c2074 100644 --- a/package.json +++ b/package.json @@ -278,7 +278,7 @@ "@glideapps/glide-data-grid": "^6.0.0", "@grafana/alerting": "workspace:*", "@grafana/api-clients": "workspace:*", - "@grafana/assistant": "0.1.1", + "@grafana/assistant": "0.1.4", "@grafana/aws-sdk": "0.7.1", "@grafana/azure-sdk": "0.0.8", "@grafana/data": "workspace:*", diff --git a/yarn.lock b/yarn.lock index b6c13125673..891c5b9c5b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3057,9 +3057,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/assistant@npm:0.1.1": - version: 0.1.1 - resolution: "@grafana/assistant@npm:0.1.1" +"@grafana/assistant@npm:0.1.4": + version: 0.1.4 + resolution: "@grafana/assistant@npm:0.1.4" peerDependencies: "@grafana/data": ">=12.1.0" "@grafana/runtime": ">=12.1.0" @@ -3067,7 +3067,7 @@ __metadata: "@grafana/ui": ">=12.1.0" react: ">=18.0.0" rxjs: ">=7.0.0" - checksum: 10/33e5b3f59b3b7a747a736f36183c77014214fa6e8048faac7a680dbba64833b1827dd7ef2e8c1ddf566a31829347449579ef6255811586b39914ca0510bb34a5 + checksum: 10/1435e4552763e88a2d4715f2e529d1a247d949563567bfb46405a2a3e348b98dc08b32c96214c2d00c46e185f92a76da8d29422b1b70edbd8447e02b3b8cf3d0 languageName: node linkType: hard @@ -18864,7 +18864,7 @@ __metadata: "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana/alerting": "workspace:*" "@grafana/api-clients": "workspace:*" - "@grafana/assistant": "npm:0.1.1" + "@grafana/assistant": "npm:0.1.4" "@grafana/aws-sdk": "npm:0.7.1" "@grafana/azure-sdk": "npm:0.0.8" "@grafana/data": "workspace:*" From eaa1b62c01bab52efd12a7edee7810877a89148d Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 05:30:10 +0000 Subject: [PATCH 193/209] fix(deps): update dependency @lezer/lr to v1.4.3 (#113787) | datasource | package | from | to | | ---------- | --------- | ----- | ----- | | npm | @lezer/lr | 1.4.2 | 1.4.3 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- public/app/plugins/datasource/tempo/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1d39e0c2074..eca1a345d05 100644 --- a/package.json +++ b/package.json @@ -306,7 +306,7 @@ "@leeoniya/ufuzzy": "1.0.19", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", - "@lezer/lr": "1.4.2", + "@lezer/lr": "1.4.3", "@locker/near-membrane-dom": "0.14.0", "@locker/near-membrane-shared": "0.14.0", "@locker/near-membrane-shared-dom": "0.14.0", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index a4d36839bf6..a0dba4898e9 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -52,7 +52,7 @@ "@leeoniya/ufuzzy": "1.0.19", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", - "@lezer/lr": "1.4.2", + "@lezer/lr": "1.4.3", "@prometheus-io/lezer-promql": "0.305.0", "@reduxjs/toolkit": "2.9.0", "@types/debounce-promise": "3.1.9", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index f207453fa4c..f2d5b0433e6 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -15,7 +15,7 @@ "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", "@lezer/common": "1.2.3", - "@lezer/lr": "1.4.2", + "@lezer/lr": "1.4.3", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.37.0", diff --git a/yarn.lock b/yarn.lock index 891c5b9c5b6..5b2f3c765a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2920,7 +2920,7 @@ __metadata: "@grafana/schema": "workspace:*" "@grafana/ui": "workspace:*" "@lezer/common": "npm:1.2.3" - "@lezer/lr": "npm:1.4.2" + "@lezer/lr": "npm:1.4.3" "@opentelemetry/api": "npm:1.9.0" "@opentelemetry/exporter-collector": "npm:0.25.0" "@opentelemetry/semantic-conventions": "npm:1.37.0" @@ -3497,7 +3497,7 @@ __metadata: "@leeoniya/ufuzzy": "npm:1.0.19" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" - "@lezer/lr": "npm:1.4.2" + "@lezer/lr": "npm:1.4.3" "@prometheus-io/lezer-promql": "npm:0.305.0" "@reduxjs/toolkit": "npm:2.9.0" "@rollup/plugin-dynamic-import-vars": "npm:2.1.5" @@ -5193,12 +5193,12 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:1.4.2": - version: 1.4.2 - resolution: "@lezer/lr@npm:1.4.2" +"@lezer/lr@npm:1.4.3": + version: 1.4.3 + resolution: "@lezer/lr@npm:1.4.3" dependencies: "@lezer/common": "npm:^1.0.0" - checksum: 10/f7b505906c8d8df14c07866553cf3dae1e065b1da8b28fbb4193fd67ab8d187eb45f92759e29a2cfe4283296f0aa864b38a0a91708ecfc3e24b8f662d626e0c6 + checksum: 10/d6dfecedcd51027b38bac756026bcbdffa2e086d9a48a4ef0f84176ca28b300d9da0e508fc77d8d64181540b6027571a7fe1dc3705abdcc5d6db45386d6fb482 languageName: node linkType: hard @@ -18896,7 +18896,7 @@ __metadata: "@leeoniya/ufuzzy": "npm:1.0.19" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" - "@lezer/lr": "npm:1.4.2" + "@lezer/lr": "npm:1.4.3" "@locker/near-membrane-dom": "npm:0.14.0" "@locker/near-membrane-shared": "npm:0.14.0" "@locker/near-membrane-shared-dom": "npm:0.14.0" From 0a9f93436ad12714c14845b7ac8f4760bfc14707 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 13 Nov 2025 09:40:47 +0100 Subject: [PATCH 194/209] Docs: Fix grpc server key file param in config ini (#113798) Docs: Fix config ini grpc server key param --- conf/defaults.ini | 2 +- conf/sample.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index e25fb0c1715..f59a5dbeb44 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -113,7 +113,7 @@ network = "tcp" address = "127.0.0.1:10000" use_tls = false cert_file = -key_file = +cert_key = # this will log the request and response for each unary gRPC call enable_logging = false diff --git a/conf/sample.ini b/conf/sample.ini index ed0233c1a29..8fcce01b2fc 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -114,7 +114,7 @@ ;address = "127.0.0.1:10000" ;use_tls = false ;cert_file = -;key_file = +;cert_key = ;max_recv_msg_size = ;max_send_msg_size = # this will log the request and response for each unary gRPC call From ac5e54a225de4d222de0e9db06a4d322d07e8c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 13 Nov 2025 09:56:10 +0100 Subject: [PATCH 195/209] Prom/Loki: Query editor padding fix (#113727) --- .../querybuilder/components/PromQueryBuilderOptions.tsx | 8 ++++---- .../querybuilder/components/LokiQueryBuilderOptions.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx index 17347c7a20b..3468be71316 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx @@ -6,8 +6,8 @@ import * as React from 'react'; import { CoreApp, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { EditorField, EditorRow, EditorSwitch } from '@grafana/plugin-ui'; -import { AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui'; +import { EditorField, EditorSwitch } from '@grafana/plugin-ui'; +import { AutoSizeInput, Box, RadioButtonGroup, Select } from '@grafana/ui'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; import { PromQueryFormat } from '../../dataquery'; @@ -80,7 +80,7 @@ export const PromQueryBuilderOptions = React.memo( const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryTypeValue)!.label; return ( - +
( )}
-
+ ); } ); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx index 8328bc42b86..27d199cb8a5 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx @@ -3,9 +3,9 @@ import { useCallback, useEffect, useMemo } from 'react'; import * as React from 'react'; import { CoreApp, isValidGrafanaDuration, LogSortOrderChangeEvent, LogsSortOrder, store } from '@grafana/data'; -import { EditorField, EditorRow, QueryOptionGroup } from '@grafana/plugin-ui'; +import { EditorField, QueryOptionGroup } from '@grafana/plugin-ui'; import { getAppEvents } from '@grafana/runtime'; -import { AutoSizeInput, RadioButtonGroup } from '@grafana/ui'; +import { AutoSizeInput, Box, RadioButtonGroup } from '@grafana/ui'; import { getQueryDirectionLabel, @@ -133,7 +133,7 @@ export const LokiQueryBuilderOptions = React.memo( }, [query.step, datasource]); return ( - + ( )} - + ); } ); From 6da33546a5a376174f7bdf9b624a9433033a4d9b Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 13 Nov 2025 09:09:15 +0000 Subject: [PATCH 196/209] Chore: Mark more files as generated in gitattributes (#113776) --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitattributes b/.gitattributes index 6dd73a786fb..506da7cc196 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,8 @@ *.gen.ts linguist-generated *_gen.ts linguist-generated *_gen.go linguist-generated +*_gen.csv linguist-generated +*_gen.json linguist-generated **/openapi_snapshots/*.json linguist-generated apps/**/pkg/apis/*_manifest.go linguist-generated public/openapi3.json linguist-generated From 5091c946b5736fcfdc7f8d51b0ee6dca84356501 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Thu, 13 Nov 2025 10:21:46 +0100 Subject: [PATCH 197/209] Correlations: Remove correlations feature toggle (#113752) * Remove correlations feature toggle The correlations feature toggle has been removed from the registry and all usages throughout the codebase have been cleaned up. Correlations are now always available. --- .../feature-toggles/index.md | 1 - .../src/types/featureToggles.gen.ts | 5 ----- pkg/services/featuremgmt/registry.go | 8 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 1 + pkg/services/navtree/navtreeimpl/admin.go | 2 +- .../CorrelationsFeatureToggle.tsx | 22 ------------------- .../app/features/explore/ExploreActions.tsx | 3 +-- public/app/features/explore/ExplorePage.tsx | 3 +-- .../extensions/ToolbarExtensionPoint.test.tsx | 4 ++++ .../extensions/ToolbarExtensionPoint.tsx | 9 ++------ public/app/routes/routes.tsx | 8 ++----- public/locales/en-US/grafana.json | 2 -- 14 files changed, 12 insertions(+), 61 deletions(-) delete mode 100644 public/app/features/correlations/CorrelationsFeatureToggle.tsx diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index efd94d714db..0b802145d87 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -27,7 +27,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | | `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes | | `featureHighlights` | Highlight Grafana Enterprise features | | -| `correlations` | Correlations page | Yes | | `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | | `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 9b85c451ecf..2f8cc13e8df 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -50,11 +50,6 @@ export interface FeatureToggles { */ storage?: boolean; /** - * Correlations page - * @default true - */ - correlations?: boolean; - /** * Allow elements nesting */ canvasPanelNesting?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 0921c25d5ce..fe4834db0ae 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -69,14 +69,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaSearchAndStorageSquad, }, - { - Name: "correlations", - Description: "Correlations page", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaDataProSquad, - Expression: "true", // enabled by default - AllowSelfServe: true, - }, { Name: "canvasPanelNesting", Description: "Allow elements nesting", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 26f593dfcd3..e493b35ca1e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -6,7 +6,6 @@ publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false, lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false storage,experimental,@grafana/search-and-storage,false,false,false -correlations,GA,@grafana/datapro,false,false,false canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true logRequestsInstrumentedAsUnknown,experimental,@grafana/grafana-backend-group,false,false,false grpcServer,preview,@grafana/search-and-storage,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1decb0d9f6e..b484694fef6 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -35,10 +35,6 @@ const ( // Configurable storage for dashboards, datasources, and resources FlagStorage = "storage" - // FlagCorrelations - // Correlations page - FlagCorrelations = "correlations" - // FlagCanvasPanelNesting // Allow elements nesting FlagCanvasPanelNesting = "canvasPanelNesting" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 48ca4bb3f18..1e511160a07 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1024,6 +1024,7 @@ "name": "correlations", "resourceVersion": "1762442825881", "creationTimestamp": "2022-09-16T13:14:27Z", + "deletionTimestamp": "2025-11-12T13:11:31Z", "annotations": { "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" } diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 02e9f44bdbd..7e2af182a87 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -89,7 +89,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Url: s.cfg.AppSubURL + "/plugins", }) } - if s.features.IsEnabled(ctx, featuremgmt.FlagCorrelations) && hasAccess(correlations.ConfigurationPageAccess) { + if hasAccess(correlations.ConfigurationPageAccess) { pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{ Text: "Correlations", Icon: "gf-glue", diff --git a/public/app/features/correlations/CorrelationsFeatureToggle.tsx b/public/app/features/correlations/CorrelationsFeatureToggle.tsx deleted file mode 100644 index ccbe05cb0e9..00000000000 --- a/public/app/features/correlations/CorrelationsFeatureToggle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Trans } from '@grafana/i18n'; -import { Page } from 'app/core/components/Page/Page'; - -export default function FeatureTogglePage() { - return ( - - -

- Correlations are disabled -

- To enable Correlations, add it in the Grafana config: -
-
-            {`[feature_toggles]
-correlations = true
-`}
-          
-
-
-
- ); -} diff --git a/public/app/features/explore/ExploreActions.tsx b/public/app/features/explore/ExploreActions.tsx index 376cc25d715..56d8fa06776 100644 --- a/public/app/features/explore/ExploreActions.tsx +++ b/public/app/features/explore/ExploreActions.tsx @@ -1,7 +1,6 @@ import { useRegisterActions, useKBar, Action, Priority } from 'kbar'; import { useEffect, useState } from 'react'; -import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { AccessControlAction } from 'app/types/accessControl'; @@ -76,7 +75,7 @@ export const ExploreActions = () => { return pane?.datasourceInstance?.uid === MIXED_DATASOURCE_NAME; }); - if (config.featureToggles.correlations && canWriteCorrelations && !hasMixed) { + if (canWriteCorrelations && !hasMixed) { actionsArr.push({ id: 'explore/correlations-editor', name: 'Correlations editor', diff --git a/public/app/features/explore/ExplorePage.tsx b/public/app/features/explore/ExplorePage.tsx index ed3c0de6d3b..a5527be8b6c 100644 --- a/public/app/features/explore/ExplorePage.tsx +++ b/public/app/features/explore/ExplorePage.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; import { ErrorBoundaryAlert, LoadingPlaceholder, useStyles2, useTheme2 } from '@grafana/ui'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; import { useGrafana } from 'app/core/context/GrafanaContext'; @@ -50,7 +49,7 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa const hasSplit = useSelector(isSplit); const correlationDetails = useSelector(selectCorrelationDetails); const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext(); - const showCorrelationEditorBar = config.featureToggles.correlations && (correlationDetails?.editorMode || false); + const showCorrelationEditorBar = correlationDetails?.editorMode || false; useEffect(() => { //This is needed for breadcrumbs and topnav. diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx index 0a72d621269..d3988d68e95 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -66,6 +66,7 @@ function setupToolbarExtensionPoint( describe('ToolbarExtensionPoint', () => { describe('with extension points', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { @@ -183,6 +184,7 @@ describe('ToolbarExtensionPoint', () => { describe('with extension points without categories', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { @@ -257,6 +259,7 @@ describe('ToolbarExtensionPoint', () => { describe('with multiple queryless apps links', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { @@ -323,6 +326,7 @@ describe('ToolbarExtensionPoint', () => { describe('with single queryless apps link', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 3c3a5f2286a..070e48caa47 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -1,7 +1,7 @@ import { ReactElement, useMemo, useState } from 'react'; import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data'; -import { config, reportInteraction, usePluginLinks } from '@grafana/runtime'; +import { reportInteraction, usePluginLinks } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; @@ -109,12 +109,7 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext { data: queryResponse, timeRange: range.raw, timeZone: getTimeZone({ timeZone }), - shouldShowAddCorrelation: - config.featureToggles.correlations === true && - canWriteCorrelations && - !isCorrelationsEditorMode && - isLeftPane && - numUniqueIds === 1, + shouldShowAddCorrelation: canWriteCorrelations && !isCorrelationsEditorMode && isLeftPane && numUniqueIds === 1, }; }, [ exploreId, diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 3a688ea53dc..462e564bbe5 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -138,12 +138,8 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/datasources/correlations', - component: SafeDynamicImport(() => - config.featureToggles.correlations - ? import(/* webpackChunkName: "CorrelationsPage" */ 'app/features/correlations/CorrelationsPage') - : import( - /* webpackChunkName: "CorrelationsFeatureToggle" */ 'app/features/correlations/CorrelationsFeatureToggle' - ) + component: SafeDynamicImport( + () => import(/* webpackChunkName: "CorrelationsPage" */ 'app/features/correlations/CorrelationsPage') ), }, { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b59d15da0bd..a17404a80e6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4378,8 +4378,6 @@ "next-button": "Next", "save-button": "Save" }, - "page-content": "To enable Correlations, add it in the Grafana config:", - "page-heading": "Correlations are disabled", "query-editor": { "control-rules": "The selected target data source must export a query editor.", "data-source-text": "Please select a target data source first.", From 6eac95f8608e96ce8a8b2b6e85f691bdd2dcba9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:27:54 +0100 Subject: [PATCH 198/209] fix: inject index min update interval into resource server (#113816) --- pkg/storage/unified/search/options.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 0773b00bc4a..ca61b684061 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -66,5 +66,9 @@ func NewSearchOptions( IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, }, nil } - return resource.SearchOptions{}, nil + return resource.SearchOptions{ + // it is used for search after write and throttles index updates + IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, + MaxIndexAge: cfg.MaxFileIndexAge, + }, nil } From b4b410f5be65c9629c12a62a23bf73f2e62301ad Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 13 Nov 2025 10:34:55 +0100 Subject: [PATCH 199/209] `grafana-iam`: Register a flag to configure dualwrite modes (#113610) * `grafana-iam`: Register a flag to configure dualwrite modes * Streamline helper code * Launch sync job only with mode 1 to 3 --- pkg/services/apiserver/builder/helper.go | 72 ++++++++++++----------- pkg/services/apiserver/options/storage.go | 61 +++++++++++++++++++ 2 files changed, 99 insertions(+), 34 deletions(-) diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index cfcf3540b43..98a9c08ed25 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -308,6 +308,7 @@ func InstallAPIs( var mode = grafanarest.DualWriterMode(0) var ( + err error dualWriterPeriodicDataSyncJobEnabled bool dualWriterMigrationDataSyncDisabled bool dataSyncerInterval = time.Hour @@ -329,29 +330,45 @@ func InstallAPIs( return storage, nil } - // TODO: inherited context from main Grafana process - ctx := context.Background() + currentMode := mode + if !dualWriterMigrationDataSyncDisabled || dualWriterPeriodicDataSyncJobEnabled { + // TODO: inherited context from main Grafana process + ctx := context.Background() - // Moving from one version to the next can only happen after the previous step has - // successfully synchronized. - requestInfo := getRequestInfo(gr, namespaceMapper) + // Moving from one version to the next can only happen after the previous step has + // successfully synchronized. + requestInfo := getRequestInfo(gr, namespaceMapper) - syncerCfg := &grafanarest.SyncerConfig{ - Kind: key, - RequestInfo: requestInfo, - Mode: mode, - SkipDataSync: dualWriterMigrationDataSyncDisabled, - LegacyStorage: legacy, - Storage: storage, - ServerLockService: serverLock, - DataSyncerInterval: dataSyncerInterval, - DataSyncerRecordsLimit: dataSyncerRecordsLimit, - } + syncerCfg := &grafanarest.SyncerConfig{ + Kind: key, + RequestInfo: requestInfo, + Mode: mode, + SkipDataSync: dualWriterMigrationDataSyncDisabled, + LegacyStorage: legacy, + Storage: storage, + ServerLockService: serverLock, + DataSyncerInterval: dataSyncerInterval, + DataSyncerRecordsLimit: dataSyncerRecordsLimit, + } - // This also sets the currentMode on the syncer config. - currentMode, err := grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg, dualWriterMetrics) - if err != nil { - return nil, err + // This also sets the currentMode on the syncer config. + currentMode, err = grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg, dualWriterMetrics) + if err != nil { + return nil, err + } + + // when unable to use + if currentMode != mode { + klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr) + } + + if dualWriterPeriodicDataSyncJobEnabled && (currentMode >= grafanarest.Mode1 && currentMode <= grafanarest.Mode3) { + // The mode might have changed in SetDualWritingMode, so apply current mode first. + syncerCfg.Mode = currentMode + if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg, dualWriterMetrics); err != nil { + return nil, err + } + } } builderMetrics.RecordDualWriterModes(gr.Resource, gr.Group, mode, currentMode) @@ -362,21 +379,8 @@ func InstallAPIs( case grafanarest.Mode4, grafanarest.Mode5: return storage, nil default: + return dualwrite.NewDualWriter(gr, currentMode, legacy, storage) } - - if dualWriterPeriodicDataSyncJobEnabled { - // The mode might have changed in SetDualWritingMode, so apply current mode first. - syncerCfg.Mode = currentMode - if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg, dualWriterMetrics); err != nil { - return nil, err - } - } - - // when unable to use - if currentMode != mode { - klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr) - } - return dualwrite.NewDualWriter(gr, currentMode, legacy, storage) } } diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index ee91ff8eb89..28f6e1046ab 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net" + "strconv" + "strings" "time" "github.com/spf13/pflag" @@ -14,6 +16,7 @@ import ( "k8s.io/apiserver/pkg/server/options" "k8s.io/client-go/rest" + apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/tracing" secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" inlinesecurevalue "github.com/grafana/grafana/pkg/registry/apis/secret/inline" @@ -87,6 +90,58 @@ type StorageOptions struct { ConfigProvider RestConfigProvider } +// unifiedStorageConfigValue implements pflag.Value for parsing unified storage config +type unifiedStorageConfigValue struct { + config *map[string]setting.UnifiedStorageConfig +} + +func (v *unifiedStorageConfigValue) String() string { + if v.config == nil || len(*v.config) == 0 { + return "" + } + parts := make([]string, 0, len(*v.config)) + for key, cfg := range *v.config { + parts = append(parts, fmt.Sprintf("%s=%d", key, cfg.DualWriterMode)) + } + return strings.Join(parts, ",") +} + +func (v *unifiedStorageConfigValue) Set(val string) error { + if val == "" { + return nil + } + + // Parse comma-separated key=value pairs + pairs := strings.Split(val, ",") + for _, pair := range pairs { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid format: %s (expected key=value)", pair) + } + + key := strings.TrimSpace(kv[0]) + mode, err := strconv.Atoi(strings.TrimSpace(kv[1])) + if err != nil { + return fmt.Errorf("invalid mode value for %s: %w", key, err) + } + + if mode < 0 || mode > 5 { + return fmt.Errorf("mode must be between 0 and 5, got %d for %s", mode, key) + } + + (*v.config)[key] = setting.UnifiedStorageConfig{ + DualWriterMode: apiserverrest.DualWriterMode(mode), + DualWriterMigrationDataSyncDisabled: true, + } + } + + return nil +} + +func (v *unifiedStorageConfigValue) Type() string { + return "stringToUnifiedStorageConfig" +} + func NewStorageOptions() *StorageOptions { return &StorageOptions{ StorageType: StorageTypeUnified, @@ -95,6 +150,7 @@ func NewStorageOptions() *StorageOptions { GrpcClientAuthenticationAllowInsecure: false, GrpcClientKeepaliveTime: 0, BlobThresholdBytes: BlobThresholdDefault, + UnifiedStorageConfig: make(map[string]setting.UnifiedStorageConfig), } } @@ -109,6 +165,11 @@ func (o *StorageOptions) AddFlags(fs *pflag.FlagSet) { fs.BoolVar(&o.GrpcClientAuthenticationAllowInsecure, "grpc-client-authentication-allow-insecure", o.GrpcClientAuthenticationAllowInsecure, "Allow insecure grpc client authentication") fs.DurationVar(&o.GrpcClientKeepaliveTime, "grpc-client-keepalive-time", o.GrpcClientKeepaliveTime, "gRPC client keep-alive ping interval (e.g., 6m).") + // Use custom flag value for unified storage config + fs.Var(&unifiedStorageConfigValue{config: &o.UnifiedStorageConfig}, + "grafana-apiserver-unified-storage-config", + "Unified storage configuration per resource.group in the format resource.group=mode,... where mode is 0-5") + // Secrets Manager Configuration flags fs.BoolVar(&o.SecretsManagerGrpcClientEnable, "grafana.secrets-manager.grpc-client-enable", false, "Enable gRPC client for secrets manager") fs.StringVar(&o.SecretsManagerGrpcServerAddress, "grafana.secrets-manager.grpc-server-address", "", "gRPC server address for secrets manager") From 3082a762d39a9992c2e987129f7a00dde3574e3e Mon Sep 17 00:00:00 2001 From: Dafydd <72009875+dafydd-t@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:53:54 +0000 Subject: [PATCH 200/209] Update grafana socks proxy information (#110832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacob Valdez Co-authored-by: Irene Rodríguez --- .../configure-grafana/proxy/index.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/proxy/index.md b/docs/sources/setup-grafana/configure-grafana/proxy/index.md index 7f17909ad5d..c448b252cce 100644 --- a/docs/sources/setup-grafana/configure-grafana/proxy/index.md +++ b/docs/sources/setup-grafana/configure-grafana/proxy/index.md @@ -15,35 +15,35 @@ weight: 1110 # Configure a data source SOCKS5 connection proxy -Grafana provides support for proxying data source connections through a Secure Socks5 Tunnel. This enables you to securely connect to data sources hosted in a different network than Grafana. +Grafana provides support for proxying data source connections through a secure SOCKS5 proxy. This enables you to securely connect to data sources hosted in a different network than Grafana. -To make use of this functionality, you need to deploy a socks5 proxy server that supports TLS on a machine exposed to the public internet within the same network as your data source. From there, Grafana establishes a mutually trusted connection from Grafana to the Proxy. Then the Proxy can proxy the Grafana connection to your private server without exposing your data sources to the public internet. +To make use of this functionality, you need to deploy a SOCKS5 proxy server that supports TLS on a machine accessible from where your Grafana instance is running, and within the same network as your data source. From there, Grafana establishes a mutually trusted connection from Grafana to the proxy. Then the SOCKS5 proxy can proxy the Grafana data source connection to your private data source instance without exposing your data source to the public internet. ## Known limitations -- You can configure only one socks5 proxy per Grafana instance +- You can configure only one SOCKS5 proxy per Grafana instance. This is because the SOCKS5 proxy address and TLS configuration is set at the Grafana instance level. - All built-in core data sources are compatible, but not all external data sources are. For a list of supported data sources, refer to [private data source connect](/docs/grafana-cloud/data-configuration/configure-private-datasource-connect/#known-limitations). ## Before you begin -To complete this task, you must first deploy a socks proxy server that supports TLS, is publicly accessible, and is hosted within the same network as the data source. +To complete this task, you must first have a SOCKS5 proxy server running, using host certificates signed by a Certificate Authority (CA) that you can get the public certificate for. You must also have a TLS client key pair for Grafana, with the client certificate signed by the same CA used to sign the proxy server certificate. ## Steps 1. For Grafana to send data source connections to the socks5 server, use the following table to configure the `secure_socks_datasource_proxy` section of the `config.ini`: - | Key | Description | Example | - | ---------------- | ------------------------------------------ | ------------------------------- | - | `enabled` | Enable this feature in Grafana | true | - | `root_ca_cert` | The file path of the root ca cert | /etc/ca.crt | - | `client_key` | The file path of the client private key | /etc/client.key | - | `client_cert` | The file path of the client public key | /etc/client.crt | - | `server_name` | The domain name of the proxy, used for SNI | proxy.grafana.svc.cluster.local | - | `proxy_address` | The address of the proxy | localhost:9090 | - | `allow_insecure` | Disable TLS in the socks proxy | false | + | Key | Description | Example | + | ---------------- | --------------------------------------------------------------------------- | ------------------------------- | + | `enabled` | Enable this feature in Grafana | true | + | `root_ca_cert` | The file path of the root ca cert used to sign the proxy server certificate | /etc/ca.crt | + | `client_key` | The file path of the client private key | /etc/client.key | + | `client_cert` | The file path of the client public key | /etc/client.crt | + | `server_name` | The domain name of the proxy, used for SNI | proxy.grafana.svc.cluster.local | + | `proxy_address` | The address of the proxy | localhost:9090 | + | `allow_insecure` | Disable TLS in the socks proxy | false | 1. Set up a data source and configure it to send data source connections through the proxy. To configure your data sources to send connections through the proxy, `enableSecureSocksProxy=true` must be specified in the data source json. You can do this in the [API](../../../developers/http_api/data_source/) or use [file based provisioning](../../../administration/provisioning/#data-sources). - Additionally, you can set the socks5 username and password by adding `secureSocksProxyUsername` in the data source json and `secureSocksProxyPassword` in the secure data source json. + Additionally, if using SOCKS5 authentication, you can set the SOCKS5 username and password by adding `secureSocksProxyUsername` in the data source's `jsonData` field and `secureSocksProxyPassword` in the data source's `secureJsonData` field. From 73657be5e7765a459550d2d8fb56eac9d8b07cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 13 Nov 2025 10:54:07 +0100 Subject: [PATCH 201/209] Provisioning: Fix history write for expired jobs (#113764) * refactor: Move job cleanup to separate controller and fix history write - Created JobCleanupController in apps/provisioning/pkg/controller - Separated cleanup logic from ConcurrentJobDriver - Fixed bug where expired jobs were not written to history - Added comprehensive tests with 93.8% coverage - Removed cleanup interval parameter from ConcurrentJobDriver - Cleanup now properly follows Complete + WriteJob pattern Fixes expired jobs being lost instead of archived * refactor: Update lease renewal interval to use jobExpiry variable - Changed the lease renewal interval in the GetPostStartHooks method to utilize the jobExpiry variable for improved clarity and maintainability. * Format code * Fix Unix milliseconds * fix: correct Unix timestamp assertions and remove duplicate test expectations - Changed Unix() to UnixMilli() for correct millisecond timestamp validation - Removed duplicate store.AssertExpectations(t) calls throughout tests --- .../provisioning/jobs/concurrent_driver.go | 49 +- pkg/registry/apis/provisioning/jobs/driver.go | 12 +- .../provisioning/jobs/expired_job_cleanup.go | 177 +++++++ .../jobs/expired_job_cleanup_test.go | 465 ++++++++++++++++++ .../provisioning/jobs/history_reader_mock.go | 158 ++++++ .../provisioning/jobs/history_writer_mock.go | 84 ++++ .../provisioning/jobs/loki_client_mock.go | 3 +- .../apis/provisioning/jobs/persistentstore.go | 214 ++------ .../apis/provisioning/jobs/store_mock.go | 141 +++--- pkg/registry/apis/provisioning/register.go | 20 +- 10 files changed, 1045 insertions(+), 278 deletions(-) create mode 100644 pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go create mode 100644 pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go create mode 100644 pkg/registry/apis/provisioning/jobs/history_reader_mock.go create mode 100644 pkg/registry/apis/provisioning/jobs/history_writer_mock.go diff --git a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go index dce8d65a97e..39f705aca31 100644 --- a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go +++ b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go @@ -14,7 +14,6 @@ import ( type ConcurrentJobDriver struct { numDrivers int jobTimeout time.Duration - cleanupInterval time.Duration jobInterval time.Duration leaseRenewalInterval time.Duration store Store @@ -27,7 +26,7 @@ type ConcurrentJobDriver struct { // NewConcurrentJobDriver creates a new concurrent job driver that spawns multiple job drivers. func NewConcurrentJobDriver( numDrivers int, - jobTimeout, cleanupInterval, jobInterval, leaseRenewalInterval time.Duration, + jobTimeout, jobInterval, leaseRenewalInterval time.Duration, store Store, repoGetter RepoGetter, historicJobs HistoryWriter, @@ -45,24 +44,12 @@ func NewConcurrentJobDriver( if leaseRenewalInterval < 5*time.Second { leaseRenewalInterval = 5 * time.Second } - // For lease-based cleanup, run at most every 3-4 lease renewal intervals - // to detect expired leases promptly but not too aggressively - if cleanupInterval <= 0 { - cleanupInterval = leaseRenewalInterval * 3 - } - if cleanupInterval < 30*time.Second { - cleanupInterval = 30 * time.Second // Minimum cleanup interval - } - if cleanupInterval > 5*time.Minute { - cleanupInterval = 5 * time.Minute // Maximum cleanup interval - } recordConcurrentDriverMetric(registry, numDrivers) return &ConcurrentJobDriver{ numDrivers: numDrivers, jobTimeout: jobTimeout, - cleanupInterval: cleanupInterval, jobInterval: jobInterval, leaseRenewalInterval: leaseRenewalInterval, store: store, @@ -73,43 +60,17 @@ func NewConcurrentJobDriver( }, nil } -// Run starts multiple job drivers concurrently and handles cleanup coordination. +// Run starts multiple job drivers concurrently. // This is a blocking function that will run until the context is canceled or an error occurs. // // Note: This function intentionally does NOT create a tracing span because it runs indefinitely -// until shutdown. Individual job processing and cleanup operations already have their own spans. +// until shutdown. Individual job processing operations already have their own spans. func (c *ConcurrentJobDriver) Run(ctx context.Context) error { logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers) - logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval) - - // Set up cleanup ticker - runs more frequently with lease-based approach - cleanupTicker := time.NewTicker(c.cleanupInterval) - defer cleanupTicker.Stop() - - // Initial cleanup - if err := c.store.Cleanup(ctx); err != nil { - logger.Error("failed initial cleanup", "error", err) - } + logger.Info("start concurrent job driver", "num_drivers", c.numDrivers) var wg sync.WaitGroup - errChan := make(chan error, c.numDrivers+1) // +1 for cleanup goroutine - - // Start cleanup goroutine - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-cleanupTicker.C: - if err := c.store.Cleanup(ctx); err != nil { - logger.Error("failed cleanup", "error", err) - } - case <-ctx.Done(): - logger.Debug("cleanup routine stopped") - return - } - } - }() + errChan := make(chan error, c.numDrivers) // Start driver goroutines for i := 0; i < c.numDrivers; i++ { diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index b1cb0ee6da1..5241c211517 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -31,14 +31,10 @@ type Store interface { // The err may be ErrNoJobs if there are no jobs to claim. Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) - // Complete marks a job as completed and moves it to the historic job store. - // When in the historic store, there is no more claim on the job. + // Complete marks a job as completed and removes it from the active job store. + // Callers are responsible for writing the job to history after calling this. Complete(ctx context.Context, job *provisioning.Job) error - // Cleanup should be called periodically to clean up abandoned jobs. - // An abandoned job is one that has been claimed by a worker, but the worker has not updated the job in a while. - Cleanup(ctx context.Context) error - // Update saves the job back to the store. Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) @@ -48,6 +44,10 @@ type Store interface { // Get retrieves a job by name for conflict resolution. Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) + + // ListExpiredJobs lists jobs with expired leases (claim timestamp older than the given time). + // Returns jobs in batches up to the specified limit. + ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*provisioning.Job, error) } // jobDriver drives jobs to completion and manages the job queue. diff --git a/pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go new file mode 100644 index 00000000000..d6cc1dc2e25 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go @@ -0,0 +1,177 @@ +package jobs + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/apps/provisioning/pkg/apifmt" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/tracing" +) + +// JobCleanupController handles cleanup of expired/abandoned jobs. +type JobCleanupController struct { + store Store + historicJobs HistoryWriter + clock func() time.Time + expiry time.Duration + cleanupInterval time.Duration +} + +// NewJobCleanupController creates a new job cleanup controller. +func NewJobCleanupController( + store Store, + historicJobs HistoryWriter, + expiry time.Duration, +) *JobCleanupController { + // Calculate cleanup interval based on expiry duration + // Run cleanup every 3-4 expiry intervals to detect expired leases promptly but not too aggressively + cleanupInterval := expiry * 3 + + // Enforce minimum and maximum bounds + if cleanupInterval < 30*time.Second { + cleanupInterval = 30 * time.Second + } + if cleanupInterval > 5*time.Minute { + cleanupInterval = 5 * time.Minute + } + + return &JobCleanupController{ + store: store, + historicJobs: historicJobs, + clock: time.Now, + expiry: expiry, + cleanupInterval: cleanupInterval, + } +} + +// Run starts the cleanup loop that runs at an appropriate interval. +// This is a blocking function that runs until the context is canceled. +func (c *JobCleanupController) Run(ctx context.Context) error { + logger := logging.FromContext(ctx).With("logger", "job-cleanup-controller") + ctx = logging.Context(ctx, logger) + + // Set up provisioning identity to access jobs across all namespaces + ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") + if err != nil { + return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err) + } + + logger.Info("starting job cleanup controller", "cleanup_interval", c.cleanupInterval, "expiry", c.expiry) + + // Initial cleanup + if err := c.Cleanup(ctx); err != nil { + logger.Error("failed to clean up jobs at start", "error", err) + } + + ticker := time.NewTicker(c.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := c.Cleanup(ctx); err != nil { + logger.Error("failed to cleanup jobs", "error", err) + } + case <-ctx.Done(): + logger.Info("job cleanup controller stopping") + return ctx.Err() + } + } +} + +// Cleanup finds jobs with expired leases and marks them as failed. +// This should be called periodically to clean up jobs from crashed workers. +func (c *JobCleanupController) Cleanup(ctx context.Context) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup") + defer span.End() + + startTime := c.clock() + logger := logging.FromContext(ctx) + + // Find jobs with expired leases + expiredBefore := c.clock().Add(-c.expiry) + + // Process in batches of 100 to avoid overwhelming the system + const batchSize = 100 + jobs, err := c.store.ListExpiredJobs(ctx, expiredBefore, batchSize) + if err != nil { + span.RecordError(err) + return apifmt.Errorf("failed to list jobs with expired leases: %w", err) + } + + // If no jobs found, cleanup is complete + if len(jobs) == 0 { + duration := c.clock().Sub(startTime) + span.SetAttributes( + attribute.Int("count", 0), + attribute.Int64("duration_ms", duration.Milliseconds()), + ) + return nil + } + + logger.Info("cleaning up expired jobs", "count", len(jobs)) + + for _, job := range jobs { + if err := c.cleanUpExpiredJob(ctx, job); err != nil { + // Log error but continue processing other jobs + logger.Error("failed to clean up expired job", "error", err, "job", job.GetName(), "namespace", job.GetNamespace()) + } + } + + duration := c.clock().Sub(startTime) + logger.Info("cleanup complete", "duration", duration, "count", len(jobs)) + + span.SetAttributes( + attribute.Int("count", len(jobs)), + attribute.Int64("duration_ms", duration.Milliseconds()), + ) + + return nil +} + +// cleanUpExpiredJob marks a single expired job as failed and archives it. +func (c *JobCleanupController) cleanUpExpiredJob(ctx context.Context, job *provisioning.Job) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job") + defer span.End() + + // Mark job as failed due to lease expiry + jobCopy := job.DeepCopy() + jobCopy.Status.State = provisioning.JobStateError + jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" + jobCopy.Status.Finished = c.clock().UnixMilli() + + span.SetAttributes( + attribute.String("job.name", jobCopy.GetName()), + attribute.String("job.namespace", jobCopy.GetNamespace()), + attribute.String("job.repository", jobCopy.Spec.Repository), + attribute.String("job.action", string(jobCopy.Spec.Action)), + ) + + jobLogger := logging.FromContext(ctx).With("namespace", jobCopy.GetNamespace(), "job", jobCopy.GetName(), "action", jobCopy.Spec.Action) + + // Delete from active job store first + if err := c.store.Complete(ctx, jobCopy); err != nil { + span.RecordError(err) + return apifmt.Errorf("failed to complete expired job: %w", err) + } + + // Remove the claim label before archiving + if jobCopy.Labels != nil { + delete(jobCopy.Labels, LabelJobClaim) + } + + // Write to history after deleting from active store (matching driver.go pattern) + if err := c.historicJobs.WriteJob(ctx, jobCopy); err != nil { + span.RecordError(err) + jobLogger.Warn("failed to write expired job to history", "error", err) + // Job was already deleted, so we can't recover from this + } + + jobLogger.Debug("cleaned up expired job") + return nil +} diff --git a/pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go new file mode 100644 index 00000000000..6018e5d91e0 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go @@ -0,0 +1,465 @@ +package jobs + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewJobCleanupController(t *testing.T) { + store := &MockStore{} + historyWriter := &MockHistoryWriter{} + + t.Run("creates controller with default cleanup interval", func(t *testing.T) { + expiry := 30 * time.Second + controller := NewJobCleanupController(store, historyWriter, expiry) + + assert.NotNil(t, controller) + assert.Equal(t, expiry, controller.expiry) + // Cleanup interval should be 3x expiry = 90 seconds + assert.Equal(t, 90*time.Second, controller.cleanupInterval) + }) + + t.Run("enforces minimum cleanup interval", func(t *testing.T) { + // With expiry of 5 seconds, 3x = 15 seconds, but minimum is 30 seconds + expiry := 5 * time.Second + controller := NewJobCleanupController(store, historyWriter, expiry) + + assert.Equal(t, 30*time.Second, controller.cleanupInterval) + }) + + t.Run("enforces maximum cleanup interval", func(t *testing.T) { + // With expiry of 5 minutes, 3x = 15 minutes, but maximum is 5 minutes + expiry := 5 * time.Minute + controller := NewJobCleanupController(store, historyWriter, expiry) + + assert.Equal(t, 5*time.Minute, controller.cleanupInterval) + }) +} + +func TestJobCleanupController_Cleanup(t *testing.T) { + t.Run("no expired jobs returns nil", func(t *testing.T) { + store := &MockStore{} + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{}, nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + // store.AssertNotCalled(t, "Complete") - not needed with combined Store mock + historyWriter.AssertNotCalled(t, "WriteJob") + }) + + t.Run("error listing expired jobs returns error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + expectedErr := errors.New("list failed") + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return(nil, expectedErr) + + err := controller.Cleanup(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list jobs with expired leases") + store.AssertExpectations(t) + }) + + t.Run("successfully cleans up expired job", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{ + LabelJobClaim: "123456789", + }, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Status.State == provisioning.JobStateError && + j.Status.Message == "Job failed due to lease expiry - worker may have crashed or lost connection" + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + // Verify claim label was removed before writing to history + _, hasLabel := j.Labels[LabelJobClaim] + return !hasLabel && j.Status.State == provisioning.JobStateError + })).Return(nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + }) + + t.Run("continues on complete error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job1 := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-1", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "123"}, + }, + Spec: provisioning.JobSpec{ + Repository: "repo-1", + Action: provisioning.JobActionPull, + }, + } + job2 := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-2", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "456"}, + }, + Spec: provisioning.JobSpec{ + Repository: "repo-2", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job1, job2}, nil) + + // First job fails to complete + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == "job-1" + })).Return(errors.New("complete failed")) + + // Second job succeeds + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == "job-2" + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == "job-2" + })).Return(nil) + + err := controller.Cleanup(ctx) + + // Should not return error, continues processing + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + }) + + t.Run("continues on history write error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "123"}, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.Anything).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.Anything).Return(errors.New("write failed")) + + err := controller.Cleanup(ctx) + + // Should not return error, just log warning + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + }) + + t.Run("sets job status correctly", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + controller.clock = func() time.Time { return fixedTime } + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "123"}, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + assert.Equal(t, provisioning.JobStateError, j.Status.State) + assert.Equal(t, "Job failed due to lease expiry - worker may have crashed or lost connection", j.Status.Message) + assert.Equal(t, fixedTime.UnixMilli(), j.Status.Finished) + return true + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.Anything).Return(nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + }) + + t.Run("removes claim label before writing to history", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{ + LabelJobClaim: "123456789", + "other-label": "value", + }, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.Anything).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + _, hasClaim := j.Labels[LabelJobClaim] + _, hasOther := j.Labels["other-label"] + assert.False(t, hasClaim, "claim label should be removed") + assert.True(t, hasOther, "other labels should be preserved") + return !hasClaim && hasOther + })).Return(nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + historyWriter.AssertExpectations(t) + }) + + t.Run("processes multiple expired jobs", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + jobs := []*provisioning.Job{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job-1", + Namespace: "ns-1", + Labels: map[string]string{LabelJobClaim: "111"}, + }, + Spec: provisioning.JobSpec{Repository: "repo-1", Action: provisioning.JobActionPull}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job-2", + Namespace: "ns-2", + Labels: map[string]string{LabelJobClaim: "222"}, + }, + Spec: provisioning.JobSpec{Repository: "repo-2", Action: provisioning.JobActionPush}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job-3", + Namespace: "ns-3", + Labels: map[string]string{LabelJobClaim: "333"}, + }, + Spec: provisioning.JobSpec{Repository: "repo-3", Action: provisioning.JobActionMigrate}, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return(jobs, nil) + for _, job := range jobs { + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == job.Name + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == job.Name + })).Return(nil) + } + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + // Verify all 3 jobs were processed + store.AssertNumberOfCalls(t, "Complete", 3) + historyWriter.AssertNumberOfCalls(t, "WriteJob", 3) + }) +} + +func TestJobCleanupController_Run(t *testing.T) { + t.Run("runs cleanup on start and periodically", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + // Use short expiry to get short cleanup interval for testing + controller := NewJobCleanupController(store, historyWriter, 10*time.Second) + // Override to even shorter for test + controller.cleanupInterval = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + // Expect initial cleanup + periodic cleanups (at least 2, maybe more depending on timing) + callCount := 0 + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return([]*provisioning.Job{}, nil). + Run(func(args mock.Arguments) { + callCount++ + }). + Maybe() // Allow variable number of calls due to timing + + err := controller.Run(ctx) + + // Should return context.DeadlineExceeded when context times out + require.Error(t, err) + assert.Equal(t, context.DeadlineExceeded, err) + + // Verify cleanup was called at least 3 times (initial + 2 periodic) + assert.GreaterOrEqual(t, callCount, 3, "should have run cleanup at least 3 times") + }) + + t.Run("stops when context is cancelled", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + controller.cleanupInterval = 1 * time.Second + + ctx, cancel := context.WithCancel(context.Background()) + + // Expect initial cleanup + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{}, nil).Once() + + // Cancel after initial cleanup + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + err := controller.Run(ctx) + + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) + store.AssertExpectations(t) + }) + + t.Run("continues running after cleanup error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 10*time.Second) + controller.cleanupInterval = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + // Track successful calls after initial failure + successCount := 0 + // First cleanup fails + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return(nil, errors.New("first failure")).Once() + // Subsequent cleanups succeed + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Run(func(args mock.Arguments) { + successCount++ + }). + Return([]*provisioning.Job{}, nil). + Maybe() + + err := controller.Run(ctx) + + // Should still run and return context error + require.Error(t, err) + assert.Equal(t, context.DeadlineExceeded, err) + // Verify it was called successfully at least once after first failure + assert.GreaterOrEqual(t, successCount, 1, "should have retried after first failure") + }) + + t.Run("logs error when periodic cleanup fails", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 10*time.Second) + controller.cleanupInterval = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 125*time.Millisecond) + defer cancel() + + // Initial cleanup succeeds + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return([]*provisioning.Job{}, nil).Once() + + // First periodic cleanup fails (this tests the error logging in ticker case) + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return(nil, errors.New("periodic failure")).Once() + + // Subsequent cleanups succeed + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return([]*provisioning.Job{}, nil). + Maybe() + + err := controller.Run(ctx) + + // Should still run and return context error, not the cleanup error + require.Error(t, err) + assert.Equal(t, context.DeadlineExceeded, err) + store.AssertExpectations(t) + }) +} diff --git a/pkg/registry/apis/provisioning/jobs/history_reader_mock.go b/pkg/registry/apis/provisioning/jobs/history_reader_mock.go new file mode 100644 index 00000000000..284c446192e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/history_reader_mock.go @@ -0,0 +1,158 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package jobs + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockHistoryReader is an autogenerated mock type for the HistoryReader type +type MockHistoryReader struct { + mock.Mock +} + +type MockHistoryReader_Expecter struct { + mock *mock.Mock +} + +func (_m *MockHistoryReader) EXPECT() *MockHistoryReader_Expecter { + return &MockHistoryReader_Expecter{mock: &_m.Mock} +} + +// GetJob provides a mock function with given fields: ctx, namespace, repo, uid +func (_m *MockHistoryReader) GetJob(ctx context.Context, namespace string, repo string, uid string) (*v0alpha1.Job, error) { + ret := _m.Called(ctx, namespace, repo, uid) + + if len(ret) == 0 { + panic("no return value specified for GetJob") + } + + var r0 *v0alpha1.Job + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*v0alpha1.Job, error)); ok { + return rf(ctx, namespace, repo, uid) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *v0alpha1.Job); ok { + r0 = rf(ctx, namespace, repo, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.Job) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = rf(ctx, namespace, repo, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockHistoryReader_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob' +type MockHistoryReader_GetJob_Call struct { + *mock.Call +} + +// GetJob is a helper method to define mock.On call +// - ctx context.Context +// - namespace string +// - repo string +// - uid string +func (_e *MockHistoryReader_Expecter) GetJob(ctx interface{}, namespace interface{}, repo interface{}, uid interface{}) *MockHistoryReader_GetJob_Call { + return &MockHistoryReader_GetJob_Call{Call: _e.mock.On("GetJob", ctx, namespace, repo, uid)} +} + +func (_c *MockHistoryReader_GetJob_Call) Run(run func(ctx context.Context, namespace string, repo string, uid string)) *MockHistoryReader_GetJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockHistoryReader_GetJob_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockHistoryReader_GetJob_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockHistoryReader_GetJob_Call) RunAndReturn(run func(context.Context, string, string, string) (*v0alpha1.Job, error)) *MockHistoryReader_GetJob_Call { + _c.Call.Return(run) + return _c +} + +// RecentJobs provides a mock function with given fields: ctx, namespace, repo +func (_m *MockHistoryReader) RecentJobs(ctx context.Context, namespace string, repo string) (*v0alpha1.JobList, error) { + ret := _m.Called(ctx, namespace, repo) + + if len(ret) == 0 { + panic("no return value specified for RecentJobs") + } + + var r0 *v0alpha1.JobList + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*v0alpha1.JobList, error)); ok { + return rf(ctx, namespace, repo) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *v0alpha1.JobList); ok { + r0 = rf(ctx, namespace, repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.JobList) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, namespace, repo) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockHistoryReader_RecentJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecentJobs' +type MockHistoryReader_RecentJobs_Call struct { + *mock.Call +} + +// RecentJobs is a helper method to define mock.On call +// - ctx context.Context +// - namespace string +// - repo string +func (_e *MockHistoryReader_Expecter) RecentJobs(ctx interface{}, namespace interface{}, repo interface{}) *MockHistoryReader_RecentJobs_Call { + return &MockHistoryReader_RecentJobs_Call{Call: _e.mock.On("RecentJobs", ctx, namespace, repo)} +} + +func (_c *MockHistoryReader_RecentJobs_Call) Run(run func(ctx context.Context, namespace string, repo string)) *MockHistoryReader_RecentJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockHistoryReader_RecentJobs_Call) Return(_a0 *v0alpha1.JobList, _a1 error) *MockHistoryReader_RecentJobs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockHistoryReader_RecentJobs_Call) RunAndReturn(run func(context.Context, string, string) (*v0alpha1.JobList, error)) *MockHistoryReader_RecentJobs_Call { + _c.Call.Return(run) + return _c +} + +// NewMockHistoryReader creates a new instance of MockHistoryReader. 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 NewMockHistoryReader(t interface { + mock.TestingT + Cleanup(func()) +}) *MockHistoryReader { + mock := &MockHistoryReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/history_writer_mock.go b/pkg/registry/apis/provisioning/jobs/history_writer_mock.go new file mode 100644 index 00000000000..8ec0e63018e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/history_writer_mock.go @@ -0,0 +1,84 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package jobs + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockHistoryWriter is an autogenerated mock type for the HistoryWriter type +type MockHistoryWriter struct { + mock.Mock +} + +type MockHistoryWriter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockHistoryWriter) EXPECT() *MockHistoryWriter_Expecter { + return &MockHistoryWriter_Expecter{mock: &_m.Mock} +} + +// WriteJob provides a mock function with given fields: ctx, job +func (_m *MockHistoryWriter) WriteJob(ctx context.Context, job *v0alpha1.Job) error { + ret := _m.Called(ctx, job) + + if len(ret) == 0 { + panic("no return value specified for WriteJob") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Job) error); ok { + r0 = rf(ctx, job) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockHistoryWriter_WriteJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteJob' +type MockHistoryWriter_WriteJob_Call struct { + *mock.Call +} + +// WriteJob is a helper method to define mock.On call +// - ctx context.Context +// - job *v0alpha1.Job +func (_e *MockHistoryWriter_Expecter) WriteJob(ctx interface{}, job interface{}) *MockHistoryWriter_WriteJob_Call { + return &MockHistoryWriter_WriteJob_Call{Call: _e.mock.On("WriteJob", ctx, job)} +} + +func (_c *MockHistoryWriter_WriteJob_Call) Run(run func(ctx context.Context, job *v0alpha1.Job)) *MockHistoryWriter_WriteJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Job)) + }) + return _c +} + +func (_c *MockHistoryWriter_WriteJob_Call) Return(_a0 error) *MockHistoryWriter_WriteJob_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockHistoryWriter_WriteJob_Call) RunAndReturn(run func(context.Context, *v0alpha1.Job) error) *MockHistoryWriter_WriteJob_Call { + _c.Call.Return(run) + return _c +} + +// NewMockHistoryWriter creates a new instance of MockHistoryWriter. 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 NewMockHistoryWriter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockHistoryWriter { + mock := &MockHistoryWriter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/loki_client_mock.go b/pkg/registry/apis/provisioning/jobs/loki_client_mock.go index 9043c329875..557350d2661 100644 --- a/pkg/registry/apis/provisioning/jobs/loki_client_mock.go +++ b/pkg/registry/apis/provisioning/jobs/loki_client_mock.go @@ -134,8 +134,7 @@ func (_c *MockLokiClient_RangeQuery_Call) RunAndReturn(run func(context.Context, func NewMockLokiClient(t interface { mock.TestingT Cleanup(func()) -}, -) *MockLokiClient { +}) *MockLokiClient { mock := &MockLokiClient{} mock.Mock.Test(t) diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go index b7c7e47bd35..8b164320bd3 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go @@ -306,11 +306,11 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } - // We need to delete the job from the job store and create it in the historic job store. - // We are fine with the job being lost if the historic job store fails to create it. + // Delete the job from the active job store. + // Callers are responsible for writing the job to history after calling this. // // We will assume that the caller is the claimant. If this is not true, an error is returned. - // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store. + // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to delete it. err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{}) if err != nil { span.RecordError(err) @@ -329,6 +329,56 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e return nil } +// ListExpiredJobs lists jobs with expired leases (claim timestamp older than the given time). +// Returns jobs in batches up to the specified limit. +func (s *persistentStore) ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*provisioning.Job, error) { + ctx, span := tracing.Start(ctx, "provisioning.jobs.list_expired_jobs") + defer span.End() + + logger := logging.FromContext(ctx).With("operation", "list_expired_jobs") + + // Set up provisioning identity to access jobs across all namespaces + ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") + if err != nil { + span.RecordError(err) + return nil, apifmt.Errorf("failed to grant provisioning identity for listing expired jobs: %w", err) + } + + // Find jobs with expired leases (older than expiredBefore) + expiry := expiredBefore.UnixMilli() + logger.Debug("searching for expired jobs", "expiry_threshold", expiredBefore.Format(time.RFC3339)) + + requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)}) + if err != nil { + span.RecordError(err) + return nil, apifmt.Errorf("could not create requirement: %w", err) + } + + span.SetAttributes( + attribute.String("expiry_threshold", expiredBefore.Format(time.RFC3339)), + attribute.Int("limit", limit), + ) + + jobList, err := s.client.Jobs("").List(ctx, metav1.ListOptions{ + LabelSelector: labels.NewSelector().Add(*requirement).String(), + Limit: int64(limit), + }) + if err != nil { + span.RecordError(err) + return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err) + } + + result := make([]*provisioning.Job, len(jobList.Items)) + for i := range jobList.Items { + result[i] = &jobList.Items[i] + } + + span.SetAttributes(attribute.Int("jobs_found", len(result))) + logger.Debug("found expired jobs", "count", len(result)) + + return result, nil +} + // RenewLease renews the lease for a claimed job, extending its expiry time. // Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired). func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error { @@ -405,164 +455,6 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) return nil } -// Cleanup finds jobs with expired leases and marks them as failed. -// This replaces the old cleanup mechanism and should be called more frequently. -func (s *persistentStore) Cleanup(ctx context.Context) error { - ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup") - defer span.End() - - startTime := s.clock() - logger := logging.FromContext(ctx).With("operation", "cleanup") - - // List expired jobs - jobs, err := s.listExpiredJobs(ctx) - if err != nil { - span.RecordError(err) - return err - } - - // If no jobs found, cleanup is complete - if len(jobs) == 0 { - duration := s.clock().Sub(startTime) - logger.Info("cleanup complete - no expired jobs found", "duration", duration) - span.SetAttributes( - attribute.Int("count", 0), - attribute.Int64("duration_ms", duration.Milliseconds()), - ) - return nil - } - - logger.Info("found expired jobs", "count", len(jobs)) - - // Clean up each expired job - for _, job := range jobs { - if err := s.cleanUpExpiredJob(ctx, job); err != nil { - span.RecordError(err) - return err - } - } - - duration := s.clock().Sub(startTime) - logger.Info("cleanup complete", - "duration", duration, - "count", len(jobs), - ) - - span.SetAttributes( - attribute.Int("count", len(jobs)), - attribute.Int64("duration_ms", duration.Milliseconds()), - ) - - return nil -} - -// listExpiredJobs returns jobs with expired leases. -func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) { - logger := logging.FromContext(ctx) - - // Set up provisioning identity to access jobs across all namespaces - ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces - if err != nil { - return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err) - } - - // Find jobs with expired leases (older than expiry time) - expiry := s.clock().Add(-s.expiry).UnixMilli() - expiryTime := time.UnixMilli(expiry) - logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339)) - - requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)}) - if err != nil { - return nil, apifmt.Errorf("could not create requirement: %w", err) - } - - listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs") - defer listSpan.End() - - listSpan.SetAttributes( - attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)), - attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())), - ) - - timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second) - defer cancel() - - jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{ - LabelSelector: labels.NewSelector().Add(*requirement).String(), - Limit: 100, // Process in batches - }) - if err != nil { - listSpan.RecordError(err) - return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err) - } - - listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items))) - return jobList.Items, nil -} - -// cleanUpExpiredJob marks a single expired job as failed and archives it. -func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error { - // Calculate how long the job has been expired - var expiredFor time.Duration - var claimTimestamp time.Time - if claimTime, exists := job.Labels[LabelJobClaim]; exists { - claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64) - if parseErr == nil { - claimTimestamp = time.UnixMilli(claimMillis) - expiredFor = s.clock().Sub(claimTimestamp) - } - } - - logger := logging.FromContext(ctx).With( - "job", job.GetName(), - "namespace", job.GetNamespace(), - "repository", job.Spec.Repository, - "action", job.Spec.Action, - "expired_for", expiredFor, - ) - - if !claimTimestamp.IsZero() { - logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339)) - } - - jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job") - defer jobSpan.End() - - jobSpan.SetAttributes( - attribute.String("job.name", job.GetName()), - attribute.String("job.namespace", job.GetNamespace()), - attribute.String("job.repository", job.Spec.Repository), - attribute.String("job.action", string(job.Spec.Action)), - attribute.String("job.expired_for", expiredFor.String()), - ) - - // Mark job as failed due to lease expiry and archive it - jobCopy := job.DeepCopy() - jobCopy.Status.State = provisioning.JobStateError - jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" - - // Set namespace context for the completion - jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace()) - if err != nil { - jobSpan.RecordError(err) - return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) - } - - // Use Complete to properly archive the failed job - if err := s.Complete(jobCtx, jobCopy); err != nil { - if apierrors.IsNotFound(err) { - // Job was already completed/deleted by another process - this is expected - logger.Warn("job already completed or deleted by another process") - return nil - } - jobSpan.RecordError(err) - return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) - } - - logger.Info("clean up expired job complete") - return nil -} - func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) { ctx, span := tracing.Start(ctx, "provisioning.jobs.insert") defer span.End() diff --git a/pkg/registry/apis/provisioning/jobs/store_mock.go b/pkg/registry/apis/provisioning/jobs/store_mock.go index aebcac9bbbe..99a1945c6a8 100644 --- a/pkg/registry/apis/provisioning/jobs/store_mock.go +++ b/pkg/registry/apis/provisioning/jobs/store_mock.go @@ -1,12 +1,14 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package jobs import ( context "context" + time "time" + + mock "github.com/stretchr/testify/mock" v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - mock "github.com/stretchr/testify/mock" ) // MockStore is an autogenerated mock type for the Store type @@ -89,52 +91,6 @@ func (_c *MockStore_Claim_Call) RunAndReturn(run func(context.Context) (*v0alpha return _c } -// Cleanup provides a mock function with given fields: ctx -func (_m *MockStore) Cleanup(ctx context.Context) error { - ret := _m.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for Cleanup") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(ctx) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockStore_Cleanup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cleanup' -type MockStore_Cleanup_Call struct { - *mock.Call -} - -// Cleanup is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockStore_Expecter) Cleanup(ctx interface{}) *MockStore_Cleanup_Call { - return &MockStore_Cleanup_Call{Call: _e.mock.On("Cleanup", ctx)} -} - -func (_c *MockStore_Cleanup_Call) Run(run func(ctx context.Context)) *MockStore_Cleanup_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *MockStore_Cleanup_Call) Return(_a0 error) *MockStore_Cleanup_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockStore_Cleanup_Call) RunAndReturn(run func(context.Context) error) *MockStore_Cleanup_Call { - _c.Call.Return(run) - return _c -} - // Complete provides a mock function with given fields: ctx, job func (_m *MockStore) Complete(ctx context.Context, job *v0alpha1.Job) error { ret := _m.Called(ctx, job) @@ -182,9 +138,9 @@ func (_c *MockStore_Complete_Call) RunAndReturn(run func(context.Context, *v0alp return _c } -// Get provides a mock function with given fields: ctx, name -func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error) { - ret := _m.Called(ctx, name) +// Get provides a mock function with given fields: ctx, namespace, name +func (_m *MockStore) Get(ctx context.Context, namespace string, name string) (*v0alpha1.Job, error) { + ret := _m.Called(ctx, namespace, name) if len(ret) == 0 { panic("no return value specified for Get") @@ -192,19 +148,19 @@ func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error var r0 *v0alpha1.Job var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.Job, error)); ok { - return rf(ctx, name) + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*v0alpha1.Job, error)); ok { + return rf(ctx, namespace, name) } - if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.Job); ok { - r0 = rf(ctx, name) + if rf, ok := ret.Get(0).(func(context.Context, string, string) *v0alpha1.Job); ok { + r0 = rf(ctx, namespace, name) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*v0alpha1.Job) } } - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, name) + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, namespace, name) } else { r1 = ret.Error(1) } @@ -219,14 +175,15 @@ type MockStore_Get_Call struct { // Get is a helper method to define mock.On call // - ctx context.Context +// - namespace string // - name string -func (_e *MockStore_Expecter) Get(ctx interface{}, name interface{}) *MockStore_Get_Call { - return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, name)} +func (_e *MockStore_Expecter) Get(ctx interface{}, namespace interface{}, name interface{}) *MockStore_Get_Call { + return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, namespace, name)} } -func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, name string)) *MockStore_Get_Call { +func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockStore_Get_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) + run(args[0].(context.Context), args[1].(string), args[2].(string)) }) return _c } @@ -236,7 +193,67 @@ func (_c *MockStore_Get_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockStore_Ge return _c } -func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.Job, error)) *MockStore_Get_Call { +func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string, string) (*v0alpha1.Job, error)) *MockStore_Get_Call { + _c.Call.Return(run) + return _c +} + +// ListExpiredJobs provides a mock function with given fields: ctx, expiredBefore, limit +func (_m *MockStore) ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*v0alpha1.Job, error) { + ret := _m.Called(ctx, expiredBefore, limit) + + if len(ret) == 0 { + panic("no return value specified for ListExpiredJobs") + } + + var r0 []*v0alpha1.Job + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, time.Time, int) ([]*v0alpha1.Job, error)); ok { + return rf(ctx, expiredBefore, limit) + } + if rf, ok := ret.Get(0).(func(context.Context, time.Time, int) []*v0alpha1.Job); ok { + r0 = rf(ctx, expiredBefore, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v0alpha1.Job) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, time.Time, int) error); ok { + r1 = rf(ctx, expiredBefore, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockStore_ListExpiredJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListExpiredJobs' +type MockStore_ListExpiredJobs_Call struct { + *mock.Call +} + +// ListExpiredJobs is a helper method to define mock.On call +// - ctx context.Context +// - expiredBefore time.Time +// - limit int +func (_e *MockStore_Expecter) ListExpiredJobs(ctx interface{}, expiredBefore interface{}, limit interface{}) *MockStore_ListExpiredJobs_Call { + return &MockStore_ListExpiredJobs_Call{Call: _e.mock.On("ListExpiredJobs", ctx, expiredBefore, limit)} +} + +func (_c *MockStore_ListExpiredJobs_Call) Run(run func(ctx context.Context, expiredBefore time.Time, limit int)) *MockStore_ListExpiredJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(time.Time), args[2].(int)) + }) + return _c +} + +func (_c *MockStore_ListExpiredJobs_Call) Return(_a0 []*v0alpha1.Job, _a1 error) *MockStore_ListExpiredJobs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockStore_ListExpiredJobs_Call) RunAndReturn(run func(context.Context, time.Time, int) ([]*v0alpha1.Job, error)) *MockStore_ListExpiredJobs_Call { _c.Call.Return(run) return _c } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index e2030ef0818..c30b43eeb60 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -775,13 +775,21 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } repoGetter := resources.NewRepositoryGetter(b.repoFactory, b.client) + + // Create job cleanup controller + jobExpiry := 30 * time.Second + jobCleanupController := jobs.NewJobCleanupController( + b.jobs, + jobHistoryWriter, + jobExpiry, + ) + // This is basically our own JobQueue system driver, err := jobs.NewConcurrentJobDriver( 3, // 3 drivers for now 20*time.Minute, // Max time for each job - time.Minute, // Cleanup jobs 30*time.Second, // Periodically look for new jobs - 30*time.Second, // Lease renewal interval + jobExpiry, // Lease renewal interval b.jobs, repoGetter, jobHistoryWriter, jobController.InsertNotifications(), b.registry, @@ -797,6 +805,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } }() + go func() { + if err := jobCleanupController.Run(postStartHookCtx.Context); err != nil { + logging.FromContext(postStartHookCtx.Context).Error("job cleanup controller failed", "error", err) + } + }() + repoController, err := controller.NewRepositoryController( b.GetClient(), repoInformer, @@ -821,7 +835,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH if b.jobHistoryLoki == nil { // Create HistoryJobController for cleanup of old job history entries // Separate informer factory for HistoryJob cleanup with resync interval - historyJobExpiration := 30 * time.Second + historyJobExpiration := 10 * time.Minute historyJobInformerFactory := informers.NewSharedInformerFactory(c, historyJobExpiration) historyJobInformer := historyJobInformerFactory.Provisioning().V0alpha1().HistoricJobs() go historyJobInformer.Informer().Run(postStartHookCtx.Done()) From 3bd49f654668bb759f75e709c0a8e307a6f49d29 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Thu, 13 Nov 2025 09:57:27 +0000 Subject: [PATCH 202/209] fix(TopBar): consistent ToolbarButton styling for sidebar buttons (#113804) * Remove custom styles from ExtensionToolbarItemButton * Use active ToolbarButton variant for ExtensionToolbarItemButton * Use active ToolbarButton variant for HelpTopBarButton * Simplify ExtensionToolbarItemButton conditional logic * Replace nested ternary with iife --- .../ExtensionToolbarItemButton.tsx | 61 ++++--------------- .../AppChrome/TopBar/HelpTopBarButton.tsx | 16 +---- 2 files changed, 15 insertions(+), 62 deletions(-) diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx index df93d6848b6..3145909e7f8 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx @@ -1,9 +1,7 @@ -import { css, cx } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ToolbarButton, useStyles2 } from '@grafana/ui'; +import { ToolbarButton } from '@grafana/ui'; interface ToolbarItemButtonProps { isOpen: boolean; @@ -30,35 +28,24 @@ function ExtensionToolbarItemButtonComponent( { isOpen, title, onClick, pluginId }: ToolbarItemButtonProps, ref: React.ForwardedRef ) { - const styles = useStyles2(getStyles); const icon = getPluginIcon(pluginId); + const tooltip = (() => { + if (isOpen) { + return t('navigation.extension-sidebar.button-tooltip.close', 'Close {{title}}', { title }); + } + if (title) { + return t('navigation.extension-sidebar.button-tooltip.open', 'Open {{title}}', { title }); + } + return t('navigation.extension-sidebar.button-tooltip.open-all', 'Open AI assistants and sidebar apps'); + })(); - if (isOpen) { - // render button to close the sidebar - return ( - - ); - } - // if a title is provided, use it in the tooltip - let tooltip = t('navigation.extension-sidebar.button-tooltip.open-all', 'Open AI assistants and sidebar apps'); - if (title) { - tooltip = t('navigation.extension-sidebar.button-tooltip.open', 'Open {{title}}', { title }); - } return ( @@ -70,25 +57,3 @@ function ExtensionToolbarItemButtonComponent( export const ExtensionToolbarItemButton = React.forwardRef( ExtensionToolbarItemButtonComponent ); - -function getStyles(theme: GrafanaTheme2) { - return { - button: css({ - // this is needed because with certain breakpoints the button will get `width: auto` - // and the icon will stretch - aspectRatio: '1 / 1 !important', - width: '28px', - height: '28px', - padding: 0, - justifyContent: 'center', - borderRadius: theme.shape.radius.circle, - margin: theme.spacing(0, 0.25), - }), - buttonActive: css({ - borderRadius: theme.shape.radius.circle, - backgroundColor: theme.colors.primary.transparent, - border: `1px solid ${theme.colors.primary.borderTransparent}`, - color: theme.colors.text.primary, - }), - }; -} diff --git a/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx b/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx index 02215cda4fd..27198a86050 100644 --- a/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx +++ b/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx @@ -1,10 +1,8 @@ -import { css } from '@emotion/css'; import { memo } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; -import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { Dropdown, ToolbarButton } from '@grafana/ui'; import { OpenExtensionSidebarEvent } from 'app/types/events'; import { @@ -23,7 +21,6 @@ interface Props { export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }: Props) { const enrichedHelpNode = useHelpNode(); const { setDockedComponentId, dockedComponentId, availableComponents } = useExtensionSidebarContext(); - const styles = useStyles2(getStyles); if (!enrichedHelpNode) { return null; @@ -52,7 +49,7 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen } iconOnly icon="question-circle" aria-label={t('navigation.help.aria-label', 'Help')} - className={isOpen ? styles.helpButtonActive : undefined} + variant={isOpen ? 'active' : 'default'} tooltip={ isOpen ? t( @@ -77,12 +74,3 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen } /> ); }); - -const getStyles = (theme: GrafanaTheme2) => ({ - helpButtonActive: css({ - borderRadius: theme.shape.radius.circle, - backgroundColor: theme.colors.primary.transparent, - border: `1px solid ${theme.colors.primary.borderTransparent}`, - color: theme.colors.text.primary, - }), -}); From 97a6ab7b1c69aa4f973e41500dcdaa151ced3895 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 13 Nov 2025 11:06:02 +0100 Subject: [PATCH 203/209] `AuthZ`: Remove outdated comments (#113817) --- pkg/services/authz/rbac/service.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index e45346740cc..5ef2e0c5ad6 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -618,6 +618,7 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, } if t.SkipScope(req.Verb) { + // Resource doesn't require scope on this verb, so allow if the user has the action return scopeMap[""], nil } @@ -627,18 +628,6 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, req.ParentFolder = accesscontrol.GeneralFolderUID } - //if req.Verb == utils.VerbCreate { - // // Resource doesn't require scope on create, so allow if the user has the action - // if t.SkipScopeOnCreate() { - // return scopeMap[""], nil - // } - // // If creating a resource that goes in a folder, but no folder is specified, - // // assume parent folder is the general folder - // if t.HasFolderSupport() && req.ParentFolder == "" { - // req.ParentFolder = accesscontrol.GeneralFolderUID - // } - //} - // Wildcard grant, no further checks needed if scopeMap["*"] { return true, nil From 108693eb3169f3c23c7c0157515c3c46823c8111 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Thu, 13 Nov 2025 06:33:09 -0400 Subject: [PATCH 204/209] TimeSeries: X-axis (time range) click-and-drag panning in panel (#112982) * feat(time-range): click and drag interaction to pan x-axis over time * feat(timeseries): add x-axis interaction area uPlot plugin * test(timeseries): validate x-axis pan with Playwright browser test * refactor(uplot-config): simplify state management for x-axis panning * refactor(uplot-config): plot state union type * fix(time-range-pan): simplify calcs, mouse handler cleanup function --- .../panels-suite/timeseries.spec.ts | 119 +++++++++++++ .../uPlot/config/UPlotConfigBuilder.ts | 11 ++ .../XAxisInteractionAreaPlugin.test.tsx | 159 +++++++++++++++++ .../plugins/XAxisInteractionAreaPlugin.tsx | 164 ++++++++++++++++++ packages/grafana-ui/src/index.ts | 1 + .../app/core/components/TimeSeries/utils.ts | 4 + .../panel/timeseries/TimeSeriesPanel.tsx | 9 +- 7 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 e2e-playwright/panels-suite/timeseries.spec.ts create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx diff --git a/e2e-playwright/panels-suite/timeseries.spec.ts b/e2e-playwright/panels-suite/timeseries.spec.ts new file mode 100644 index 00000000000..fb2124f7dda --- /dev/null +++ b/e2e-playwright/panels-suite/timeseries.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = '1KxMUdE7k'; + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@timeseries'] }, () => { + test('cursor changes to grab hand over x-axis', async ({ gotoDashboardPage, page }) => { + await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const timeseriesPanel = page.locator('.uplot').first(); + await expect(timeseriesPanel, 'panel rendered').toBeVisible(); + + const xAxis = timeseriesPanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + }); + }); + + test('drag right pans backward in time, drag left pans forward', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and capture initial time range', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const timeseriesPanel = page.locator('.uplot').first(); + await expect(timeseriesPanel, 'panel rendered').toBeVisible(); + + const xAxis = timeseriesPanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + + return dashboardPage; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 2431d9ecff5..27322714477 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -29,6 +29,8 @@ const cursorDefaults: Cursor = { type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData; type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData; +type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number }; + export class UPlotConfigBuilder { readonly uid = Math.random().toString(36).slice(2); @@ -47,6 +49,7 @@ export class UPlotConfigBuilder { // to prevent more than one threshold per scale private thresholds: Record = {}; private padding?: Padding = undefined; + private state: PlotState = { isPanning: false }; private cachedConfig?: PlotConfig; @@ -59,6 +62,14 @@ export class UPlotConfigBuilder { // Exposed to let the container know the primary scale keys scaleKeys: [string, string] = ['', '']; + setState(state: PlotState) { + this.state = merge({}, this.state, state); + } + + getState() { + return this.state; + } + addHook(type: T, hook: Hooks.Defs[T]) { pluginLog('UPlotConfigBuilder', false, 'addHook', type); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx new file mode 100644 index 00000000000..d9a6e787251 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx @@ -0,0 +1,159 @@ +import uPlot from 'uplot'; + +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +import { calculatePanRange, setupXAxisPan } from './XAxisInteractionAreaPlugin'; + +const asUPlot = (partial: Partial) => partial as uPlot; +const asConfigBuilder = (partial: Partial) => partial as UPlotConfigBuilder; + +const createMockXAxis = () => { + const element = document.createElement('div'); + element.classList.add('u-axis'); + return element; +}; + +const createMockConfigBuilder = () => { + return { + setState: jest.fn(), + getState: jest.fn(() => ({ isPanning: false })), + } satisfies Partial; +}; + +const createMockUPlot = (xAxisElement: HTMLElement) => { + const root = document.createElement('div'); + root.appendChild(xAxisElement); + + const over = document.createElement('div'); + Object.defineProperty(over, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: 800, height: 400 }), + }); + + return { + root, + over, + bbox: { width: 800, height: 400, left: 0, top: 0 }, + scales: { + x: { + min: 1000, + max: 2000, + range: () => [1000, 2000], + }, + }, + setScale: jest.fn(), + } satisfies Partial; +}; + +describe('XAxisInteractionAreaPlugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('calculatePanRange', () => { + it('should calculate pan range correctly for positive and negative drag', () => { + const timeFrom = 1000; + const timeTo = 2000; + const plotWidth = 800; + + const dragRight100px = calculatePanRange(timeFrom, timeTo, 100, plotWidth); + expect(dragRight100px.from).toBeCloseTo(875, 1); + expect(dragRight100px.to).toBeCloseTo(1875, 1); + + const dragLeft100px = calculatePanRange(timeFrom, timeTo, -100, plotWidth); + expect(dragLeft100px.from).toBeCloseTo(1125, 1); + expect(dragLeft100px.to).toBeCloseTo(2125, 1); + }); + + it('should return original range when not dragged', () => { + const noDrag = calculatePanRange(1000, 2000, 0, 800); + expect(noDrag.from).toBe(1000); + expect(noDrag.to).toBe(2000); + }); + }); + + describe('setupXAxisPan', () => { + let mockQueryZoom: jest.Mock; + let mockConfigBuilder: ReturnType; + let xAxisElement: HTMLElement; + let mockUPlot: ReturnType; + + beforeEach(() => { + mockQueryZoom = jest.fn(); + mockConfigBuilder = createMockConfigBuilder(); + xAxisElement = createMockXAxis(); + mockUPlot = createMockUPlot(xAxisElement); + document.body.appendChild(mockUPlot.root!); + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('should handle missing x-axis element gracefully', () => { + const emptyRoot = document.createElement('div'); + const emptyUPlot = { ...mockUPlot, root: emptyRoot }; + + expect(() => setupXAxisPan(asUPlot(emptyUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom)).not.toThrow(); + }); + + it('should show grab cursor on hover and grabbing during drag', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mouseenter')); + expect(xAxisElement).toHaveStyle({ cursor: 'grab' }); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + expect(xAxisElement).toHaveStyle({ cursor: 'grabbing' }); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + expect(xAxisElement).toHaveStyle({ cursor: 'grab' }); + }); + + it('should update scale during drag and call queryZoom on completion', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 350, bubbles: true })); + + const expectedRange = calculatePanRange(1000, 2000, -50, 800); + + expect(mockUPlot.setScale).toHaveBeenCalledWith('x', { + min: expectedRange.from, + max: expectedRange.to, + }); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + + expect(mockQueryZoom).toHaveBeenCalledWith(expectedRange); + }); + + it('should not call queryZoom when drag distance is below threshold', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 402, bubbles: true })); + + expect(mockQueryZoom).not.toHaveBeenCalled(); + }); + + it('should set isPanning state during drag and clear on mouseup', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 350, bubbles: true })); + + const expectedRange = calculatePanRange(1000, 2000, -50, 800); + + expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ + isPanning: true, + min: expectedRange.from, + max: expectedRange.to, + }); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + + expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx new file mode 100644 index 00000000000..694f372307d --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx @@ -0,0 +1,164 @@ +import { useLayoutEffect } from 'react'; +import uPlot from 'uplot'; + +import { getFeatureToggle } from '../../../utils/featureToggle'; +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +const MIN_PAN_DIST = 5; + +/** + * Calculates the new time range after a pan operation. + * + * @returns Object containing the new from and to time values + * @internal - exported for testing only + */ +export const calculatePanRange = ( + timeFrom: number, + timeTo: number, + dragPixels: number, + plotWidth: number +): { from: number; to: number } => { + const unitsPerPx = (timeTo - timeFrom) / (plotWidth / uPlot.pxRatio); + const timeShift = dragPixels * unitsPerPx; + + return { + from: timeFrom - timeShift, + to: timeTo - timeShift, + }; +}; + +/** + * Enables panning the time range by click and dragging x-axis labels with the mouse. + * Provides visual feedback (grab/grabbing cursor) and real-time grid updates during drag. + * + * @returns Cleanup function to remove event listeners + * @internal - exported for testing only + */ +export const setupXAxisPan = ( + u: uPlot, + config: UPlotConfigBuilder, + queryZoom: (range: { from: number; to: number }) => void +): (() => void) => { + let xAxes = u.root.querySelectorAll('.u-axis'); + let xAxis = xAxes[0]; + + if (!xAxis || !(xAxis instanceof HTMLElement)) { + return () => {}; + } + + const xAxisEl = xAxis; + + let activeMoveListener: ((e: MouseEvent) => void) | null = null; + let activeUpListener: ((e: MouseEvent) => void) | null = null; + + const handleMouseEnter = () => { + xAxisEl.style.cursor = 'grab'; + }; + + const handleMouseLeave = () => { + xAxisEl.style.cursor = ''; + }; + + const handleMouseDown = (e: Event) => { + if (!(e instanceof MouseEvent)) { + return; + } + e.preventDefault(); + + xAxisEl.style.cursor = 'grabbing'; + + let xScale = u.scales.x; + + let rect = u.over.getBoundingClientRect(); + let startX = e.clientX - rect.left; + let startMin = xScale.min!; + let startMax = xScale.max!; + + const onMove = (e: MouseEvent) => { + e.preventDefault(); + + let currentX = e.clientX - rect.left; + let dragPixels = currentX - startX; + + const { from, to } = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width); + + config.setState({ isPanning: true, min: from, max: to }); + + u.setScale('x', { + min: from, + max: to, + }); + }; + + const onUp = (e: MouseEvent) => { + let endX = e.clientX - rect.left; + let dragPixels = endX - startX; + + xAxisEl.style.cursor = 'grab'; + + config.setState({ isPanning: false }); + + if (Math.abs(dragPixels) >= MIN_PAN_DIST) { + const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width); + queryZoom(newRange); + } + + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + activeMoveListener = null; + activeUpListener = null; + }; + + activeMoveListener = onMove; + activeUpListener = onUp; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + + xAxisEl.addEventListener('mouseenter', handleMouseEnter); + xAxisEl.addEventListener('mouseleave', handleMouseLeave); + xAxisEl.addEventListener('mousedown', handleMouseDown); + + return () => { + xAxisEl.removeEventListener('mouseenter', handleMouseEnter); + xAxisEl.removeEventListener('mouseleave', handleMouseLeave); + xAxisEl.removeEventListener('mousedown', handleMouseDown); + + if (activeMoveListener) { + document.removeEventListener('mousemove', activeMoveListener); + } + if (activeUpListener) { + document.removeEventListener('mouseup', activeUpListener); + } + }; +}; + +export interface XAxisInteractionAreaPluginProps { + config: UPlotConfigBuilder; + queryZoom?: (range: { from: number; to: number }) => void; +} + +/** + * Plugin for handling x-axis area interactions, such as time range panning. + * Properly manages event listener lifecycle to prevent memory leaks. + */ +export const XAxisInteractionAreaPlugin = ({ config, queryZoom }: XAxisInteractionAreaPluginProps) => { + useLayoutEffect(() => { + let cleanup: (() => void) | undefined; + + config.addHook('init', (u) => { + if (queryZoom != null && getFeatureToggle('timeRangePan')) { + cleanup = setupXAxisPan(u, config, queryZoom); + } + }); + + return () => { + if (cleanup) { + cleanup(); + } + }; + }, [config, queryZoom]); + + return null; +}; diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index b9082ae9901..b46ce1d33bb 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -353,6 +353,7 @@ export { EventsCanvas } from './components/uPlot/geometries/EventsCanvas'; export { TooltipPlugin2 } from './components/uPlot/plugins/TooltipPlugin2'; export { EventBusPlugin } from './components/uPlot/plugins/EventBusPlugin'; export { KeyboardPlugin } from './components/uPlot/plugins/KeyboardPlugin'; +export { XAxisInteractionAreaPlugin } from './components/uPlot/plugins/XAxisInteractionAreaPlugin'; export { type PlotTooltipInterpolator, type PlotSelection, FIXED_UNIT } from './components/uPlot/types'; export { type UPlotConfigPrepFn } from './components/uPlot/config/UPlotConfigBuilder'; diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index 82c18cdd359..f81de163b7f 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -136,6 +136,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ direction: isHorizontal ? ScaleDirection.Right : ScaleDirection.Up, isTime: true, range: () => { + const state = builder.getState(); + if (state.isPanning) { + return [state.min, state.max]; + } const r = getTimeRange(); return [r.from.valueOf(), r.to.valueOf()]; }, diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index bb0a93310a8..24f6be1672e 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -12,7 +12,13 @@ import { } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode, VizOrientation } from '@grafana/schema'; -import { EventBusPlugin, KeyboardPlugin, TooltipPlugin2, usePanelContext } from '@grafana/ui'; +import { + EventBusPlugin, + KeyboardPlugin, + TooltipPlugin2, + XAxisInteractionAreaPlugin, + usePanelContext, +} from '@grafana/ui'; import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; @@ -141,6 +147,7 @@ export const TimeSeriesPanel = ({ {cursorSync !== DashboardCursorSync.Off && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( Date: Thu, 13 Nov 2025 12:08:14 +0100 Subject: [PATCH 205/209] fix(unified-storage): process list items concurrently (#113801) --- pkg/storage/unified/apistore/store.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 2ad474dc90f..5adf5e8b958 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -33,6 +33,7 @@ import ( "k8s.io/client-go/tools/cache" authtypes "github.com/grafana/authlib/types" + "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" @@ -472,16 +473,36 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti } if v.IsNil() { - v.Set(reflect.MakeSlice(v.Type(), 0, 0)) + v.Set(reflect.MakeSlice(v.Type(), 0, len(rsp.Items))) } - for _, item := range rsp.Items { + // Pre-allocate results slice to preserve order and avoid race conditions. + // Each goroutine writes to its own index, no mutex needed. + type resultSlot struct { + obj runtime.Object + shouldAppend bool + } + results := make([]resultSlot, len(rsp.Items)) + + // Concurrently process items as some may be large and take a while to process. + err = concurrency.ForEachJob(ctx, len(rsp.Items), 10, func(ctx context.Context, idx int) error { + item := rsp.Items[idx] obj, shouldAppend, err := s.processItem(ctx, item, opts, predicate) if err != nil { return err } if shouldAppend { - v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem())) + results[idx] = resultSlot{obj: obj, shouldAppend: true} + } + return nil + }) + if err != nil { + return err + } + + for _, r := range results { + if r.shouldAppend { + v.Set(reflect.Append(v, reflect.ValueOf(r.obj).Elem())) } } From b550750a9b9548b3fcd81b0fa0a969561e05dd82 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 13 Nov 2025 13:08:10 +0100 Subject: [PATCH 206/209] Zanzana: Rename namespace to req_namespace label (#113822) --- pkg/services/authz/zanzana/server/metrics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/authz/zanzana/server/metrics.go b/pkg/services/authz/zanzana/server/metrics.go index 7df91aed169..182cedf41be 100644 --- a/pkg/services/authz/zanzana/server/metrics.go +++ b/pkg/services/authz/zanzana/server/metrics.go @@ -25,7 +25,7 @@ func newZanzanaServerMetrics(reg prometheus.Registerer) *metrics { Subsystem: metricsSubSystem, Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), }, - []string{"method", "namespace"}, + []string{"method", "request_namespace"}, ), } } From f5f0c1e6f6f3c4099a70d9bbf47378180809be9f Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 13 Nov 2025 12:23:25 +0000 Subject: [PATCH 207/209] Playwright: fix timezone test to work at all times (#113827) * fix timezone test to work at all times * remove unused imports --- .../dashboards-suite/dashboard-time-zone.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 6d4b589b79c..2d56cd14512 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -1,4 +1,4 @@ -import { addDays, addHours, differenceInCalendarDays, differenceInMinutes, isBefore, parseISO, toDate } from 'date-fns'; +import { differenceInMinutes, parseISO, toDate } from 'date-fns'; import { Page } from 'playwright-core'; import { test, expect, DashboardPage, E2ESelectorGroups } from '@grafana/plugin-e2e'; @@ -213,10 +213,7 @@ const isTimeCorrect = (inUtc: string, inTz: string, offset: number): boolean => } const utcDate = toDate(parseISO(inUtc)); - const utcDateWithOffset = addHours(toDate(parseISO(inUtc)), offset); - const dayDifference = differenceInCalendarDays(utcDate, utcDateWithOffset); // if the utcDate +/- offset is the day before/after then we need to adjust reference - const dayOffset = isBefore(utcDateWithOffset, utcDate) ? dayDifference * -1 : dayDifference; - const tzDate = addDays(toDate(parseISO(inTz)), dayOffset); // adjust tzDate with any dayOffset + const tzDate = toDate(parseISO(inTz)); const diff = Math.abs(differenceInMinutes(utcDate, tzDate)); // use Math.abs if tzDate is in future return diff <= Math.abs(offset * 60); From a2150b0b79f597b3b2aa11cc22989f1d64bf4af1 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:33:43 +0200 Subject: [PATCH 208/209] Deps: Bump scenes version to v6.46.0 (#113823) bump scenes --- package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index eca1a345d05..0402c332903 100644 --- a/package.json +++ b/package.json @@ -296,8 +296,8 @@ "@grafana/plugin-ui": "^0.10.10", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.42.1", - "@grafana/scenes-react": "^6.42.1", + "@grafana/scenes": "^6.46.0", + "@grafana/scenes-react": "^6.46.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index 5b2f3c765a7..c7ffd4339f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3596,11 +3596,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^6.42.1": - version: 6.42.1 - resolution: "@grafana/scenes-react@npm:6.42.1" +"@grafana/scenes-react@npm:^6.46.0": + version: 6.46.0 + resolution: "@grafana/scenes-react@npm:6.46.0" dependencies: - "@grafana/scenes": "npm:6.42.1" + "@grafana/scenes": "npm:6.46.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3612,7 +3612,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/190289561d343a7a2d9d036f9b23abb063cee20f3a3e9e9275d8f11ef2e0df9fbd256e4687bf5a7b8f59aa79ae2bbc05bbb009876f582ebaeed97a0f52492907 + checksum: 10/be082f31c14e636efe6c60f98771a03b94606c6210a1f90d64a1c4ee8429ccb5aaa9e401092f4f6d728befeec13051d9811ec894bdee9b3c39865a5ebc2bba41 languageName: node linkType: hard @@ -3642,9 +3642,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.42.1, @grafana/scenes@npm:^6.42.1": - version: 6.42.1 - resolution: "@grafana/scenes@npm:6.42.1" +"@grafana/scenes@npm:6.46.0, @grafana/scenes@npm:^6.46.0": + version: 6.46.0 + resolution: "@grafana/scenes@npm:6.46.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3664,7 +3664,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/f1f455f823c01324942fef9feb256b04cb209d486a43e6dd683e984abb88f078d8e2a9d85e31619e37c9c3ee4356c97707d258a608f9b64ae8df097813bf178f + checksum: 10/c4b2b3113da0ea9b5745b4d560e73ad8877934138cb2862ed0fecf0954a36dc7aee2b15e12b802e0884ff8f56756935acc545019753f99a2900ba19a620a4e96 languageName: node linkType: hard @@ -18885,8 +18885,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.10.10" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:^6.42.1" - "@grafana/scenes-react": "npm:^6.42.1" + "@grafana/scenes": "npm:^6.46.0" + "@grafana/scenes-react": "npm:^6.46.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From 3e4933ec603ffa5075fb679aa5f1d6954c26ef75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Thu, 13 Nov 2025 13:59:18 +0100 Subject: [PATCH 209/209] Span Details: Two-column view (#112856) * Span Details: Two-column view Fixes #108465 * Use different flow * Remove redundant comment * Fix resizing and background color * Clean up styles * Fix tests * Clean up * Update types * Revert i18n key changes * Clean up i18n keys --- .../SpanDetail/AccordianKeyValues.tsx | 3 - .../SpanDetail/AccordianLogs.tsx | 3 - .../SpanDetail/AccordianReferences.tsx | 7 +- .../SpanDetail/KeyValuesTable.tsx | 2 - .../SpanDetail/ShareSpanButton.tsx | 21 +- .../SpanDetail/SpanDetailLinkButtons.test.tsx | 33 +- .../SpanDetail/SpanDetailLinkButtons.tsx | 80 ++++- .../TraceTimelineViewer/SpanDetail/index.tsx | 303 ++++++++++++------ public/locales/en-US/grafana.json | 4 +- 9 files changed, 310 insertions(+), 146 deletions(-) diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx index 93822458ac7..971c55e4101 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -39,9 +39,6 @@ export const getStyles = (theme: GrafanaTheme2) => { padding: '0.25em 0.1em', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - '&:hover': { - background: autoColor(theme, '#e8e8e8'), - }, }), headerLabel: css({ width: '120px', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index efb36f26948..4e40044bab6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -38,9 +38,6 @@ const getStyles = (theme: GrafanaTheme2) => { color: 'inherit', display: 'flex', alignItems: 'center', - '&:hover': { - background: autoColor(theme, '#e8e8e8'), - }, }), AccordianLogsContent: css({ label: 'AccordianLogsContent', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 10c31897ec6..9c80e9855b6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -37,20 +37,15 @@ const getStyles = (theme: GrafanaTheme2) => ({ AccordianReferences: css({ label: 'AccordianReferences', position: 'relative', - marginBottom: '0.25rem', }), AccordianReferencesHeader: css({ label: 'AccordianReferencesHeader', color: 'inherit', display: 'block', padding: '0.25rem 0', - '&:hover': { - background: autoColor(theme, '#dadada'), - }, }), AccordianReferencesContent: css({ label: 'AccordianReferencesContent', - background: autoColor(theme, '#f0f0f0'), borderTop: `1px solid ${autoColor(theme, '#d8d8d8')}`, padding: '0.5rem 0.5rem 0.25rem 0.5rem', }), @@ -96,7 +91,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ debugLabel: css({ margin: '0 5px 0 5px', '&::before': { - color: '#bbb', + color: autoColor(theme, '#666'), content: 'attr(data-label)', }, }), diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx index a6c79b2d2cf..58dec09f302 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -32,8 +32,6 @@ export const getStyles = (theme: GrafanaTheme2) => { KeyValueTable: css({ label: 'KeyValueTable', background: autoColor(theme, '#fff'), - border: `1px solid ${autoColor(theme, '#ddd')}`, - marginBottom: '0.5rem', maxHeight: '450px', overflow: 'auto', }), diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx index cca6e4f3562..f33691e0cd8 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx @@ -1,14 +1,29 @@ -import { LinkModel } from '@grafana/data'; +import { css } from '@emotion/css'; + +import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { Button } from '@grafana/ui'; +import { Button, useStyles2 } from '@grafana/ui'; type Props = { focusSpanLink: LinkModel; }; +function getStyles(theme: GrafanaTheme2) { + return { + shareButton: css({ + [theme.breakpoints.down('sm')]: { + span: { + display: 'none', + }, + }, + }), + }; +} + export function ShareSpanButton(props: Props) { const { focusSpanLink } = props; const { interpolatedParams, ...linkProps } = focusSpanLink ?? {}; + const styles = useStyles2(getStyles); return ( {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} @@ -29,7 +44,7 @@ export function ShareSpanButton(props: Props) { } }} > - diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx index 8c0f82337f4..528c4ea7944 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { CoreApp, TimeRange } from '@grafana/data'; import { usePluginLinks } from '@grafana/runtime'; import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer'; @@ -25,6 +27,10 @@ const timeRange = { to: new Date(1000), } as unknown as TimeRange; +function getContent(result: React.ReactElement) { + return result.props.children.props.children[0]; +} + describe('getSpanDetailLinkButtons', () => { beforeEach(() => { jest.clearAllMocks(); @@ -55,8 +61,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Explore, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Logs for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Logs for this span'); }); it('should create profile link button when profiles link exists', () => { @@ -75,8 +82,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Dashboard, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Profiles for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span'); }); it('should create session link button when session link exists', () => { @@ -91,8 +99,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Explore, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Session for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Session for this span'); }); it('should create profile drilldown button when plugin link exists', () => { @@ -121,9 +130,10 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Explore, }); - expect(result.props.children).toHaveLength(2); - expect(result.props.children[0].props.link.title).toBe('Profiles for this span'); - expect(result.props.children[1].props.link.title).toBe('Open in Profiles Drilldown'); + const content = getContent(result); + expect(content).toHaveLength(2); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span'); + expect(content[1].props.spanLinkModel.linkModel.title).toBe('Open in Profiles Drilldown'); }); it('should not create profile drilldown button when not in Explore', () => { @@ -152,8 +162,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Dashboard, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Profiles for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span'); }); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx index 605280c292a..45828f7c247 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx @@ -1,11 +1,20 @@ +import { css } from '@emotion/css'; import * as React from 'react'; -import { CoreApp, IconName, LinkModel, PluginExtensionPoints, RawTimeRange, TimeRange } from '@grafana/data'; +import { + CoreApp, + GrafanaTheme2, + IconName, + LinkModel, + PluginExtensionPoints, + RawTimeRange, + TimeRange, +} from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { config, locationService, reportInteraction, usePluginLinks } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; -import { DataLinkButton, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { Button, DataLinkButton, Dropdown, Menu, useStyles2 } from '@grafana/ui'; import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; @@ -28,6 +37,7 @@ export type Props = { timeRange: TimeRange; createSpanLink?: SpanLinkFunc; app: CoreApp; + shareButton?: React.ReactNode; }; /** @@ -51,9 +61,10 @@ const MAX_LINKS = 3; const ABSOLUTE_LINK_PATTERN = /^https?:\/\//i; export const getSpanDetailLinkButtons = (props: Props) => { - const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app } = props; + const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app, shareButton } = props; let linkToProfiles: SpanLinkDef | undefined; + let content = shareButton ? <>{shareButton} : undefined; if (createSpanLink) { const links = (createSpanLink(span) || []) @@ -112,23 +123,65 @@ export const getSpanDetailLinkButtons = (props: Props) => { }); if (links.length > MAX_LINKS) { - return ; - } else { - return ( + content = ( <> - {links.map(({ linkModel, icon, className }, index) => ( - + + {shareButton} + + ); + } else if (links.length > 0) { + content = ( + <> + {links.map((spanLinkModel, index) => ( + ))} + {shareButton} ); } } - return <>; + if (!content) { + return <>; + } + + return ( + + {content} + + ); +}; + +function getResponsibleButtonStyles(theme: GrafanaTheme2) { + return css({ + [theme.breakpoints.down('sm')]: { + span: { display: 'none' }, + }, + }); +} + +const SingleLinkButton: React.FC<{ spanLinkModel: SpanLinkModel }> = ({ spanLinkModel }) => { + const styles = useStyles2(getResponsibleButtonStyles); + const { linkModel, icon, className } = spanLinkModel; + return ( + + + + ); }; const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => { - const [isOpen, setIsOpen] = React.useState(false); + const [_, setIsOpen] = React.useState(false); + const styles = useStyles2(getResponsibleButtonStyles); const menu = ( @@ -144,14 +197,15 @@ const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => { return ( - Links - + ); }; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 85a15233995..238002e7975 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -14,7 +14,7 @@ import { css, cx } from '@emotion/css'; import { SpanStatusCode } from '@opentelemetry/api'; -import { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { CoreApp, @@ -114,17 +114,32 @@ const useResourceAttributesExtensionLinks = ({ const getStyles = (theme: GrafanaTheme2) => { return { + card: css({ + ':not(:empty)': { + border: '1px solid ' + theme.colors.border.weak, + '&:hover': { + border: '1px solid ' + theme.colors.border.strong, + }, + }, + borderRadius: theme.shape.radius.md, + margin: '6px', + padding: '5px', + }), header: css({ + label: 'SpanDetailHeader', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0 1rem', marginBottom: '0.25rem', + flexDirection: 'column', }), content: css({ + label: 'SpanDetailContent', fontSize: theme.typography.bodySmall.fontSize, }), listWrapper: css({ + label: 'SpanDetailListWrapper', overflow: 'hidden', flexGrow: 1, display: 'flex', @@ -133,13 +148,25 @@ const getStyles = (theme: GrafanaTheme2) => { list: css({ textAlign: 'left', }), + spanDetailComponent: css({ + label: 'SpanDetailComponent', + display: 'flex', + flexDirection: 'column', // On bigger screens display attributes below service name + }), + serviceNameAndLinks: css({ + label: 'ServiceNameAndLinks', + display: 'flex', + width: '100%', + marginBottom: '16px', + }), operationName: css({ + label: 'SpanDetailOperationName', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '50%', - flexGrow: 0, + flexGrow: 1, flexShrink: 0, }), AccordianWarnings: css({ @@ -152,9 +179,6 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'AccordianWarningsHeader', background: autoColor(theme, '#fff7e6'), padding: '0.25rem 0.5rem', - '&:hover': { - background: autoColor(theme, '#ffe7ba'), - }, }), AccordianWarningsHeaderOpen: css({ label: 'AccordianWarningsHeaderOpen', @@ -180,6 +204,7 @@ const getStyles = (theme: GrafanaTheme2) => { letterSpacing: '0.25px', margin: '0.5em 0 -0.75em', textAlign: 'right', + clear: 'both', }), debugLabel: css({ label: 'debugLabel', @@ -314,6 +339,8 @@ export default function SpanDetail(props: SpanDetailProps) { : []), ]; + const mainContainerRef = useRef(null); + const styles = useStyles2(getStyles); if (span.kind) { overviewItems.push({ @@ -358,15 +385,6 @@ export default function SpanDetail(props: SpanDetailProps) { }); } - const linksComponent = getSpanDetailLinkButtons({ - span, - createSpanLink, - datasourceType, - traceToProfilesOptions, - timeRange, - app, - }); - const { interpolatedParams, ...focusSpanLink } = createFocusSpanLink(traceID, spanID); const resourceLinksGetter = useResourceAttributesExtensionLinks({ process, @@ -376,102 +394,132 @@ export default function SpanDetail(props: SpanDetailProps) { timeRange, }); + const linksComponent = getSpanDetailLinkButtons({ + span, + createSpanLink, + datasourceType, + traceToProfilesOptions, + timeRange, + app, + shareButton: , + }); + + const listOfContentCards = []; + + listOfContentCards.push( + tagsToggle(spanID)} + /> + ); + + if (process.tags) { + listOfContentCards.push( + processToggle(spanID)} + /> + ); + } + + if (logs && logs.length > 0) { + listOfContentCards.push( + logsToggle(spanID)} + onItemToggle={(logItem) => logItemToggle(spanID, logItem)} + timestamp={traceStartTime} + /> + ); + } + + if (warnings && warnings.length > 0) { + listOfContentCards.push( + ({ + key: '', + value: warning, + type: 'warning', + }))} + onlyValues={true} + showSummary={false} + showCountBadge={true} + isOpen={isWarningsOpen} + onToggle={() => warningsToggle(spanID)} + label={t('explore.span-detail.label-warnings', 'Warnings')} + /> + ); + } + + if (stackTraces?.length) { + listOfContentCards.push( + ({ + key: '', + value: stackTrace, + type: 'code', + }))} + onlyValues={true} + showSummary={false} + showCountBadge={true} + isOpen={isStackTracesOpen} + onToggle={() => stackTracesToggle(spanID)} + label={t('explore.span-detail.label-stack-trace', 'Stack trace')} + /> + ); + } + + if (references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF')) { + listOfContentCards.push( + referencesToggle(spanID)} + onItemToggle={(reference) => referenceItemToggle(spanID, reference)} + createFocusSpanLink={createFocusSpanLink} + /> + ); + } + + if (span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey)) { + listOfContentCards.push( + + ); + } + return ( -
+
-
- {operationName} -
+
+
+ {operationName} +
+ {linksComponent} +
-
-
{linksComponent}
-
- tagsToggle(spanID)} - /> - {process.tags && ( - processToggle(spanID)} - /> - )} -
- {logs && logs.length > 0 && ( - logsToggle(spanID)} - onItemToggle={(logItem) => logItemToggle(spanID, logItem)} - timestamp={traceStartTime} - /> - )} - - {warnings && warnings.length > 0 && ( - ({ - key: '', - value: warning, - type: 'text', - }))} - showSummary={false} - showCountBadge={true} - isOpen={isWarningsOpen} - onlyValues={true} - onToggle={() => warningsToggle(spanID)} - label={t('explore.span-detail.warnings', 'Warnings')} - /> - )} - - {stackTraces?.length ? ( - ({ - key: '', - value: stackTrace, - type: 'code', - }))} - onlyValues={true} - showSummary={false} - showCountBadge={true} - isOpen={isStackTracesOpen} - onToggle={() => stackTracesToggle(spanID)} - label={t('explore.span-detail.label-stack-trace', 'Stack trace')} - /> - ) : null} - - {references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF') && ( - referencesToggle(spanID)} - onItemToggle={(reference) => referenceItemToggle(spanID, reference)} - createFocusSpanLink={createFocusSpanLink} - /> - )} - {span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey) && ( - - )} + {/* TODO: fix keyboard a11y */} @@ -507,3 +555,52 @@ export const getAbsoluteTime = (startTime: number, timeZone: TimeZone) => { const absoluteTime = match[1] ? match[1] : dateStr; return ` (${absoluteTime})`; }; + +const CardsContainer = ({ + listOfContentCards, + mainContainerRef, +}: { + listOfContentCards: React.ReactNode[]; + mainContainerRef?: React.RefObject; +}) => { + const styles = useStyles2(getStyles); + + const useTwoColumns = + mainContainerRef && mainContainerRef.current && mainContainerRef.current.getBoundingClientRect().width > 1000; + + if (useTwoColumns) { + return ( + <> +
+ {listOfContentCards.map((card, index) => + index % 2 === 0 ? ( +
+ {card} +
+ ) : null + )} +
+ +
+ {listOfContentCards.map((card, index) => + index % 2 === 1 ? ( +
+ {card} +
+ ) : null + )} +
+ + ); + } + + return ( +
+ {listOfContentCards.map((card, index) => ( +
+ {card} +
+ ))} +
+ ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a17404a80e6..2707b681ae4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7465,6 +7465,7 @@ "label-resource-attributes": "Resource attributes", "label-span-attributes": "Span attributes", "label-stack-trace": "Stack trace", + "label-warnings": "Warnings", "overview-items": { "label": { "child-count": "Child Count:", @@ -7473,8 +7474,7 @@ "start-time": "Start Time:" } }, - "share-span": "Share", - "warnings": "Warnings" + "share-span": "Share" }, "span-filters": { "aria-label-select-max-span-operator": "Select max span operator",