mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
5 Commits
feat/dashb
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7136a51fdc | ||
|
|
3b1c1c401f | ||
|
|
58aec92161 | ||
|
|
4e1c77cab1 | ||
|
|
14694f55e1 |
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -159,6 +159,8 @@ function makePanel(
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
isEditable: true,
|
||||
editDisabledReason: '',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 don’t 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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user