Merge branch 'bugfix/deprecate-single-move-delete' into bugfix/files-authorization

This commit is contained in:
Roberto Jimenez Sanchez
2025-12-15 13:28:46 +01:00
10 changed files with 297 additions and 43 deletions
-5
View File
@@ -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
@@ -154,9 +154,12 @@ func TestJobProgressRecorderWarningStatus(t *testing.T) {
// Verify the final status includes warnings
require.NotNil(t, finalStatus.Warnings)
assert.Len(t, finalStatus.Warnings, 3)
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
assert.Contains(t, finalStatus.Warnings[1], "missing optional field")
assert.Contains(t, finalStatus.Warnings[2], "validation warning")
expectedWarnings := []string{
"deprecated API used (file: dashboards/test.json, name: test-resource, action: updated)",
"missing optional field (file: dashboards/test2.json, name: test-resource-2, action: created)",
"validation warning (file: datasources/test.yaml, name: test-resource-3, action: created)",
}
assert.ElementsMatch(t, finalStatus.Warnings, expectedWarnings)
// Verify the state is set to Warning
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
@@ -51,11 +51,16 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const visibleChildren = useMemo(() => {
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
return isEditing
? children
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
}, [editableElement, isEditing]);
const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -74,6 +79,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
setIsCollapsed(!isCollapsed);
};
if (elementInfo.isHidden && !isEditing) {
return null;
}
return (
// todo: add proper keyboard navigation
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
@@ -130,8 +139,8 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
{isContainer && !isCollapsed && (
<ul className={styles.nodeChildren} role="group">
{children.length > 0 ? (
children.map((child, i) => (
{visibleChildren.length > 0 ? (
visibleChildren.map((child, i) => (
<DashboardOutlineNode
key={child.state.key}
sceneObject={child}
@@ -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 }) => (
@@ -91,10 +91,12 @@ export class RowItem
}
public getEditableElementInfo(): EditableDashboardElementInfo {
const isHidden = !this.state.conditionalRendering?.state.result;
return {
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'list-ul',
isHidden,
};
}
@@ -89,10 +89,12 @@ export class TabItem
}
public getEditableElementInfo(): EditableDashboardElementInfo {
const isHidden = !this.state.conditionalRendering?.state.result;
return {
typeName: t('dashboard.edit-pane.elements.tab', 'Tab'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'layers',
isHidden,
};
}
@@ -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');
}
}
+4 -1
View File
@@ -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",