V2 Schema: Restore inspect panel json editing workflow, for v2 (#115227)
* Restore inspect panel json editing workflow, for v2 * add reporting * add validation testing * update the test to replicate * update localization
This commit is contained in:
@@ -1830,11 +1830,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 2
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('InspectJsonTab', () => {
|
||||
expect(obj.kind).toEqual('Panel');
|
||||
expect(obj.spec.id).toEqual(12);
|
||||
expect(obj.spec.data.kind).toEqual('QueryGroup');
|
||||
expect(tab.isEditable()).toBe(false);
|
||||
expect(tab.isEditable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { LibraryPanel } from '@grafana/schema/';
|
||||
import { Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui';
|
||||
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
|
||||
import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
@@ -27,6 +27,7 @@ import { getPrettyJSON } from 'app/features/inspector/utils/utils';
|
||||
import { reportPanelInspectInteraction } from 'app/features/search/page/reporting';
|
||||
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { buildVizPanel } from '../serialization/layoutSerializers/utils';
|
||||
import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene';
|
||||
import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
|
||||
import { vizPanelToSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
getQueryRunnerFor,
|
||||
isLibraryPanel,
|
||||
} from '../utils/utils';
|
||||
import { isPanelKindV2 } from '../v2schema/validation';
|
||||
|
||||
export type ShowContent = 'panel-json' | 'panel-data' | 'data-frames';
|
||||
|
||||
@@ -45,6 +47,7 @@ export interface InspectJsonTabState extends SceneObjectState {
|
||||
source: ShowContent;
|
||||
jsonText: string;
|
||||
onClose: () => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
|
||||
@@ -102,38 +105,77 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
|
||||
}
|
||||
|
||||
public onChangeSource = (value: SelectableValue<ShowContent>) => {
|
||||
this.setState({ source: value.value!, jsonText: getJsonText(value.value!, this.state.panelRef.resolve()) });
|
||||
this.setState({
|
||||
source: value.value!,
|
||||
jsonText: getJsonText(value.value!, this.state.panelRef.resolve()),
|
||||
error: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
public onApplyChange = () => {
|
||||
const panel = this.state.panelRef.resolve();
|
||||
const dashboard = getDashboardSceneFor(panel);
|
||||
const jsonObj = JSON.parse(this.state.jsonText);
|
||||
|
||||
const panelModel = new PanelModel(jsonObj);
|
||||
const gridItem = buildGridItemForPanel(panelModel);
|
||||
const newState = sceneUtils.cloneSceneObjectState(gridItem.state);
|
||||
|
||||
if (!(panel.parent instanceof DashboardGridItem)) {
|
||||
console.error('Cannot update state of panel', panel, gridItem);
|
||||
let jsonObj: unknown;
|
||||
try {
|
||||
jsonObj = JSON.parse(this.state.jsonText);
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
error: t('dashboard-scene.inspect-json-tab.error-invalid-json', 'Invalid JSON'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.onClose();
|
||||
if (isDashboardV2Spec(dashboard.getSaveModel())) {
|
||||
if (!isPanelKindV2(jsonObj)) {
|
||||
this.setState({
|
||||
error: t(
|
||||
'dashboard-scene.inspect-json-tab.error-invalid-v2-panel',
|
||||
'Panel JSON did not pass validation. Please check the JSON and try again.'
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const vizPanel = buildVizPanel(jsonObj, jsonObj.spec.id);
|
||||
|
||||
if (!dashboard.state.isEditing) {
|
||||
dashboard.onEnterEditMode();
|
||||
if (!dashboard.state.isEditing) {
|
||||
dashboard.onEnterEditMode();
|
||||
}
|
||||
|
||||
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
|
||||
panel_type_changed: panel.state.pluginId !== jsonObj.spec.vizConfig.group,
|
||||
panel_id_changed: getPanelIdForVizPanel(panel) !== jsonObj.spec.id,
|
||||
panel_grid_pos_changed: false, // Grid cant be edited from inspect in v2 panels.
|
||||
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(vizPanel.state.$data)),
|
||||
});
|
||||
|
||||
panel.setState(vizPanel.state);
|
||||
this.state.onClose();
|
||||
} else {
|
||||
const panelModel = new PanelModel(jsonObj);
|
||||
const gridItem = buildGridItemForPanel(panelModel);
|
||||
const newState = sceneUtils.cloneSceneObjectState(gridItem.state);
|
||||
|
||||
if (!(panel.parent instanceof DashboardGridItem)) {
|
||||
console.error('Cannot update state of panel', panel, gridItem);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.onClose();
|
||||
|
||||
if (!dashboard.state.isEditing) {
|
||||
dashboard.onEnterEditMode();
|
||||
}
|
||||
|
||||
panel.parent.setState(newState);
|
||||
|
||||
//Report relevant updates
|
||||
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
|
||||
panel_type_changed: panel.state.pluginId !== panelModel.type,
|
||||
panel_id_changed: getPanelIdForVizPanel(panel) !== panelModel.id,
|
||||
panel_grid_pos_changed: hasGridPosChanged(panel.parent.state, newState),
|
||||
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(newState.$data)),
|
||||
});
|
||||
}
|
||||
|
||||
panel.parent.setState(newState);
|
||||
|
||||
//Report relevant updates
|
||||
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
|
||||
panel_type_changed: panel.state.pluginId !== panelModel.type,
|
||||
panel_id_changed: getPanelIdForVizPanel(panel) !== panelModel.id,
|
||||
panel_grid_pos_changed: hasGridPosChanged(panel.parent.state, newState),
|
||||
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(newState.$data)),
|
||||
});
|
||||
};
|
||||
|
||||
public onCodeEditorBlur = (value: string) => {
|
||||
@@ -152,11 +194,6 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// V2 dashboard panels are not editable from the inspect
|
||||
if (isDashboardV2Spec(getDashboardSceneFor(panel).getSaveModel())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only support normal grid items for now and not repeated items
|
||||
if (panel.parent instanceof DashboardGridItem && panel.parent.isRepeated()) {
|
||||
return false;
|
||||
@@ -170,14 +207,14 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
|
||||
}
|
||||
|
||||
function InspectJsonTabComponent({ model }: SceneComponentProps<InspectJsonTab>) {
|
||||
const { source: show, jsonText } = model.useState();
|
||||
const { source: show, jsonText, error } = model.useState();
|
||||
const styles = useStyles2(getPanelInspectorStyles2);
|
||||
const options = model.getOptions();
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.toolbar} data-testid={selectors.components.PanelInspector.Json.content}>
|
||||
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1">
|
||||
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1" noMargin>
|
||||
<Select
|
||||
inputId="select-source-dropdown"
|
||||
options={options}
|
||||
@@ -192,6 +229,12 @@ function InspectJsonTabComponent({ model }: SceneComponentProps<InspectJsonTab>)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" title={t('dashboard-scene.inspect-json-tab.validation-error', 'Validation error')}>
|
||||
<p>{error}</p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
defaultPanelKind,
|
||||
defaultQueryGroupKind,
|
||||
defaultPanelQueryKind,
|
||||
defaultVizConfigKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
|
||||
import { isPanelKindV2 } from './validation';
|
||||
|
||||
describe('v2schema validation', () => {
|
||||
it('isPanelKindV2 returns true for a minimal valid PanelKind', () => {
|
||||
const panel = defaultPanelKind();
|
||||
// Ensure minimal required properties exist (defaults should be fine)
|
||||
panel.spec.vizConfig = defaultVizConfigKind();
|
||||
panel.spec.data = defaultQueryGroupKind();
|
||||
|
||||
expect(isPanelKindV2(panel)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when kind is not "Panel"', () => {
|
||||
const panel = defaultPanelKind();
|
||||
// @ts-expect-error intentional invalid kind for test
|
||||
panel.kind = 'NotAPanel';
|
||||
expect(isPanelKindV2(panel)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when data kind is wrong', () => {
|
||||
const panel = defaultPanelKind();
|
||||
// @ts-expect-error intentional invalid kind for test
|
||||
panel.spec.data = { kind: 'Wrong', spec: {} };
|
||||
expect(isPanelKindV2(panel)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when queries contain invalid entries', () => {
|
||||
const panel = defaultPanelKind();
|
||||
panel.spec.data = defaultQueryGroupKind();
|
||||
// @ts-expect-error push an invalid query shape
|
||||
panel.spec.data.spec.queries = [{}];
|
||||
expect(isPanelKindV2(panel)).toBe(false);
|
||||
|
||||
// Ensure a valid query shape passes
|
||||
panel.spec.data.spec.queries = [defaultPanelQueryKind()];
|
||||
expect(isPanelKindV2(panel)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when vizConfig.group is not a string', () => {
|
||||
const panel = defaultPanelKind();
|
||||
panel.spec.vizConfig = defaultVizConfigKind();
|
||||
// @ts-expect-error force wrong type
|
||||
panel.spec.vizConfig.group = 42;
|
||||
expect(isPanelKindV2(panel)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when transparent is not a boolean', () => {
|
||||
const panel = defaultPanelKind();
|
||||
// @ts-expect-error wrong type
|
||||
panel.spec.transparent = 'yes';
|
||||
expect(isPanelKindV2(panel)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
PanelKind,
|
||||
QueryGroupKind,
|
||||
VizConfigKind,
|
||||
PanelQueryKind,
|
||||
TransformationKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isPanelQueryKind(value: unknown): value is PanelQueryKind {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value.kind !== 'PanelQuery' || !isObject(value.spec)) {
|
||||
return false;
|
||||
}
|
||||
// Minimal checks for query spec; accept additional properties
|
||||
if (typeof value.spec.refId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.spec.hidden !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
// value.spec.query is an opaque "DataQueryKind" which is { kind: string, spec: Record<string, any> }
|
||||
const q = value.spec.query;
|
||||
if (!isObject(q) || typeof q.kind !== 'string' || !isObject(q.spec)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTransformationKind(value: unknown): value is TransformationKind {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.kind !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(value.spec)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isQueryGroupKind(value: unknown): value is QueryGroupKind {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value.kind !== 'QueryGroup' || !isObject(value.spec)) {
|
||||
return false;
|
||||
}
|
||||
const spec = value.spec;
|
||||
if (!Array.isArray(spec.queries) || !spec.queries.every(isPanelQueryKind)) {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(spec.transformations) || !spec.transformations.every(isTransformationKind)) {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(spec.queryOptions)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isVizConfigKind(value: unknown): value is VizConfigKind {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value.kind !== 'VizConfig') {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.group !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.version !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(value.spec)) {
|
||||
return false;
|
||||
}
|
||||
const spec = value.spec;
|
||||
if (!isObject(spec.options)) {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(spec.fieldConfig)) {
|
||||
return false;
|
||||
}
|
||||
// Minimal fieldConfig shape (defaults/overrides may be empty)
|
||||
if (!isObject(spec.fieldConfig)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isPanelKindV2(value: unknown): value is PanelKind {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value.kind !== 'Panel') {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(value.spec)) {
|
||||
return false;
|
||||
}
|
||||
const spec = value.spec;
|
||||
if (typeof spec.id !== 'number') {
|
||||
return false;
|
||||
}
|
||||
if (typeof spec.title !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof spec.description !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(spec.links)) {
|
||||
return false;
|
||||
}
|
||||
if (!isQueryGroupKind(spec.data)) {
|
||||
return false;
|
||||
}
|
||||
if (!isVizConfigKind(spec.vizConfig)) {
|
||||
return false;
|
||||
}
|
||||
if (spec.transparent !== undefined && typeof spec.transparent !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validatePanelKindV2(value: unknown): asserts value is PanelKind {
|
||||
if (!isPanelKindV2(value)) {
|
||||
throw new Error('Provided JSON is not a valid v2 Panel spec');
|
||||
}
|
||||
}
|
||||
@@ -6146,7 +6146,10 @@
|
||||
"no-data-found": "No data found"
|
||||
},
|
||||
"inspect-json-tab": {
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"error-invalid-json": "Invalid JSON",
|
||||
"error-invalid-v2-panel": "Panel JSON did not pass validation. Please check the JSON and try again.",
|
||||
"validation-error": "Validation error"
|
||||
},
|
||||
"interval-variable-form": {
|
||||
"description-auto-option": "Dynamically calculates interval by dividing time range by the count specified",
|
||||
|
||||
Reference in New Issue
Block a user