Compare commits

...

2 Commits

Author SHA1 Message Date
SagarRajput-7
b60e255475 chore(pagination): upgrade pagination import to use from signozhq 2026-06-05 19:24:51 +05:30
Ashwin Bhatkal
9c9016d49e feat(dashboards): V2 dashboard — sections, drag-and-drop & panel management (#11544)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboard-v2): session store — edit-context + persisted collapse

* feat(dashboard-v2): patch-op builders (RFC-6902) for layouts & panels

* feat(dashboard-v2): editable dashboard shell + within-section panel geometry

* feat(dashboard-v2): section layout — reorder & collapse

* feat(dashboard-v2): section lifecycle — add, rename, delete, migrate

* feat(dashboard-v2): panel management — add, delete, move
2026-06-05 06:33:48 +00:00
37 changed files with 1942 additions and 54 deletions

View File

@@ -5,7 +5,8 @@ import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { Skeleton } from 'antd';
import { Pagination } from '@signozhq/ui/pagination';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListServiceAccountsQueryKey,
@@ -529,25 +530,25 @@ function ServiceAccountDrawer({
const footer = (
<div className="sa-drawer__footer">
{activeTab === ServiceAccountDrawerTab.Keys ? (
<Pagination
current={keysPage}
pageSize={PAGE_SIZE}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<div className="sa-drawer__keys-pagination">
{keys.length > 0 && (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
{(keysPage - 1) * PAGE_SIZE + 1} &#8212;{' '}
{Math.min(keysPage * PAGE_SIZE, keys.length)}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
<span className="sa-drawer__pagination-total"> of {keys.length}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => {
void setKeysPage(page);
}}
className="sa-drawer__keys-pagination"
/>
<Pagination
current={keysPage}
pageSize={PAGE_SIZE}
total={keys.length}
onPageChange={(page): void => {
void setKeysPage(page);
}}
/>
</div>
) : (
<>
{!isDeleted && (

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Pagination, Skeleton } from 'antd';
import { Skeleton } from 'antd';
import { Pagination } from '@signozhq/ui/pagination';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
@@ -153,15 +154,6 @@ function RolesListingTable({
return result;
}, [displayList, currentPage]);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
{range[0]} &#8212; {range[1]}
</span>
<span className="total"> of {total}</span>
</>
);
if (!hasListPermission && listPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:list" />;
}
@@ -280,16 +272,23 @@ function RolesListingTable({
</div>
</div>
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
showTotal={showPaginationItem}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className="roles-table-pagination"
/>
<div className="roles-table-pagination">
{totalRoleCount > 0 && (
<>
<span className="numbers">
{(currentPage - 1) * PAGE_SIZE + 1} &#8212;{' '}
{Math.min(currentPage * PAGE_SIZE, totalRoleCount)}
</span>
<span className="total"> of {totalRoleCount}</span>
</>
)}
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
onPageChange={(page): void => setCurrentPage(page)}
/>
</div>
</div>
);
}

View File

@@ -6,6 +6,10 @@ import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface Props {
@@ -17,9 +21,22 @@ interface Props {
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
function Panel({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
@@ -48,7 +65,17 @@ function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
<EllipsisVertical size={14} />
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>

View File

@@ -0,0 +1,16 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
interface Props {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default PanelActionsMenu;

View File

@@ -0,0 +1,22 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--bg-robin-500);
}
}

View File

@@ -0,0 +1,82 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
}
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
key={type.pluginKind}
type="button"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
))}
</div>
</Modal>
);
}
export default PanelTypeSelectionModal;

View File

@@ -0,0 +1,76 @@
import { useCallback } from 'react';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addPanelToSectionOps,
createDefaultPanel,
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface AddPanelArgs {
layoutIndex: number;
pluginKind: string;
}
/**
* Creates a new panel and places its item ref at the bottom of the target
* section, as one atomic patch. Structure-only: the panel is a valid minimal
* placeholder (its query is filled in once the panel editor lands).
*/
export function useAddPanelToSection({
sections,
}: Params): (args: AddPanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;
}
const panelId = uuid();
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
try {
await patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId,
panel: createDefaultPanel(pluginKind),
layoutIndex,
item: {
x: 0,
y: nextY,
width: 6,
height: 6,
content: { $ref: panelRef(panelId) },
},
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -0,0 +1,54 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface DeletePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Removes a panel: drops its item ref from the section's items and deletes the
* panel from `spec.panels`, as one atomic patch.
*/
export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const section = sections.find((s) => s.layoutIndex === layoutIndex);
if (!section) {
return;
}
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -0,0 +1,79 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
export interface MovePanelArgs {
panelId: string;
fromLayoutIndex: number;
toLayoutIndex: number;
}
interface Params {
sections: DashboardSection[];
}
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({
panelId,
fromLayoutIndex,
toLayoutIndex,
}: MovePanelArgs): Promise<void> => {
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
return;
}
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
if (!source || !target) {
return;
}
const moved = source.items.find((i) => i.id === panelId);
if (!moved) {
return;
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
targetIndex: toLayoutIndex,
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -0,0 +1,17 @@
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
}
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../../utils';
import { useAddSection } from '../hooks/useAddSection';
import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration';
import FirstSectionMigrationModal from '../FirstSectionMigrationModal';
import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
isSectioned: boolean;
}
function AddSectionControl({
sections,
layouts,
isSectioned,
}: Props): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts });
const { migrate, isSaving } = useFirstSectionMigration({ sections });
// Free-flowing dashboard with existing panels → must migrate before sections
// can coexist (every panel must belong to a section once any exists).
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
const handleConfirmMigration = async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
return (
<>
<button
type="button"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}
onClose={(): void => setIsMigrationOpen(false)}
onConfirm={handleConfirmMigration}
/>
</>
);
}
export default AddSectionControl;

