Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7bd7f4f72 | |||
| c47c360fd9 | |||
| 62cab8bd63 | |||
| 1e031db607 | |||
| 172f1fb974 |
@@ -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
|
||||
|
||||
@@ -16,17 +16,22 @@ interface Props {
|
||||
title?: string;
|
||||
offset?: number;
|
||||
dragClass?: string;
|
||||
onDragStart?: (event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onOpenMenu?: () => void;
|
||||
}
|
||||
|
||||
export function HoverWidget({ menu, title, dragClass, children, offset = -32, onOpenMenu }: Props) {
|
||||
export function HoverWidget({ menu, title, dragClass, children, offset = -32, onOpenMenu, onDragStart }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const selectors = e2eSelectors.components.Panels.Panel.HoverWidget;
|
||||
// Capture the pointer to keep the widget visible while dragging
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
draggableRef.current?.setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
draggableRef.current?.setPointerCapture(e.pointerId);
|
||||
onDragStart?.(e);
|
||||
},
|
||||
[onDragStart]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
draggableRef.current?.releasePointerCapture(e.pointerId);
|
||||
|
||||
@@ -384,6 +384,7 @@ export function PanelChrome({
|
||||
menu={menu}
|
||||
title={typeof title === 'string' ? title : undefined}
|
||||
dragClass={dragClass}
|
||||
onDragStart={onDragStart}
|
||||
offset={hoverHeaderOffset}
|
||||
onOpenMenu={onOpenMenu}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,9 +90,6 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
|
||||
}
|
||||
|
||||
if safepath.IsDir(opts.Path) {
|
||||
if err := r.authorizeDeleteFolder(ctx, opts.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.deleteFolder(ctx, opts)
|
||||
}
|
||||
|
||||
@@ -530,71 +527,26 @@ func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource,
|
||||
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), fmt.Errorf("could not determine identity type to check access"))
|
||||
}
|
||||
// only apply role based access if identity is not of type access policy
|
||||
if idType != authlib.TypeAnonymous {
|
||||
if idType == authlib.TypeAccessPolicy || id.GetOrgRole().Includes(identity.RoleEditor) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
|
||||
fmt.Errorf("must be logged in to access files from provisioning"))
|
||||
fmt.Errorf("must be admin or editor to access files from provisioning"))
|
||||
}
|
||||
|
||||
func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) error {
|
||||
func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) error {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return apierrors.NewUnauthorized(err.Error())
|
||||
}
|
||||
|
||||
// Determine the parent folder where this folder will be created
|
||||
parentFolderUID := ParentFolder(path, r.repo.Config())
|
||||
|
||||
rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{
|
||||
Group: FolderResource.Group,
|
||||
Resource: FolderResource.Resource,
|
||||
Namespace: id.GetNamespace(),
|
||||
Name: "",
|
||||
Verb: utils.VerbCreate,
|
||||
}, parentFolderUID)
|
||||
|
||||
if err != nil || !rsp.Allowed {
|
||||
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
|
||||
fmt.Errorf("no permission to create folder in parent folder %s", parentFolderUID))
|
||||
}
|
||||
|
||||
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
|
||||
fmt.Errorf("must have permission to access folders with provisioning"))
|
||||
}
|
||||
|
||||
func (r *DualReadWriter) authorizeDeleteFolder(ctx context.Context, path string) error {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return apierrors.NewUnauthorized(err.Error())
|
||||
}
|
||||
|
||||
// Parse the folder being deleted to get its UID
|
||||
folderToDelete := ParseFolder(path, r.repo.Config().GetName())
|
||||
|
||||
// Determine the parent folder for hierarchical permission checking
|
||||
parentFolderUID := ParentFolder(path, r.repo.Config())
|
||||
|
||||
rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{
|
||||
Group: FolderResource.Group,
|
||||
Resource: FolderResource.Resource,
|
||||
Namespace: id.GetNamespace(),
|
||||
Name: folderToDelete.ID,
|
||||
Verb: utils.VerbDelete,
|
||||
}, parentFolderUID)
|
||||
|
||||
if err != nil || !rsp.Allowed {
|
||||
return apierrors.NewForbidden(FolderResource.GroupResource(), folderToDelete.ID,
|
||||
fmt.Errorf("no permission to delete folder %s", folderToDelete.ID))
|
||||
}
|
||||
|
||||
// Simple role based access for now
|
||||
if id.GetOrgRole().Includes(identity.RoleEditor) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return apierrors.NewForbidden(FolderResource.GroupResource(), folderToDelete.ID,
|
||||
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
|
||||
fmt.Errorf("must be admin or editor to access folders with provisioning"))
|
||||
}
|
||||
|
||||
|
||||
@@ -224,4 +224,46 @@ func testCheck(t *testing.T, server *Server) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:19 should be able to view folder 4 and all subfolders", func(t *testing.T) {
|
||||
res, err := server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbGet, folderGroup, folderResource, "", "", "4"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbGet, folderGroup, folderResource, "", "", "5"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbGet, folderGroup, folderResource, "", "", "6"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:19 should be able to edit folder 4 and all subfolders", func(t *testing.T) {
|
||||
res, err := server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbDelete, folderGroup, folderResource, "", "", "4"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbDelete, folderGroup, folderResource, "", "", "5"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbDelete, folderGroup, folderResource, "", "", "6"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
})
|
||||
|
||||
t.Run("user:20 should be able to view folder 4 and all subfolders", func(t *testing.T) {
|
||||
res, err := server.Check(newContextWithNamespace(), newReq("user:20", utils.VerbGet, folderGroup, folderResource, "", "", "4"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
res, err = server.Check(newContextWithNamespace(), newReq("user:20", utils.VerbGet, folderGroup, folderResource, "", "", "5"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
|
||||
res, err = server.Check(newContextWithNamespace(), newReq("user:20", utils.VerbGet, folderGroup, folderResource, "", "", "6"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.GetAllowed())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ func setup(t *testing.T, srv *Server) *Server {
|
||||
common.NewFolderTuple("user:17", common.RelationSetView, "4"),
|
||||
common.NewFolderTuple("user:18", common.RelationCreate, "general"),
|
||||
common.NewFolderResourceTuple("user:18", common.RelationCreate, dashboardGroup, dashboardResource, "", "general"),
|
||||
common.NewFolderTuple("user:19", common.RelationSetAdmin, "4"),
|
||||
common.NewFolderTuple("user:20", common.RelationSetEdit, "4"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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