Compare commits

...

5 Commits

22 changed files with 516 additions and 171 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { generatePath } from 'react-router-dom';
import {
@@ -17,6 +17,7 @@ import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
@@ -29,6 +30,7 @@ import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
@@ -58,7 +60,8 @@ function DashboardActions({
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const isEditable = useDashboardStore((s) => s.isEditable);
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
@@ -111,23 +114,37 @@ function DashboardActions({
});
}, [deleteDashboardMutation]);
// Edit-action label: plain text when editable, else a disabled row whose label
// still shows a hover tooltip explaining why (locked / no permission).
const editLabel = useCallback(
(text: string): ReactNode =>
isEditable ? (
text
) : (
<DisabledMenuItemLabel reason={editDisabledReason}>
{text}
</DisabledMenuItemLabel>
),
[isEditable, editDisabledReason],
);
const menuItems = useMemo<MenuItem[]>(() => {
const dashboardGroup: MenuItem[] = [];
if (canEdit) {
dashboardGroup.push({
const dashboardGroup: MenuItem[] = [
{
key: 'rename',
label: 'Rename',
label: editLabel('Rename'),
icon: <PenLine size={14} />,
disabled: !isEditable,
onClick: onOpenRename,
});
}
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
},
{
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
},
];
if (isAuthor || user.role === USER_ROLES.ADMIN) {
dashboardGroup.push({
key: 'lock',
@@ -144,45 +161,40 @@ function DashboardActions({
onClick: handle.enter,
});
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
key: 'new-section',
label: 'New section',
icon: <SquareStack size={14} />,
onClick: (): void => setIsNewSectionOpen(true),
});
}
const items: MenuItem[] = [
return [
{
type: 'group',
key: 'group-dashboard',
label: 'Dashboard',
children: dashboardGroup,
},
];
if (layoutGroup.length > 0) {
items.push({
{
type: 'group',
key: 'group-layout',
label: 'Layout',
children: layoutGroup,
});
}
items.push(
children: [
{
key: 'new-section',
label: editLabel('New section'),
icon: <SquareStack size={14} />,
disabled: !isEditable,
onClick: (): void => setIsNewSectionOpen(true),
},
],
},
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: 'Delete dashboard',
label: editLabel('Delete dashboard'),
icon: <Trash2 size={14} />,
danger: true,
disabled: !isEditable,
onClick: (): void => setIsDeleteOpen(true),
},
);
return items;
];
}, [
canEdit,
editLabel,
isEditable,
isCloning,
isAuthor,
user.role,
@@ -194,6 +206,16 @@ function DashboardActions({
handle.enter,
]);
// A disabled edit control stays visible with a tooltip explaining why.
const withDisabledTooltip = (node: JSX.Element): JSX.Element =>
isEditable ? (
node
) : (
<TooltipSimple title={editDisabledReason} disableHoverableContent>
{node}
</TooltipSimple>
);
return (
<div className={styles.dashboardActionsContainer}>
<DropdownMenuSimple menu={{ items: menuItems }}>
@@ -207,27 +229,26 @@ function DashboardActions({
Actions
</Button>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
{withDisabledTooltip(
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>,
)}
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
<Button
variant="solid"
color="secondary"
@@ -238,17 +259,18 @@ function DashboardActions({
>
JSON
</Button>
{!isDashboardLocked && (
{withDisabledTooltip(
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
New Panel
</Button>
</Button>,
)}
<JsonEditorDrawer
dashboard={dashboard}

View File

@@ -14,6 +14,7 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
import { useDashboardStore } from '../../store/useDashboardStore';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -28,6 +29,12 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const isEditable = useDashboardStore((s) => s.isEditable);
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
// Locked/no-permission dashboards open the JSON for inspection only — Apply,
// Format and Reset are disabled and edits can't be saved.
const readOnly = !isEditable;
const {
draft,
setDraft,
@@ -39,7 +46,7 @@ function JsonEditorDrawer({
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
} = useJsonEditor({ dashboard, isOpen, readOnly, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -63,13 +70,15 @@ function JsonEditorDrawer({
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
if (!readOnly) {
void apply();
}
}
},
[apply],
[apply, readOnly],
);
const applyDisabled = !isDirty || !validity.valid || isSaving;
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
@@ -150,16 +159,30 @@ function JsonEditorDrawer({
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
{readOnly ? (
<TooltipSimple title={readOnlyReason} disableHoverableContent>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled
>
Apply changes
</Button>
</TooltipSimple>
) : (
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
)}
</div>
</div>
}
@@ -168,6 +191,7 @@ function JsonEditorDrawer({
<div className={styles.body} onKeyDown={onKeyDown}>
<JsonEditorToolbar
isDirty={isDirty}
readOnly={readOnly}
onFormat={format}
onCopy={onCopy}
onDownload={onDownload}
@@ -180,6 +204,7 @@ function JsonEditorDrawer({
value={draft}
onChange={(value): void => setDraft(value ?? '')}
options={{
readOnly,
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 13,

View File

@@ -5,6 +5,8 @@ import styles from './JsonEditorToolbar.module.scss';
interface JsonEditorToolbarProps {
isDirty: boolean;
/** Locked/no-permission — Format and Reset (draft mutators) are disabled. */
readOnly?: boolean;
onFormat: () => void;
onCopy: () => void;
onDownload: () => void;
@@ -13,6 +15,7 @@ interface JsonEditorToolbarProps {
function JsonEditorToolbar({
isDirty,
readOnly = false,
onFormat,
onCopy,
onDownload,
@@ -26,6 +29,7 @@ function JsonEditorToolbar({
size="sm"
prefix={<AlignLeft size={14} />}
testId="json-editor-format"
disabled={readOnly}
onClick={onFormat}
>
Format
@@ -57,7 +61,7 @@ function JsonEditorToolbar({
size="sm"
prefix={<RotateCcw size={14} />}
testId="json-editor-reset"
disabled={!isDirty}
disabled={readOnly || !isDirty}
onClick={onReset}
>
Reset

View File

@@ -7,6 +7,13 @@ import { useJsonEditor } from '../useJsonEditor';
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
// Editable by default so the drawer renders in its editable (non-read-only) mode.
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
}));
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({

View File

@@ -23,6 +23,8 @@ export interface JsonValidity {
interface Params {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
/** Locked/no-permission — `apply` is a no-op so edits can never be saved. */
readOnly?: boolean;
onApplied: () => void;
}
@@ -77,6 +79,7 @@ function errorLineFromMessage(
export function useJsonEditor({
dashboard,
isOpen,
readOnly = false,
onApplied,
}: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
@@ -147,7 +150,7 @@ export function useJsonEditor({
}, [appliedText]);
const apply = useCallback(async (): Promise<void> => {
if (!validity.valid || !isDirty) {
if (readOnly || !validity.valid || !isDirty) {
return;
}
try {
@@ -173,6 +176,7 @@ export function useJsonEditor({
validity.valid,
isDirty,
draft,
readOnly,
refetch,
onApplied,
showErrorModal,

View File

@@ -1,14 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
getGetDashboardV2QueryKey,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
@@ -32,13 +35,13 @@ import styles from './DashboardPageToolbar.module.scss';
interface DashboardPageToolbarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const { dashboard, handle } = props;
const id = dashboard.id;
const queryClient = useQueryClient();
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
@@ -101,12 +104,21 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
refetch();
// Patch just the `locked` flag in the cache — a full refetch would reload
// every panel's chart data for a metadata-only change.
const key = getGetDashboardV2QueryKey({ id });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
});
}
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, refetch, showErrorModal]);
}, [id, isDashboardLocked, queryClient, showErrorModal]);
const onNameSave = useCallback(
async (next: string): Promise<void> => {

View File

@@ -3,6 +3,7 @@ import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
@@ -12,6 +13,9 @@ interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
/** Locked/no-permission dashboard — Save is disabled with a reason. */
readOnly?: boolean;
readOnlyReason?: string;
onSave: () => void;
onSwitchToView?: () => void;
onClose: () => void;
@@ -21,6 +25,8 @@ function Header({
isDirty,
isSaving,
showSwitchToView = false,
readOnly = false,
readOnlyReason,
onSave,
onSwitchToView,
onClose,
@@ -63,16 +69,29 @@ function Header({
Switch to View Mode
</Button>
)}
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
{readOnly ? (
<TooltipSimple title={readOnlyReason} disableHoverableContent>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled
>
Save changes
</Button>
</TooltipSimple>
) : (
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
)}
</div>
<DialogWrapper

View File

@@ -159,6 +159,8 @@ function makePanel(
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
isEditable: true,
editDisabledReason: '',
onClose: jest.fn(),
onSaved: jest.fn(),
};

View File

@@ -43,6 +43,10 @@ interface PanelEditorContainerProps {
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -60,6 +64,8 @@ function PanelEditorContainer({
panel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
@@ -198,6 +204,9 @@ function PanelEditorContainer({
});
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
await save(buildSaveSpec(draft.spec));
@@ -206,7 +215,7 @@ function PanelEditorContainer({
} catch {
toast.error('Failed to save panel');
}
}, [save, buildSaveSpec, draft.spec, onSaved]);
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
@@ -214,6 +223,8 @@ function PanelEditorContainer({
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onClose={onClose}

View File

@@ -4,9 +4,19 @@ import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/qu
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { DASHBOARD_LOCKED_REASON } from '../../../../store/slices/editContextSlice';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
function disabledKeys(
result: ReturnType<typeof usePanelActionItems>,
): unknown[] {
return result.items
.filter((item) => 'disabled' in item && item.disabled)
.map((item) => ('key' in item ? item.key : undefined));
}
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
@@ -115,7 +125,7 @@ describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ isEditable: true });
useDashboardStore.setState({ isEditable: true, editDisabledReason: '' });
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
@@ -136,11 +146,14 @@ describe('usePanelActionItems', () => {
// it's present for every renderable kind.
});
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
it('AUTHOR sees edit and clone disabled (edit_widget excludes AUTHOR) but can move and delete', () => {
mockRole = 'AUTHOR';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
// The full set is now always present — the role gate disables rather than hides.
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'download',
'create-alert',
@@ -149,31 +162,49 @@ describe('usePanelActionItems', () => {
'divider',
'delete-panel',
]);
});
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'download',
'create-alert',
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
]);
});
it('read-only dashboard keeps the non-mutating actions (View, Download, Create Alerts)', () => {
useDashboardStore.setState({ isEditable: false });
it('VIEWER sees every edit action disabled (no edit permissions)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
'move',
'delete-panel',
]);
});
it('a non-editable (locked / no-permission) dashboard disables every edit action', () => {
useDashboardStore.setState({
isEditable: false,
editDisabledReason: DASHBOARD_LOCKED_REASON,
});
// A read-only dashboard mounts panels without layout context (no panelActions).
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
// View, the Download submenu (PNG/SVG) and Create Alerts are all
// non-mutating, so they survive on a read-only dashboard (V1 parity).
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'download',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
'move',
'delete-panel',
]);
});

View File

@@ -1,5 +1,12 @@
import { useCallback, useMemo } from 'react';
import { Bell, Copy, Fullscreen, PenLine, Trash2 } from '@signozhq/icons';
import { type ReactNode, useCallback, useMemo } from 'react';
import {
Bell,
Copy,
FolderInput,
Fullscreen,
PenLine,
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -23,6 +30,8 @@ import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import { PANEL_ACTION_META } from './panelActionMeta';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import { DASHBOARD_NO_EDIT_PERMISSION_REASON } from '../../../hooks/useDashboardEditGuard';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -68,6 +77,7 @@ export function usePanelActionItems({
user.role,
);
const isEditable = useDashboardStore((s) => s.isEditable);
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -102,6 +112,23 @@ export function usePanelActionItems({
const { request: requestDelete } = deleteConfirm;
const items = useMemo<MenuItem[]>(() => {
// The reason an edit action is unavailable: dashboard not editable (locked /
// no permission) takes precedence, else the missing widget-level role
// permission. Empty string ⇒ the action is enabled.
const reasonFor = (hasRolePerm: boolean): string => {
if (!isEditable) {
return editDisabledReason;
}
return hasRolePerm ? '' : DASHBOARD_NO_EDIT_PERMISSION_REASON;
};
// Disabled rows keep a hover tooltip via DisabledMenuItemLabel (see component).
const label = (reason: string, text: string): ReactNode =>
reason ? (
<DisabledMenuItemLabel reason={reason}>{text}</DisabledMenuItemLabel>
) : (
text
);
const panelGroup: MenuItem[] = [];
if (panelCapabilities.view) {
panelGroup.push({
@@ -111,26 +138,33 @@ export function usePanelActionItems({
onClick: (): void => openView(panelId),
});
}
if (isEditable && canEditWidget && panelCapabilities.edit) {
if (panelCapabilities.edit) {
const reason = reasonFor(canEditWidget);
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
label: label(reason, 'Edit panel'),
icon: <PenLine size={14} />,
disabled: !!reason,
onClick: (): void => openPanelEditor(panelId),
});
}
// Clone needs the section context to place the copy, so — unlike Edit —
// it requires panelActions.
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
if (panelCapabilities.clone) {
// Clone needs the section context to place the copy; without it (read-only
// mount) it stays disabled.
const reason = reasonFor(canEditWidget);
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
label: label(reason, 'Clone'),
icon: <Copy size={14} />,
onClick: (): void =>
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
}),
disabled: !!reason || !panelActions,
onClick: (): void => {
if (panelActions) {
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
});
}
},
});
}
@@ -140,7 +174,7 @@ export function usePanelActionItems({
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity).
// unlike edit/clone — it isn't gated on editability (V1 parity).
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
@@ -150,28 +184,35 @@ export function usePanelActionItems({
});
}
const moveReason = reasonFor(canMove);
const moveGroup: MenuItem[] =
canMove && panelActions
!moveReason && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [];
const deleteGroup: MenuItem[] =
canDelete && panelActions
? [
: [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void => requestDelete(),
key: 'move',
label: label(moveReason, 'Move to section'),
icon: <FolderInput size={14} />,
disabled: true,
},
]
: [];
];
const deleteReason = reasonFor(canDelete);
const deleteGroup: MenuItem[] = [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: label(deleteReason, 'Delete panel'),
disabled: !!deleteReason || !panelActions,
onClick: (): void => requestDelete(),
},
];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
@@ -180,6 +221,7 @@ export function usePanelActionItems({
);
}, [
isEditable,
editDisabledReason,
canEditWidget,
canMove,
canDelete,

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
@@ -29,6 +30,7 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
const {
isPickerOpen,
openPicker,
@@ -104,22 +106,19 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
actions={
isEditable
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined
}
disabledReason={isEditable ? '' : editDisabledReason}
actions={{
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onDeleteSection: (): void => setIsDeleteOpen(true),
}}
/>
{open &&
(section.items.length > 0 ? (
grid
) : (
<div className={styles.emptySection}>
{isEditable && (
{isEditable ? (
<Button
type="button"
variant="dashed"
@@ -130,6 +129,19 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
>
New Panel
</Button>
) : (
<TooltipSimple title={editDisabledReason} disableHoverableContent>
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
disabled
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</TooltipSimple>
)}
</div>
))}

View File

@@ -1,13 +1,16 @@
import { useMemo } from 'react';
import { type ReactNode, useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import styles from './SectionActionsMenu.module.scss';
interface SectionActionsMenuProps {
sectionId: string;
/** Non-empty when edits are unavailable — items render disabled with this reason. */
disabledReason?: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
@@ -15,17 +18,28 @@ interface SectionActionsMenuProps {
function SectionActionsMenu({
sectionId,
disabledReason = '',
onAddPanel,
onRename,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const disabled = !!disabledReason;
const label = (text: string): ReactNode =>
disabled ? (
<DisabledMenuItemLabel reason={disabledReason}>
{text}
</DisabledMenuItemLabel>
) : (
text
);
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: 'Add panel',
label: label('Add panel'),
disabled,
onClick: onAddPanel,
});
}
@@ -33,7 +47,8 @@ function SectionActionsMenu({
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: 'Rename section',
label: label('Rename section'),
disabled,
onClick: onRename,
});
}
@@ -44,13 +59,14 @@ function SectionActionsMenu({
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete section',
label: label('Delete section'),
disabled,
onClick: onDeleteSection,
},
);
}
return result;
}, [onAddPanel, onRename, onDeleteSection]);
}, [disabledReason, onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>

View File

@@ -29,8 +29,10 @@ interface SectionHeaderProps {
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
/** The section action handlers (always provided; disabled state gates them). */
actions?: SectionHeaderActions;
/** Non-empty when edits are unavailable — actions render disabled with this reason. */
disabledReason?: string;
}
function SectionHeader({
@@ -41,6 +43,7 @@ function SectionHeader({
repeatVariable,
dragHandle,
actions,
disabledReason = '',
}: SectionHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
@@ -79,6 +82,7 @@ function SectionHeader({
{actions ? (
<SectionActionsMenu
sectionId={sectionId}
disabledReason={disabledReason}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onDeleteSection={actions.onDeleteSection}

View File

@@ -0,0 +1,5 @@
.label {
// Re-enable pointer events so the tooltip fires while the disabled row
// (pointer-events: none) suppresses selection.
pointer-events: auto;
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DisabledMenuItemLabel.module.scss';
interface DisabledMenuItemLabelProps {
reason: string;
children: ReactNode;
}
/**
* Menu-item label that shows a hover tooltip even though the row is disabled.
* A disabled dropdown item has `pointer-events: none`, so the label re-enables
* them (`styles.label`) to become a valid hover target for the tooltip.
*/
function DisabledMenuItemLabel({
reason,
children,
}: DisabledMenuItemLabelProps): JSX.Element {
return (
<TooltipSimple title={reason} disableHoverableContent>
<span className={styles.label}>{children}</span>
</TooltipSimple>
);
}
export default DisabledMenuItemLabel;

View File

@@ -21,8 +21,8 @@ jest.mock('api/generated/services/dashboard', () => ({
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string }) => unknown) =>
selector({ dashboardId: 'dash-1' }),
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', isEditable: true }),
),
}));

View File

@@ -0,0 +1,54 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
// Re-exported from the (dependency-light) store slice so importing just the reason
// strings doesn't pull this hook's provider chain into leaf modules / unit tests.
export {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
export interface DashboardEditGuard {
/** `canEditDashboard && !isLocked` — the effective gate for performing edits. */
isEditable: boolean;
isLocked: boolean;
/** The user's role grants edit permission, regardless of the lock. */
canEditDashboard: boolean;
/** Why edits are disabled (locked vs no-permission), for tooltips; '' when editable. */
editDisabledReason: string;
}
/**
* Single source of truth for whether a V2 dashboard can be edited, plus the reason
* it can't (locked vs no permission) so controls can render disabled with a tooltip.
* Used where the store isn't seeded (the panel-editor route reached by direct URL).
*/
export function useDashboardEditGuard(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): DashboardEditGuard {
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const canEditDashboard = !!editDashboardPermission;
const isLocked = !!dashboard?.locked;
let editDisabledReason = '';
if (isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
return {
isEditable: canEditDashboard && !isLocked,
isLocked,
canEditDashboard,
editDisabledReason,
};
}

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
@@ -11,6 +12,7 @@ import type {
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/** Cached dashboard snapshot, kept for rollback on error. */
@@ -35,6 +37,7 @@ export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const storeIsEditable = useDashboardStore((s) => s.isEditable);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
@@ -69,8 +72,24 @@ export function useOptimisticPatch(
},
});
// Defense-in-depth: block edits when the store is warm for this dashboard and
// it isn't editable (locked/no-permission). Callers already hide/disable their
// controls; this guards any un-gated or future caller. When the store isn't
// seeded for this id (e.g. the panel editor reached by direct URL), fall through
// — that surface derives editability itself and gates its own save.
const { mutateAsync } = mutation;
const patchAsync = useCallback(
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
if (storeDashboardId === dashboardId && !storeIsEditable) {
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
}
return mutateAsync(ops);
},
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
);
return {
patchAsync: mutation.mutateAsync,
patchAsync,
isPatching: mutation.isLoading,
error: mutation.error ?? null,
};

View File

@@ -2,11 +2,10 @@ import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
@@ -32,18 +31,15 @@ function DashboardContainer({
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
isLocked,
canEditDashboard,
refetch,
});
@@ -55,11 +51,7 @@ function DashboardContainer({
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
</div>
</FullScreen>

View File

@@ -2,6 +2,13 @@ import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/** Tooltip reason shown on edit controls disabled because the dashboard is locked. */
export const DASHBOARD_LOCKED_REASON = 'This dashboard is locked';
/** Tooltip reason shown on edit controls disabled for want of edit permission. */
export const DASHBOARD_NO_EDIT_PERMISSION_REASON =
'You dont have permission to edit this dashboard';
/**
* Edit context shared across the V2 dashboard tree — the dashboard id, whether
* the user can edit, and the react-query refetch. Set once by DashboardContainer
@@ -10,11 +17,20 @@ import type { DashboardStore } from '../useDashboardStore';
*/
export interface EditContextSlice {
dashboardId: string;
/** `canEditDashboard && !isLocked` — the effective edit gate. */
isEditable: boolean;
/** The dashboard is locked. Distinct from `isEditable`: edit-permitted users
* still see a locked dashboard's controls (disabled), viewers never do. */
isLocked: boolean;
/** The user's role grants edit permission, regardless of the lock state. */
canEditDashboard: boolean;
/** Why edits are disabled (locked vs no-permission), for control tooltips; '' when editable. */
editDisabledReason: string;
refetch: () => void;
setEditContext: (ctx: {
dashboardId: string;
isEditable: boolean;
isLocked: boolean;
canEditDashboard: boolean;
refetch: () => void;
}) => void;
}
@@ -27,20 +43,35 @@ export const createEditContextSlice: StateCreator<
> = (set, get) => ({
dashboardId: '',
isEditable: false,
isLocked: false,
canEditDashboard: false,
editDisabledReason: '',
refetch: (): void => undefined,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const { dashboardId, isEditable, refetch } = get();
const isEditable = ctx.canEditDashboard && !ctx.isLocked;
let editDisabledReason = '';
if (ctx.isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!ctx.canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
const prev = get();
if (
dashboardId === ctx.dashboardId &&
isEditable === ctx.isEditable &&
refetch === ctx.refetch
prev.dashboardId === ctx.dashboardId &&
prev.isEditable === isEditable &&
prev.isLocked === ctx.isLocked &&
prev.canEditDashboard === ctx.canEditDashboard &&
prev.refetch === ctx.refetch
) {
return;
}
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,
isEditable,
isLocked: ctx.isLocked,
canEditDashboard: ctx.canEditDashboard,
editDisabledReason,
refetch: ctx.refetch,
});
},

View File

@@ -12,6 +12,7 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
@@ -42,6 +43,9 @@ function PanelEditorPage(): JSX.Element {
const { dashboard, isLoading, isError, error } =
useDashboardFetch(dashboardId);
// Derived here (not from the store) because the editor route doesn't mount
// DashboardContainer, so the store's edit context may be cold on a direct URL.
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up. Persisted (with a real id) only on save.
@@ -110,6 +114,8 @@ function PanelEditorPage(): JSX.Element {
panel={panel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}
editDisabledReason={editDisabledReason}
onClose={backToDashboard}
onSaved={backToDashboard}
/>