View File

@@ -0,0 +1,41 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
open: boolean;
isSaving: boolean;
onClose: () => void;
onConfirm: () => void;
}
/**
* Shown when the user adds the first section to a free-flowing dashboard that
* already has panels. Confirms grouping the existing panels into a section
* before proceeding.
*/
function FirstSectionMigrationModal({
open,
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Group panels into sections?"
onCancel={onClose}
onOk={onConfirm}
okText="Continue"
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
destroyOnClose
>
<Typography.Text>
This dashboard&apos;s panels are currently free-flowing. Adding a section
will move the existing panels into their own section, and a new empty
section will be added below. You can rename sections afterwards.
</Typography.Text>
</Modal>
);
}
export default FirstSectionMigrationModal;

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
open: boolean;
initialValue: string;
isSaving: boolean;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
open,
initialValue,
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
useEffect(() => {
if (open) {
setValue(initialValue);
}
}, [open, initialValue]);
const submit = (): void => {
const trimmed = value.trim();
if (trimmed) {
onSubmit(trimmed);
}
};
return (
<Modal
open={open}
title="Rename section"
onCancel={onClose}
onOk={submit}
okText="Rename"
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</Modal>
);
}
export default RenameSectionModal;

View File

@@ -1,17 +1,45 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import RenameSectionModal from '../RenameSectionModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader from '../SectionHeader/SectionHeader';
import SectionHeader, {
type SectionDragHandle,
} from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
function Section({ section }: Props): JSX.Element {
function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
@@ -19,10 +47,48 @@ function Section({ section }: Props): JSX.Element {
rootMargin: '200px',
});
const [open, setOpen] = useState<boolean>(section.open);
const toggle = (): void => setOpen((prev) => !prev);
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
const grid = <SectionGrid items={section.items} isVisible={isVisible} />;
const [isRenaming, setIsRenaming] = useState(false);
const { rename, isSaving } = useRenameSection({
layoutIndex: section.layoutIndex,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const { deleteSection } = useDeleteSection({ section });
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
@@ -51,8 +117,26 @@ function Section({ section }: Props): JSX.Element {
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}
onSubmit={handleRenameSubmit}
/>
<PanelTypeSelectionModal
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -0,0 +1,68 @@
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import styles from './SectionActionsMenu.module.scss';
interface Props {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
}
function SectionActionsMenu({
sectionId,
onAddPanel,
onRename,
onDeleteSection,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: 'Add panel',
onClick: onAddPanel,
});
}
if (onRename) {
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: 'Rename section',
onClick: onRename,
});
}
if (onDeleteSection) {
result.push(
{ type: 'divider' },
{
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete section',
onClick: onDeleteSection,
},
);
}
return result;
}, [onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Section actions"
data-testid={`dashboard-section-actions-${sectionId}`}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default SectionActionsMenu;

View File

@@ -0,0 +1,7 @@
.preview {
border: 1px solid var(--bg-robin-500);
border-radius: 4px;
background: var(--bg-ink-400, #0b0c0e);
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
cursor: grabbing;
}

View File

@@ -0,0 +1,32 @@
import type { DashboardSection } from '../../../utils';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './SectionDragPreview.module.scss';
interface Props {
section: DashboardSection;
}
/**
* Lightweight preview rendered inside the DragOverlay while a section is being
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
* cheap and never triggers RGL width re-measurement.
*/
function SectionDragPreview({ section }: Props): JSX.Element {
const panelCount = section.items.length;
const title = `${section.title ?? ''} · ${panelCount} ${
panelCount === 1 ? 'panel' : 'panels'
}`;
return (
<div className={styles.preview}>
<SectionHeader
sectionId={`${section.id}-preview`}
title={title}
open={false}
onToggle={(): void => undefined}
/>
</div>
);
}
export default SectionDragPreview;

View File

@@ -2,18 +2,35 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({ items, isVisible }: Props): JSX.Element {
function SectionGrid({
items,
layoutIndex,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
@@ -26,6 +43,8 @@ function SectionGrid({ items, isVisible }: Props): JSX.Element {
[items],
);
const { handleLayoutChange } = usePersistLayout({ layoutIndex, items });
return (
<ResponsiveGridLayout
className={styles.grid}
@@ -34,13 +53,24 @@ function SectionGrid({ items, isVisible }: Props): JSX.Element {
autoSize
useCSSTransforms
layout={rglLayout}
isDraggable={false}
isResizable={false}
draggableHandle=".panel-drag-handle"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<Panel panel={item.panel} panelId={item.id} isVisible={isVisible} />
<Panel
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
currentLayoutIndex={layoutIndex}
sections={isEditable ? sections : undefined}
onMovePanel={isEditable ? onMovePanel : undefined}
onDeletePanel={isEditable ? onDeletePanel : undefined}
/>
</div>
))}
</ResponsiveGridLayout>

View File

@@ -1,15 +1,29 @@
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
import styles from './SectionHeader.module.scss';
export interface SectionDragHandle {
attributes: DraggableAttributes;
listeners: SyntheticListenerMap | undefined;
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
interface Props {
sectionId: string;
title: string;
open: boolean;
onToggle: () => void;
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
onRename?: () => void;
onAddPanel?: () => void;
onDeleteSection?: () => void;
}
function SectionHeader({
@@ -18,9 +32,27 @@ function SectionHeader({
open,
onToggle,
repeatVariable,
dragHandle,
onRename,
onAddPanel,
onDeleteSection,
}: Props): JSX.Element {
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
{dragHandle ? (
<button
type="button"
className={styles.dragHandle}
ref={dragHandle.setActivatorNodeRef}
aria-label="Drag to reorder section"
data-testid={`dashboard-section-drag-${sectionId}`}
{...dragHandle.attributes}
{...dragHandle.listeners}
>
<GripVertical size={14} />
</button>
) : null}
<button
type="button"
className={styles.toggle}
@@ -35,6 +67,14 @@ function SectionHeader({
</Typography.Text>
) : null}
</button>
{hasActions ? (
<SectionActionsMenu
sectionId={sectionId}
onAddPanel={onAddPanel}
onRename={onRename}
onDeleteSection={onDeleteSection}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
function SectionList({ sections, layouts }: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
} = useSectionDragReorder({ sections, layouts });
const onAddPanel = useAddPanelToSection({ sections });
const onMovePanel = useMovePanelToSection({ sections });
const onDeletePanel = useDeletePanel({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
const sortableIds = useMemo(
() => orderedSections.filter((s) => s.title).map((s) => s.id),
[orderedSections],
);
if (!isEditable) {
return (
<>
{sections.map((section) => (
<Section key={section.id} section={section} />
))}
</>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<Section
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
),
)}
</SortableContext>
{/* dropAnimation disabled: optimistic reorder already places the section,
so animating the overlay back would cause a visible snap/shake. */}
<DragOverlay dropAnimation={null}>
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
</DragOverlay>
</DndContext>
);
}
export default SectionList;

View File

@@ -0,0 +1,59 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DashboardSection } from '../../utils';
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface Props {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
function SortableSection({
section,
sections,
onAddPanel,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
// dnd-kit drives the drag transform per-frame, so this must be an inline
// style — there is no static-stylesheet equivalent for a live transform.
// While dragging, the original is hidden (the DragOverlay renders the moving
// preview); keeping it in place preserves the gap and lets siblings animate.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<Section
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>
);
}
export default SortableSection;

View File

@@ -0,0 +1,59 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addSectionOp,
newGridLayout,
reorderLayoutsOp,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
interface Result {
addSection: (title: string) => Promise<void>;
isSaving: boolean;
}
/**
* Appends an empty titled section. When the dashboard has no layouts yet, the
* layouts array is created via a `replace` (an `add` to a missing/empty array
* pointer is unreliable); otherwise a new Grid is appended.
*/
export function useAddSection({ layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const addSection = useCallback(
async (title: string): Promise<void> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return;
}
const op =
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[layouts, dashboardId, refetch, showErrorModal],
);
return { addSection, isSaving };
}

View File

@@ -0,0 +1,51 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, removeSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
section: DashboardSection;
}
interface Result {
deleteSection: () => Promise<void>;
isSaving: boolean;
}
/**
* Deletes a section: removes its Grid layout and deletes every panel it
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
*/
export function useDeleteSection({ section }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const deleteSection = useCallback(async (): Promise<void> => {
if (!dashboardId) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
removePanelOp(i.id),
);
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, refetch, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
interface Result {
migrate: (newSectionTitle: string) => Promise<void>;
isSaving: boolean;
}
/**
* Converts a free-flowing dashboard into a sectioned one: every existing
* untitled layout that holds panels is titled in place ("Section 1", "Section
* 2", …), then the brand-new section the user asked for is appended — all in one
* atomic patch. Used once the user confirms the migration prompt.
*/
export function useFirstSectionMigration({ sections }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const migrate = useCallback(
async (newSectionTitle: string): Promise<void> => {
const trimmed = newSectionTitle.trim();
if (!dashboardId || !trimmed) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
let counter = 1;
sections.forEach((s) => {
if (!s.title && s.items.length > 0) {
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
counter += 1;
}
});
ops.push(addSectionOp(trimmed));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
return { migrate, isSaving };
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
interface Params {
layoutIndex: number;
items: GridItem[];
}
interface Result {
handleLayoutChange: (rglLayout: Layout[]) => void;
isSaving: boolean;
}
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
function mergeRglLayout(rglLayout: Layout[], items: GridItem[]): GridItem[] {
const byId = new Map(items.map((item) => [item.id, item]));
return rglLayout
.map((entry) => {
const existing = byId.get(entry.i);
if (!existing) {
return null;
}
return {
...existing,
x: entry.x,
y: entry.y,
width: entry.w,
height: entry.h,
};
})
.filter((item): item is GridItem => item !== null);
}
function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
if (next.length !== prev.length) {
return true;
}
const prevById = new Map(prev.map((item) => [item.id, item]));
return next.some((item) => {
const before = prevById.get(item.id);
if (!before) {
return true;
}
return (
before.x !== item.x ||
before.y !== item.y ||
before.width !== item.width ||
before.height !== item.height
);
});
}
/**
* Persists panel geometry within a single section. Call the returned handler
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
* `onLayoutChange`) to limit network churn.
*/
export function usePersistLayout({ layoutIndex, items }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const handleLayoutChange = useCallback(
async (rglLayout: Layout[]): Promise<void> => {
if (!dashboardId) {
return;
}
const nextItems = mergeRglLayout(rglLayout, items);
if (!hasGeometryChanged(nextItems, items)) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, refetch, showErrorModal],
);
return { handleLayoutChange, isSaving };
}

View File

@@ -0,0 +1,50 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { renameSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
interface Params {
layoutIndex: number;
}
interface Result {
rename: (title: string) => Promise<boolean>;
isSaving: boolean;
}
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({ layoutIndex }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const rename = useCallback(
async (title: string): Promise<boolean> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return false;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, refetch, showErrorModal],
);
return { rename, isSaving };
}

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type DragEndEvent,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { reorderLayoutsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
interface Result {
sensors: ReturnType<typeof useSensors>;
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
orderedSections: DashboardSection[];
/** The section currently being dragged (for the DragOverlay preview), or null. */
activeSection: DashboardSection | null;
onDragStart: (event: DragStartEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: () => void;
}
/**
* Owns section-reorder drag state. Reorders happen optimistically in local
* state (keyed by stable section id) and persist via a single
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
* so the new layouts array is rebuilt by mapping the reordered sections back.
*/
export function useSectionDragReorder({ sections, layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Server data is the source of truth — drop optimistic order whenever it changes.
useEffect(() => {
setLocalOrderIds(null);
}, [sections]);
const orderedSections = useMemo<DashboardSection[]>(() => {
if (!localOrderIds) {
return sections;
}
const byId = new Map(sections.map((s) => [s.id, s]));
const ordered = localOrderIds
.map((id) => byId.get(id))
.filter((s): s is DashboardSection => s !== undefined);
return ordered.length === sections.length ? ordered : sections;
}, [sections, localOrderIds]);
const onDragStart = useCallback((event: DragStartEvent): void => {
setActiveId(String(event.active.id));
}, []);
const onDragCancel = useCallback((): void => {
setActiveId(null);
}, []);
const onDragEnd = useCallback(
async (event: DragEndEvent): Promise<void> => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id || !dashboardId || !layouts) {
return;
}
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
setLocalOrderIds(newOrdered.map((s) => s.id));
const newLayouts = newOrdered
.map((s) => layouts[s.layoutIndex])
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
);
const activeSection = useMemo(
() => orderedSections.find((s) => s.id === activeId) ?? null,
[orderedSections, activeId],
);
return {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
};
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import {
selectIsSectionOpen,
useDashboardStore,
} from '../../../store/useDashboardStore';
interface Params {
sectionId: string;
}
interface Result {
open: boolean;
toggle: () => void;
}
/**
* Owns a section's expand/collapse state. Collapse is a frontend-only, per-user
* preference (not in the dashboard spec): it lives in the persisted zustand
* store, keyed by dashboardId + section id, and survives reloads. Default open.
*/
export function useToggleSectionCollapse({ sectionId }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const open = useDashboardStore(selectIsSectionOpen(dashboardId, sectionId));
const toggleSectionCollapse = useDashboardStore(
(s) => s.toggleSectionCollapse,
);
const toggle = useCallback((): void => {
if (dashboardId) {
toggleSectionCollapse(dashboardId, sectionId);
}
}, [dashboardId, sectionId, toggleSectionCollapse]);
return { open, toggle };
}

View File

@@ -7,8 +7,11 @@ import type {
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
@@ -20,6 +23,8 @@ interface Props {
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
@@ -28,6 +33,11 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
// Sectioned mode = at least one titled layout. Sections then become a
// reorderable list; otherwise the dashboard is a single free-flowing grid
// with no section chrome or reordering.
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
@@ -42,12 +52,27 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
);
}
if (isSectioned) {
return <SectionList sections={sections} layouts={layouts} />;
}
return sections.map((section) => (
<Section key={section.id} section={section} />
));
};
return <div className={styles.body}>{renderContent()}</div>;
return (
<div className={styles.body}>
{renderContent()}
{isEditable ? (
<AddSectionControl
sections={sections}
layouts={layouts}
isSectioned={isSectioned}
/>
) : null}
</div>
);
}
export default PanelsAndSectionsLayout;

View File

@@ -1,10 +1,13 @@
import { useMemo } from 'react';
import { useEffect, useMemo } 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 DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
interface Props {
@@ -15,6 +18,17 @@ interface Props {
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const isEditable = !dashboard.locked && editDashboard;
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
}, [dashboard.id, isEditable, refetch, setEditContext]);
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);

View File

@@ -0,0 +1,177 @@
import type {
DashboardGridItemDTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
/**
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';
export function panelRef(panelId: string): string {
return `${PANEL_REF_PREFIX}${panelId}`;
}
/**
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
* requires exactly one query whose plugin kind is allowed for the panel;
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
* validated, so an empty builder query is the safe default. The real query is
* filled in once the panel editor lands.
*/
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
// The DTO types plugin/query kinds as large generated enum unions; the kind
// here is chosen dynamically by the user, so we build the structurally-valid
// shape and assert the type.
return {
kind: 'Panel',
spec: {
display: { name: 'New panel' },
plugin: { kind: pluginKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
export function gridItemToDTO(item: GridItem): DashboardGridItemDTO {
return {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
content: { $ref: panelRef(item.id) },
};
}
/** Replace the entire items array of one section (used on panel move/resize). */
export function replaceSectionItemsOp(
layoutIndex: number,
items: GridItem[],
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/items`,
value: items.map(gridItemToDTO),
};
}
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
export function reorderLayoutsOp(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesJSONPatchOperationDTO {
return { op: replace, path: '/spec/layouts', value: layouts };
}
/** An empty titled Grid layout (one section). */
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
return {
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: { display: { title }, items: [] },
};
}
/** Append a new, empty titled Grid section. */
export function addSectionOp(
title: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
}
interface AddPanelToSectionArgs {
panelId: string;
panel: DashboardtypesPanelDTO;
layoutIndex: number;
item: DashboardGridItemDTO;
}
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
export function addPanelToSectionOps({
panelId,
panel,
layoutIndex,
item,
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
];
}
interface MovePanelArgs {
sourceIndex: number;
sourceItems: GridItem[];
targetIndex: number;
targetItems: GridItem[];
}
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
export function movePanelBetweenSectionsOps({
sourceIndex,
sourceItems,
targetIndex,
targetItems,
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
replaceSectionItemsOp(sourceIndex, sourceItems),
replaceSectionItemsOp(targetIndex, targetItems),
];
}
/** Rename an existing section's title. */
export function renameSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
value: title,
};
}
/**
* First-section migration: give an existing untitled (free-flowing) layout a
* title, turning it into a section in place while preserving its panels.
*/
export function titleUntitledSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: add,
path: `/spec/layouts/${layoutIndex}/spec/display`,
value: { title },
};
}
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
export function removeSectionOp(
layoutIndex: number,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
}
/** Remove a panel definition from `spec.panels`. */
export function removePanelOp(
panelId: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/panels/${panelId}` };
}

View File

@@ -0,0 +1,35 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* Section collapse state — frontend-only and persisted to localStorage. Keyed by
* dashboardId → section stable id → open. An absent entry means "open" (the
* default). This is intentionally NOT server state: collapse is a per-user UI
* preference, so it lives here instead of in the dashboard spec.
*/
export interface CollapseSlice {
collapsed: Record<string, Record<string, boolean>>;
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
}
export const createCollapseSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
CollapseSlice
> = (set, get) => ({
collapsed: {},
toggleSectionCollapse: (dashboardId, sectionId): void => {
const { collapsed } = get();
const current = collapsed[dashboardId]?.[sectionId];
// Absent → open by default, so the first toggle closes it.
const next = current === undefined ? false : !current;
set({
collapsed: {
...collapsed,
[dashboardId]: { ...collapsed[dashboardId], [sectionId]: next },
},
});
},
});

View File

@@ -0,0 +1,38 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* 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
* so hooks/components read it from the store instead of receiving it as props
* through every layer. Not persisted.
*/
export interface EditContextSlice {
dashboardId: string;
isEditable: boolean;
refetch: () => void;
setEditContext: (ctx: {
dashboardId: string;
isEditable: boolean;
refetch: () => void;
}) => void;
}
export const createEditContextSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
EditContextSlice
> = (set) => ({
dashboardId: '',
isEditable: false,
refetch: (): void => undefined,
setEditContext: (ctx): void => {
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,
refetch: ctx.refetch,
});
},
});

View File

@@ -0,0 +1,44 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
createEditContextSlice,
type EditContextSlice,
} from './slices/editContextSlice';
import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
},
),
);
/** Selector: is a section open? Absent entry (or no dashboard) → open by default. */
export const selectIsSectionOpen =
(dashboardId: string, sectionId: string) =>
(state: DashboardStore): boolean => {
if (!dashboardId) {
return true;
}
const value = state.collapsed[dashboardId]?.[sectionId];
return value === undefined ? true : value;
};

View File

@@ -72,7 +72,6 @@ export interface DashboardSection {
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
layoutIndex: number;
title: string | undefined;
open: boolean;
items: GridItem[];
repeatVariable: string | undefined;
}
@@ -127,15 +126,11 @@ export function layoutsToSections(
.filter((it): it is GridItem => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: getSectionStableId(items, idx),
layoutIndex: idx,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};