diff --git a/public/app/core/reducers/fn-slice.ts b/public/app/core/reducers/fn-slice.ts index 5c6dbc5d3d7a3..69d053f58acf9 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -10,6 +10,12 @@ export interface FnPanelOptionsUpdate { readonly revision: number; } +export interface FnPanelQueryPreviewUpdate { + readonly panelId: number; + readonly rawSql: string; + readonly revision: number; +} + export interface FnState { uid: string; slug: string; @@ -38,6 +44,7 @@ export interface FnState { eventListener: ((event: { type: string; data: T }) => void) | null; }; panelOptionsUpdate?: FnPanelOptionsUpdate; + panelQueryPreviewUpdate?: FnPanelQueryPreviewUpdate; /** * Monotonic host-owned revision that requests a one-shot refresh of this * dashboard without remounting the microfrontend. @@ -65,6 +72,7 @@ export type FnPropMappedFromState = Extract< | 'enablePanelDelete' | 'enablePanelLayoutEdit' | 'panelOptionsUpdate' + | 'panelQueryPreviewUpdate' | 'refreshRevision' | 'portalContainerID' >; @@ -85,6 +93,7 @@ export const fnStateProps: FnStateProp[] = [ 'hiddenVariables', 'pageTitle', 'panelOptionsUpdate', + 'panelQueryPreviewUpdate', 'refreshRevision', 'portalContainerID', 'queryParams', @@ -115,6 +124,7 @@ export const INITIAL_FN_STATE: FnState = { eventListener: null, }, panelOptionsUpdate: undefined, + panelQueryPreviewUpdate: undefined, refreshRevision: 0, portalContainerID: 'grafana-portal', } as const; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 4a7a200d2a910..d1fd9497d6f25 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -231,3 +231,88 @@ describe('applyFnPanelOptionsPreview', () => { expect(panel.updateOptions).not.toHaveBeenCalled(); }); }); + +describe('applyFnPanelQueryPreview', () => { + it('replaces only the first query and refreshes the live panel', async () => { + const { applyFnPanelQueryPreview } = await import('./DashboardPageFnPanelOptions'); + const panel = getPanel('table'); + panel.targets = [ + { refId: 'A', rawSql: 'SELECT old' }, + { refId: 'B', rawSql: 'SELECT unchanged' }, + ]; + panel.refresh = jest.fn(); + + applyFnPanelQueryPreview(panel, { + panelId: panel.id, + rawSql: 'SELECT preview', + revision: 2, + }); + + expect(panel.targets).toEqual([ + { refId: 'A', rawSql: 'SELECT preview' }, + { refId: 'B', rawSql: 'SELECT unchanged' }, + ]); + expect(panel.refresh).toHaveBeenCalledTimes(1); + }); + + it('does not replace a non-SQL first query target', async () => { + const { applyFnPanelQueryPreview } = await import('./DashboardPageFnPanelOptions'); + const panel = getPanel('table'); + panel.targets = [{ refId: 'A' }, { refId: 'B', rawSql: 'SELECT unchanged' }]; + panel.refresh = jest.fn(); + + applyFnPanelQueryPreview(panel, { + panelId: panel.id, + rawSql: 'SELECT preview', + revision: 1, + }); + + expect(panel.targets).toEqual([{ refId: 'A' }, { refId: 'B', rawSql: 'SELECT unchanged' }]); + expect(panel.refresh).not.toHaveBeenCalled(); + }); + + it('does nothing when a panel cannot execute a query preview', async () => { + const { applyFnPanelQueryPreview } = await import('./DashboardPageFnPanelOptions'); + const panel = getPanel('table'); + + expect(() => + applyFnPanelQueryPreview(panel, { + panelId: panel.id, + rawSql: 'SELECT preview', + revision: 1, + }) + ).not.toThrow(); + expect(panel.render).not.toHaveBeenCalled(); + }); +}); + +describe('applyFnPanelQueryPreview editor mode', () => { + it('does not replace a builder-mode SQL target that retains rawSql', async () => { + const { applyFnPanelQueryPreview } = await import('./DashboardPageFnPanelOptions'); + const panel = getPanel('table'); + panel.targets = [{ refId: 'A', editorMode: 'builder', rawSql: 'SELECT built' }]; + panel.refresh = jest.fn(); + + applyFnPanelQueryPreview(panel, { panelId: panel.id, rawSql: 'SELECT preview', revision: 1 }); + + expect(panel.targets).toEqual([{ refId: 'A', editorMode: 'builder', rawSql: 'SELECT built' }]); + expect(panel.refresh).not.toHaveBeenCalled(); + }); +}); + +describe('resolveFnPanelQueryPreview', () => { + it('replays a retained update only on the dashboard it was issued against', async () => { + const { resolveFnPanelQueryPreview } = await import('./DashboardPageFnPanelOptions'); + const update = { panelId: 1, rawSql: 'SELECT preview', revision: 1 }; + + const first = resolveFnPanelQueryPreview(undefined, update, 'a', true); + expect(first).toEqual({ apply: true, owner: { panelId: 1, revision: 1, uid: 'a' } }); + + expect(resolveFnPanelQueryPreview(first.owner, update, 'a', true).apply).toBe(true); + expect(resolveFnPanelQueryPreview(first.owner, update, 'a', false).apply).toBe(false); + expect(resolveFnPanelQueryPreview(first.owner, update, 'b', true).apply).toBe(false); + + const next = resolveFnPanelQueryPreview(first.owner, { ...update, revision: 2 }, 'b', false); + expect(next).toEqual({ apply: true, owner: { panelId: 1, revision: 2, uid: 'b' } }); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index e3a16ae23a161..a196329d492f6 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -14,7 +14,7 @@ import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaCont import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { FnGlobalState, FnPanelOptionsUpdate } from 'app/core/reducers/fn-slice'; +import { FnGlobalState, FnPanelOptionsUpdate, FnPanelQueryPreviewUpdate } from 'app/core/reducers/fn-slice'; import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; @@ -45,7 +45,12 @@ import { cleanUpDashboardAndVariables } from '../state/actions'; import { initDashboard } from '../state/initDashboard'; import { calculateNewPanelGridPos } from '../utils/panel'; -import { applyFnPanelOptionsPreview } from './DashboardPageFnPanelOptions'; +import { + applyFnPanelOptionsPreview, + applyFnPanelQueryPreview, + type FnPanelQueryPreviewOwner, + resolveFnPanelQueryPreview, +} from './DashboardPageFnPanelOptions'; export { applyFnPanelOptionsPreview } from './DashboardPageFnPanelOptions'; @@ -79,7 +84,12 @@ export type MapStateToDashboardPageProps = MapStateToProps< navIndex: StoreState['navIndex']; } & Pick< FnGlobalState, - 'FNDashboard' | 'controlsContainer' | 'dashboardAccessMode' | 'enablePanelLayoutEdit' | 'panelOptionsUpdate' + | 'FNDashboard' + | 'controlsContainer' + | 'dashboardAccessMode' + | 'enablePanelLayoutEdit' + | 'panelOptionsUpdate' + | 'panelQueryPreviewUpdate' > & { dashboardEventListener: FnGlobalState['metadata']['eventListener']; }, @@ -107,6 +117,7 @@ export const mapStateToProps: MapStateToDashboardPageProps = (state) => ({ dashboardAccessMode: state.fnGlobalState.dashboardAccessMode, enablePanelLayoutEdit: state.fnGlobalState.enablePanelLayoutEdit, panelOptionsUpdate: state.fnGlobalState.panelOptionsUpdate, + panelQueryPreviewUpdate: state.fnGlobalState.panelQueryPreviewUpdate, dashboardEventListener: state.fnGlobalState.metadata?.eventListener ?? null, }); @@ -225,6 +236,20 @@ export class UnthemedDashboardPage extends PureComponent { this.applyFnPanelOptionsUpdate(this.props.panelOptionsUpdate); } + if (FNDashboard && this.props.panelQueryPreviewUpdate) { + const update = this.props.panelQueryPreviewUpdate; + const { apply, owner } = resolveFnPanelQueryPreview( + this.fnPanelQueryPreviewOwner, + update, + dashboard.uid, + prevProps.dashboard !== dashboard + ); + this.fnPanelQueryPreviewOwner = owner; + if (apply) { + this.applyFnPanelQueryPreviewUpdate(update); + } + } + if (!FNDashboard) { const routeReloadCounter = (this.props.history.location?.state as any)?.routeReloadCounter; @@ -286,6 +311,20 @@ export class UnthemedDashboardPage extends PureComponent { } } + private fnPanelQueryPreviewOwner?: FnPanelQueryPreviewOwner; + + applyFnPanelQueryPreviewUpdate(update: FnPanelQueryPreviewUpdate) { + const panel = this.props.dashboard?.getPanelById(update.panelId); + if (!panel) { + FnLoggerService.warn('Unable to apply FN panel query preview because the panel was not found', { + panelId: update.panelId, + }); + return; + } + + applyFnPanelQueryPreview(panel, update); + } + applyFnPanelOptionsUpdate(update: FnPanelOptionsUpdate) { const panel = this.props.dashboard?.getPanelById(update.panelId); if (!panel) { diff --git a/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts b/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts index ddc89f36ae41c..8d6f49b54ef29 100644 --- a/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts +++ b/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts @@ -1,11 +1,18 @@ -import type { FieldConfigSource } from '@grafana/data'; -import type { FnPanelOptionsUpdate } from 'app/core/reducers/fn-slice'; +import type { DataQuery, FieldConfigSource } from '@grafana/data'; +import type { FnPanelOptionsUpdate, FnPanelQueryPreviewUpdate } from 'app/core/reducers/fn-slice'; + +export interface FnPanelQueryPreviewTarget extends DataQuery { + readonly editorMode?: string; + readonly rawSql?: string; +} export interface FnPanelOptionsPreviewTarget { description?: string; fieldConfig?: FieldConfigSource; id: number; options?: Record; + refresh?: () => void; + targets?: FnPanelQueryPreviewTarget[]; render: () => void; title: string; type: string; @@ -339,3 +346,40 @@ export function applyFnPanelOptionsPreview(panel: FnPanelOptionsPreviewTarget, u panel.render(); } } + +export function applyFnPanelQueryPreview(panel: FnPanelOptionsPreviewTarget, update: FnPanelQueryPreviewUpdate): void { + const target = panel.targets?.[0]; + // A builder-mode SQL target keeps a stale `rawSql` string; only a Raw SQL + // target executes it, so only that mode may take the preview. + if (!panel.refresh || !panel.targets || typeof target?.rawSql !== 'string' || target.editorMode === 'builder') { + return; + } + + panel.targets = panel.targets.map((target, index) => (index === 0 ? { ...target, rawSql: update.rawSql } : target)); + panel.refresh(); +} + +export interface FnPanelQueryPreviewOwner { + readonly panelId: number; + readonly revision: number; + readonly uid: string; +} + +/** + * Decide whether a panel query preview should be applied to `dashboardUid`. + * + * The first dashboard to receive an update owns it. A retained update is + * replayed after a reload only on its owner, so another dashboard with the + * same panel id is left untouched. + */ +export function resolveFnPanelQueryPreview( + owner: FnPanelQueryPreviewOwner | undefined, + update: FnPanelQueryPreviewUpdate, + dashboardUid: string, + dashboardChanged: boolean +): { apply: boolean; owner: FnPanelQueryPreviewOwner } { + if (owner?.panelId !== update.panelId || owner.revision !== update.revision) { + return { apply: true, owner: { panelId: update.panelId, revision: update.revision, uid: dashboardUid } }; + } + return { apply: dashboardChanged && owner.uid === dashboardUid, owner }; +} diff --git a/public/microfrontends/fn_dashboard/index.html b/public/microfrontends/fn_dashboard/index.html index 77759729254e6..7b80d58035e2d 100644 --- a/public/microfrontends/fn_dashboard/index.html +++ b/public/microfrontends/fn_dashboard/index.html @@ -1,6 +1,6 @@ CodeRabbit Micro-frontend
\ No newline at end of file + themePaths: { + light: '../../../public/build/grafana.light.626001a02b5b33e83edf.css', + dark: '../../../public/build/grafana.dark.dafda7e3786ebd2d8f31.css', + }, + }; \ No newline at end of file diff --git a/public/views/index-microfrontend-template.html b/public/views/index-microfrontend-template.html index 4eb1d8a0cd03b..f4ba7714986da 100644 --- a/public/views/index-microfrontend-template.html +++ b/public/views/index-microfrontend-template.html @@ -7,21 +7,21 @@
- + <% for (index in htmlWebpackPlugin.files.js) { %> <% if (htmlWebpackPlugin.files.jsIntegrity) { %> <% } else { %>