Compare commits

...

1 Commits

Author SHA1 Message Date
Ashwin Bhatkal
f60e5039be feat(dashboard-v2): toolbar repositioning, JSON editor & expandable variables bar (#11837)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboard-v2): json editor drawer to edit dashboard as raw JSON

Right-side drawer with a Monaco JSON editor (reusing the import-JSON theme),
a Format/Copy/Download/Reset toolbar, live JSON validation, and Apply via the
full-document updateDashboardV2 endpoint. Cmd/Ctrl+Enter applies; Esc closes.

* feat(dashboard-v2): grouped actions menu with clone, new section & edit-as-JSON

Regroup the actions dropdown into labelled Dashboard/Data/Layout groups, add
Clone dashboard (cloneDashboardV2 + navigate) and New section (useAddSection),
and surface an Edit as JSON button that opens the JSON editor drawer. The menu
trigger is a labelled "Actions" button with a 9-grid icon; the time selector
moves out of the actions row to the toolbar's second row.

* feat(dashboard-v2): description-on-hover and space-aware tag overflow

Collapse the dashboard description behind an info icon shown on hover, and move
tags inline after the title where they restrict to the available width and
collapse the remainder into a +N badge (reusing the alerts badge measuring).

* refactor(dashboard-v2): two-row toolbar with a floated, expandable variables bar

Second toolbar row floats the time-range selector top-right; the variables bar
flows beside it, collapsing to a single line with an inline +N trigger that hugs
the last visible pill. Expanding clears the float so the pills pack full-width
on the lines beneath the time selector (no stair-stepping). Overflow pills are
display:none but stay mounted (widths cached) so auto-selection and option
fetching keep driving the panels. Also centre the variable info icon, give the
pills a visible --l3-border (and drop the single-select's stray inner border so
it matches), and replace the toolbar's fuzzy drop shadow with a token hairline.

* feat(dashboard-v2): section title modal & scroll to the new section

New section now opens a title-entry modal instead of inserting a default-named
section, and the view scrolls the freshly created section into view once the
refetch renders it. Generalise the rename modal into a shared SectionTitleModal
reused by both create and rename.

* test(dashboard-v2): cover the JSON editor hook and drawer

useJsonEditor: seeding, live validation, format/reset, dirty tracking, apply
(no-op when clean/invalid, PUTs the narrowed body, error handling) and re-seed
on re-open. JsonEditorDrawer: toolbar/footer wiring, validation text, Apply
enablement, editor changes and Cmd/Ctrl+Enter — with Monaco and the hook mocked.

* refactor(dashboard-v2): extract generic useInlineOverflowCount hook

Address review on the variables bar: generalise the single-line overflow
measurement into hooks/useInlineOverflowCount (container of data-overflow-item
children, with gap/reserveWidth/enabled options) so it's reusable elsewhere, and
clarify the internal variable names (container/itemWidths/availableWidth/etc.).

* refactor(dashboard-v2): use descriptive names in useInlineOverflowCount

Replace single-letter/abbreviated locals (el, i, w, width/widths) with
itemElement/index/itemWidth/cachedWidth(s) inside the measure loop.
2026-06-24 12:54:34 +00:00
24 changed files with 1513 additions and 190 deletions

View File

@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseInlineOverflowCountOptions {
itemCount: number;
/** Horizontal gap between items, in px. */
gap?: number;
/** Width kept free at the end of the line for a trailing "+N" trigger, in px. */
reserveWidth?: number;
/** Pause measuring (e.g. while expanded) without unmounting. */
enabled?: boolean;
}
interface UseInlineOverflowCountResult {
containerRef: React.RefObject<HTMLDivElement>;
visibleCount: number;
overflowCount: number;
}
/**
* Measures how many of a container's children (each marked
* `data-overflow-item="true"`) fit on a single line, reserving `reserveWidth`
* for a trailing "+N" trigger. Item widths are cached, so children hidden with
* `display: none` still count toward the fit; measuring pauses while `enabled`
* is false.
*/
export function useInlineOverflowCount({
itemCount,
gap = 8,
reserveWidth = 0,
enabled = true,
}: UseInlineOverflowCountOptions): UseInlineOverflowCountResult {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(itemCount);
const itemWidthsRef = useRef<number[]>([]);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => {
itemWidthsRef.current = [];
setVisibleCount(itemCount);
}, [itemCount]);
const measure = useCallback((): void => {
const container = containerRef.current;
if (!container || !enabledRef.current) {
return;
}
const itemElements = Array.from(container.children).filter(
(itemElement): itemElement is HTMLElement =>
itemElement instanceof HTMLElement &&
itemElement.dataset.overflowItem === 'true',
);
if (itemElements.length === 0) {
setVisibleCount(0);
return;
}
itemElements.forEach((itemElement, index) => {
if (itemElement.offsetWidth > 0) {
itemWidthsRef.current[index] = itemElement.offsetWidth;
}
});
const cachedWidths: number[] = [];
for (let index = 0; index < itemElements.length; index += 1) {
const cachedWidth = itemWidthsRef.current[index];
if (cachedWidth == null) {
// Width not cached yet — reveal everything for one frame so it gets
// measured, then the next pass collapses accurately.
setVisibleCount(itemElements.length);
return;
}
cachedWidths.push(cachedWidth);
}
const containerWidth = container.clientWidth;
const totalWidth = cachedWidths.reduce(
(runningTotal, itemWidth, index) =>
runningTotal + itemWidth + (index > 0 ? gap : 0),
0,
);
if (totalWidth <= containerWidth) {
setVisibleCount(itemElements.length);
return;
}
const availableWidth = containerWidth - reserveWidth;
let usedWidth = 0;
let fitCount = 0;
for (let index = 0; index < cachedWidths.length; index += 1) {
const itemWidthWithGap = cachedWidths[index] + (index > 0 ? gap : 0);
if (usedWidth + itemWidthWithGap > availableWidth && fitCount > 0) {
break;
}
usedWidth += itemWidthWithGap;
fitCount += 1;
}
setVisibleCount(Math.max(1, fitCount));
}, [gap, reserveWidth]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const observer = new ResizeObserver(() => measure());
observer.observe(container);
Array.from(container.children).forEach((child) => observer.observe(child));
measure();
return (): void => observer.disconnect();
}, [measure, itemCount, enabled]);
return {
containerRef,
visibleCount,
overflowCount: Math.max(0, itemCount - visibleCount),
};
}

View File

@@ -1,11 +1,7 @@
.dashboardActionsContainer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.dashboardActionsSecondary {
display: flex;
gap: 12px;
}

View File

@@ -1,32 +1,42 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
Braces,
ClipboardCopy,
Configure,
Ellipsis,
Copy,
FileJson,
Fullscreen,
Grid3X3,
LockKeyhole,
PenLine,
Plus,
SquareStack,
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 { toast } from '@signozhq/ui/sonner';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -55,14 +65,31 @@ function DashboardActions({
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
const handleCreateSection = useCallback(
async (title: string): Promise<void> => {
await addSection(title);
setIsNewSectionOpen(false);
},
[addSection],
);
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
@@ -89,6 +116,24 @@ function DashboardActions({
URL.revokeObjectURL(url);
}, [dashboardDataJSON, title]);
const handleClone = useCallback(async (): Promise<void> => {
if (!dashboard.id) {
return;
}
try {
setIsCloning(true);
const response = await cloneDashboardV2({ id: dashboard.id });
toast.success('Dashboard cloned');
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsCloning(false);
}
}, [dashboard.id, safeNavigate, showErrorModal]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
@@ -99,17 +144,24 @@ function DashboardActions({
}, [deleteDashboardMutation]);
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
const dashboardGroup: MenuItem[] = [];
if (canEdit) {
editGroup.push({
dashboardGroup.push({
key: 'rename',
label: 'Rename',
icon: <PenLine size={14} />,
onClick: onOpenRename,
});
}
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
if (isAuthor || user.role === USER_ROLES.ADMIN) {
editGroup.push({
dashboardGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
icon: <LockKeyhole size={14} />,
@@ -117,14 +169,14 @@ function DashboardActions({
onClick: onLockToggle,
});
}
editGroup.push({
dashboardGroup.push({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handle.enter,
});
const exportGroup: MenuItem[] = [
const dataGroup: MenuItem[] = [
{
key: 'export',
label: 'Export JSON',
@@ -139,7 +191,35 @@ function DashboardActions({
},
];
const dangerGroup: MenuItem[] = [
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
key: 'new-section',
label: 'New section',
icon: <SquareStack size={14} />,
onClick: (): void => setIsNewSectionOpen(true),
});
}
const items: MenuItem[] = [
{
type: 'group',
key: 'group-dashboard',
label: 'Dashboard',
children: dashboardGroup,
},
{ type: 'group', key: 'group-data', label: 'Data', children: dataGroup },
];
if (layoutGroup.length > 0) {
items.push({
type: 'group',
key: 'group-layout',
label: 'Layout',
children: layoutGroup,
});
}
items.push(
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: 'Delete dashboard',
@@ -147,74 +227,85 @@ function DashboardActions({
danger: true,
onClick: (): void => setIsDeleteOpen(true),
},
];
return [editGroup, exportGroup, dangerGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
);
);
return items;
}, [
isDashboardLocked,
canEdit,
isCloning,
isAuthor,
user.role,
isDashboardLocked,
dashboard.createdBy,
onOpenRename,
handleClone,
onLockToggle,
handle.enter,
exportJSON,
setCopy,
dashboardDataJSON,
canEdit,
]);
return (
<div className={styles.dashboardActionsContainer}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<div className={styles.dashboardActionsSecondary}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
size="icon"
prefix={<Ellipsis size="md" />}
testId="options"
/>
</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>
</>
)}
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
New Panel
Configure
</Button>
)}
</div>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
<Button
variant="solid"
color="secondary"
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
size="md"
>
Edit as JSON
</Button>
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<JsonEditorDrawer
dashboard={dashboard}
isOpen={isJsonEditorOpen}
onClose={(): void => setIsJsonEditorOpen(false)}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
@@ -223,6 +314,15 @@ function DashboardActions({
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
<SectionTitleModal
open={isNewSectionOpen}
heading="New section"
okText="Create section"
initialValue=""
isSaving={isAddingSection}
onClose={(): void => setIsNewSectionOpen(false)}
onSubmit={handleCreateSection}
/>
</div>
);
}

View File

@@ -1,19 +1,9 @@
.dashboardInfo {
display: flex;
flex-direction: column;
gap: 8px;
width: 40%;
@media (min-width: 1280px) {
width: 30%;
}
}
.dashboardTitleContainer {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.dashboardImage {
@@ -21,9 +11,8 @@
}
.dashboardTitle {
flex: 1;
flex: 0 1 auto;
min-width: 0;
max-width: fit-content;
color: var(--l1-foreground);
font-size: 18px;
font-weight: 500;
@@ -37,6 +26,19 @@
cursor: text !important;
}
.descriptionIcon {
flex-shrink: 0;
color: var(--l2-foreground);
cursor: help;
}
.divider {
flex-shrink: 0;
width: 1px;
height: 18px;
background: var(--l2-border);
}
.dashboardTitleEditor {
display: flex;
align-items: center;
@@ -54,8 +56,13 @@
flex-shrink: 0;
}
/* Flexes into the remaining space and clips so the ResizeObserver can measure
how many tags fit before collapsing the rest into a `+N` badge. */
.dashboardTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1 1 0;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}

View File

@@ -1,5 +1,5 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -9,6 +9,7 @@ import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
@@ -45,6 +46,11 @@ function DashboardInfo({
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const { containerRef, visibleCount } = useVisibleTagCount(tags);
const needsOverflow = tags.length > visibleCount;
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -56,83 +62,106 @@ function DashboardInfo({
return (
<div className={styles.dashboardInfo}>
<div className={styles.dashboardTitleContainer}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
<img src={image} alt={title} className={styles.dashboardImage} />
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
{hasTags && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{hasDescription && (
<Typography.Text color="muted">{description}</Typography.Text>
<TooltipSimple title={description}>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
data-testid="dashboard-description-info"
/>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
{hasTags && (
<>
<span className={styles.divider} />
<div
ref={containerRef}
className={styles.dashboardTags}
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="warning"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
</TooltipSimple>
)}
</div>
</>
)}
</div>
);

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
BADGE_GAP,
estimateBadgeWidth,
OVERFLOW_BADGE_WIDTH,
} from 'components/Alerts/LabelColumn/utils';
interface Result {
containerRef: React.RefObject<HTMLDivElement>;
visibleCount: number;
}
/**
* Measures how many tags fit in the container and returns the visible count,
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
* from the alerts LabelColumn so dashboards and alerts overflow identically.
*/
export function useVisibleTagCount(tags: string[]): Result {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(tags.length);
const calculateVisible = useCallback(
(width: number): number => {
if (width <= 0) {
return 1;
}
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
let usedWidth = 0;
let count = 0;
for (const tag of tags) {
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
if (usedWidth + badgeWidth > availableWidth && count > 0) {
break;
}
usedWidth += badgeWidth;
count += 1;
}
return Math.max(1, count);
},
[tags],
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setVisibleCount(calculateVisible(entry.contentRect.width));
}
});
observer.observe(container);
if (container.clientWidth > 0) {
setVisibleCount(calculateVisible(container.clientWidth));
}
return (): void => observer.disconnect();
}, [calculateVisible]);
return { containerRef, visibleCount };
}

View File

@@ -5,7 +5,9 @@
color: var(--l2-foreground);
background-color: var(--l1-background);
padding: 16px;
box-shadow: 0 2px 2px 0px var(--l2-border);
box-shadow:
0 1px 0 0 var(--l2-border),
0 6px 12px -10px var(--l2-border);
}
.dashboardPageToolbarSubContainer {
@@ -16,5 +18,22 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.toolbarRow2 {
width: 100%;
margin-top: 12px;
&::after {
display: block;
clear: both;
content: '';
}
}
.timeCluster {
float: right;
margin: 0 0 0 16px;
}

View File

@@ -0,0 +1,72 @@
.root {
:global(.ant-drawer-wrapper-body) {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
}
:global(.ant-drawer-header) {
height: 48px;
border-bottom: 1px solid var(--l1-border);
:global(.ant-drawer-title) {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
}
:global(.ant-drawer-body) {
padding: 0;
display: flex;
min-height: 0;
}
:global(.ant-drawer-footer) {
padding: 12px 16px;
border-top: 1px solid var(--l1-border);
}
}
.body {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.editor {
flex: 1;
min-height: 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.validation {
min-width: 0;
overflow: hidden;
font-family: 'Space Mono', monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.validationValid {
color: var(--bg-forest-400);
}
.validationInvalid {
color: var(--bg-cherry-400);
}
.footerActions {
display: flex;
flex-shrink: 0;
gap: 8px;
}

View File

@@ -0,0 +1,141 @@
import { KeyboardEvent, useCallback } from 'react';
import MEditor from '@monaco-editor/react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
onClose: () => void;
}
function JsonEditorDrawer({
dashboard,
isOpen,
onClose,
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
toast.success('JSON copied to clipboard');
}, [copyToClipboard, draft]);
const onDownload = useCallback((): void => {
const blob = new Blob([draft], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${dashboard.name || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [draft, dashboard.name]);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
}
},
[apply],
);
const applyDisabled = !isDirty || !validity.valid || isSaving;
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
return (
<Drawer
title="Dashboard JSON"
placement="right"
width={660}
onClose={onClose}
open={isOpen}
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerActions}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="json-editor-cancel"
onClick={onClose}
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
</div>
</div>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.body} onKeyDown={onKeyDown}>
<JsonEditorToolbar
isDirty={isDirty}
onFormat={format}
onCopy={onCopy}
onDownload={onDownload}
onReset={reset}
/>
<div className={styles.editor}>
<MEditor
language="json"
height="100%"
value={draft}
onChange={(value): void => setDraft(value ?? '')}
options={{
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'Space Mono',
}}
theme="vs-dark"
onMount={(editor, monaco): void => {
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
monaco.editor.setTheme(JSON_EDITOR_THEME);
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
}}
/>
</div>
</div>
</Drawer>
);
}
export default JsonEditorDrawer;

View File

@@ -0,0 +1,12 @@
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
}
.spacer {
flex: 1;
}

View File

@@ -0,0 +1,69 @@
import { AlignLeft, Copy, Download, RotateCcw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './JsonEditorToolbar.module.scss';
interface JsonEditorToolbarProps {
isDirty: boolean;
onFormat: () => void;
onCopy: () => void;
onDownload: () => void;
onReset: () => void;
}
function JsonEditorToolbar({
isDirty,
onFormat,
onCopy,
onDownload,
onReset,
}: JsonEditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<AlignLeft size={14} />}
testId="json-editor-format"
onClick={onFormat}
>
Format
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Copy size={14} />}
testId="json-editor-copy"
onClick={onCopy}
>
Copy
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Download size={14} />}
testId="json-editor-download"
onClick={onDownload}
>
Download
</Button>
<div className={styles.spacer} />
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<RotateCcw size={14} />}
testId="json-editor-reset"
disabled={!isDirty}
onClick={onReset}
>
Reset
</Button>
</div>
);
}
export default JsonEditorToolbar;

View File

@@ -0,0 +1,165 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
import { useJsonEditor } from '../useJsonEditor';
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({
value,
onChange,
}: {
value: string;
onChange: (next?: string) => void;
}): JSX.Element => (
<textarea
aria-label="json-editor"
data-testid="monaco"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, jest.Mock] => [{}, jest.fn()],
}));
const mockUseJsonEditor = useJsonEditor as jest.Mock;
const dashboard = {
id: 'dash-1',
name: 'My dashboard',
} as unknown as DashboardtypesGettableDashboardV2DTO;
function hookValue(
overrides: Partial<ReturnType<typeof useJsonEditor>> = {},
): ReturnType<typeof useJsonEditor> {
return {
draft: '{\n "a": 1\n}',
setDraft: jest.fn(),
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('JsonEditorDrawer', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the toolbar, editor and footer actions when open', () => {
mockUseJsonEditor.mockReturnValue(hookValue());
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-format')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-copy')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-download')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-reset')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-apply')).toBeInTheDocument();
expect(screen.getByTestId('monaco')).toBeInTheDocument();
});
it('shows a valid status with the line count', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ validity: { valid: true, lineCount: 12 } }),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
'Valid JSON · 12 lines',
);
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({
validity: {
valid: false,
lineCount: 4,
errorLine: 3,
message: 'Unexpected token',
},
}),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
'Line 3 · Unexpected token',
);
});
it('disables Apply when not dirty, invalid, or saving', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
const { rerender } = render(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
mockUseJsonEditor.mockReturnValue(
hookValue({ validity: { valid: false, lineCount: 1 } }),
);
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
mockUseJsonEditor.mockReturnValue(hookValue({ isSaving: true }));
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
});
it('wires toolbar and footer buttons to the hook callbacks', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
const onClose = jest.fn();
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('json-editor-format'));
expect(value.format).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-reset'));
expect(value.reset).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-apply'));
expect(value.apply).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-cancel'));
expect(onClose).toHaveBeenCalled();
});
it('forwards editor changes to setDraft', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
fireEvent.change(screen.getByTestId('monaco'), {
target: { value: '{"b":2}' },
});
expect(value.setDraft).toHaveBeenCalledWith('{"b":2}');
});
it('applies on Cmd/Ctrl+Enter', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
fireEvent.keyDown(screen.getByTestId('monaco'), {
key: 'Enter',
metaKey: true,
});
expect(value.apply).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,179 @@
import { act, renderHook } from '@testing-library/react';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useJsonEditor } from '../useJsonEditor';
const mockRefetch = jest.fn();
const mockShowErrorModal = jest.fn();
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1', refetch: mockRefetch }),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: mockShowErrorModal,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
updateDashboardV2: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockUpdate = updateDashboardV2 as jest.Mock;
const mockToastSuccess = toast.success as jest.Mock;
const dashboard = {
id: 'dash-1',
name: 'My dashboard',
schemaVersion: 'v6',
image: 'icon.png',
tags: [{ key: 'env', value: 'prod' }],
spec: {
display: { name: 'My dashboard' },
panels: {},
layouts: [],
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const serialized = JSON.stringify(dashboard, null, 2);
describe('useJsonEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUpdate.mockResolvedValue({});
});
it('seeds the draft from the dashboard and reports valid, non-dirty state', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
expect(result.current.draft).toBe(serialized);
expect(result.current.isDirty).toBe(false);
expect(result.current.validity.valid).toBe(true);
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
});
it('flags invalid JSON with a line number and marks the draft dirty', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('{\n "name": ,\n}'));
expect(result.current.validity.valid).toBe(false);
expect(result.current.validity.message).toBeDefined();
expect(result.current.isDirty).toBe(true);
});
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('{"a":1}'));
act(() => result.current.format());
expect(result.current.draft).toBe('{\n "a": 1\n}');
act(() => result.current.setDraft('{bad'));
act(() => result.current.format());
expect(result.current.draft).toBe('{bad');
});
it('reset() restores the last-applied text', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('edited'));
expect(result.current.isDirty).toBe(true);
act(() => result.current.reset());
expect(result.current.draft).toBe(serialized);
expect(result.current.isDirty).toBe(false);
});
it('apply() is a no-op when the draft is unchanged or invalid', async () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).not.toHaveBeenCalled();
act(() => result.current.setDraft('{bad'));
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).not.toHaveBeenCalled();
});
it('apply() PUTs the narrowed body, toasts, refetches and calls onApplied', async () => {
const onApplied = jest.fn();
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied }),
);
const next = { ...dashboard, name: 'Renamed' };
act(() => result.current.setDraft(JSON.stringify(next)));
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith(
{ id: 'dash-1' },
expect.objectContaining({
name: 'Renamed',
schemaVersion: 'v6',
spec: next.spec,
tags: next.tags,
}),
);
expect(mockToastSuccess).toHaveBeenCalled();
expect(mockRefetch).toHaveBeenCalled();
expect(onApplied).toHaveBeenCalled();
});
it('apply() surfaces errors through the error modal', async () => {
mockUpdate.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() =>
result.current.setDraft(JSON.stringify({ ...dashboard, name: 'X' })),
);
await act(async () => {
await result.current.apply();
});
expect(mockShowErrorModal).toHaveBeenCalled();
});
it('re-seeds the draft when the drawer re-opens', () => {
const onApplied = jest.fn();
const { result, rerender } = renderHook(
(props: { isOpen: boolean }) =>
useJsonEditor({ dashboard, isOpen: props.isOpen, onApplied }),
{ initialProps: { isOpen: false } },
);
act(() => result.current.setDraft('stale edit'));
expect(result.current.draft).toBe('stale edit');
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
});

View File

@@ -0,0 +1,26 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesDashboardSpecDTO,
DashboardtypesUpdatableDashboardV2DTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Narrow a parsed (full Gettable-shaped) dashboard JSON down to the PUT-updatable
* body. The editor shows the whole dashboard for readability, but the update
* endpoint only accepts `{ name, schemaVersion, image, tags, spec }` — the
* server owns `id`, `locked`, timestamps, etc., so we drop them here.
*/
export function dashboardToUpdatable(
parsed: Record<string, unknown>,
): DashboardtypesUpdatableDashboardV2DTO {
const dashboard = parsed as Partial<DashboardtypesGettableDashboardV2DTO>;
return {
name: dashboard.name ?? '',
schemaVersion: dashboard.schemaVersion ?? 'v6',
image: dashboard.image,
tags: (dashboard.tags as TagtypesPostableTagDTO[] | null | undefined) ?? null,
spec: dashboard.spec as DashboardtypesDashboardSpecDTO,
};
}

View File

@@ -0,0 +1,47 @@
import type { Monaco } from '@monaco-editor/react';
export const JSON_EDITOR_THEME = 'signoz-json';
function token(el: HTMLElement, name: string): string {
return getComputedStyle(el).getPropertyValue(name).trim().replace('#', '');
}
function isDark(hex: string): boolean {
if (hex.length < 6) {
return true;
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
}
export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const background = token(el, '--l1-background');
const foreground = token(el, '--l1-foreground');
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
const rules: { token: string; foreground: string }[] = [];
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
const colors: Record<string, string> = {};
if (background) {
colors['editor.background'] = `#${background}`;
}
if (foreground) {
colors['editor.foreground'] = `#${foreground}`;
}
monaco.editor.defineTheme(JSON_EDITOR_THEME, {
base: isDark(background) ? 'vs-dark' : 'vs',
inherit: true,
rules,
colors,
});
}

View File

@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
valid: boolean;
lineCount: number;
/** 1-based line of the parse error, when known. */
errorLine?: number;
message?: string;
}
interface Params {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
onApplied: () => void;
}
interface Result {
draft: string;
setDraft: (next: string) => void;
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
format: () => void;
reset: () => void;
apply: () => Promise<void>;
}
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
JSON.stringify(dashboard, null, 2);
/** Derive a 1-based line number from a `JSON.parse` "position N" error message. */
function errorLineFromMessage(
source: string,
message: string,
): number | undefined {
const match = /position (\d+)/.exec(message);
if (!match) {
return undefined;
}
const position = Number(match[1]);
return source.slice(0, position).split('\n').length;
}
/**
* Editor state for the dashboard JSON drawer: tracks the editable `draft`
* against the last-applied text, exposes live validation, and applies changes
* via the full-document update endpoint.
*/
export function useJsonEditor({
dashboard,
isOpen,
onApplied,
}: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [appliedText, setAppliedText] = useState<string>(() =>
serialize(dashboard),
);
const [draft, setDraft] = useState<string>(appliedText);
const [isSaving, setIsSaving] = useState(false);
// Re-seed the editor from the current dashboard each time the drawer opens so
// it always reflects the latest persisted state (e.g. after a refetch).
useEffect(() => {
if (isOpen) {
const next = serialize(dashboard);
setAppliedText(next);
setDraft(next);
}
}, [isOpen, dashboard]);
const validity = useMemo<JsonValidity>(() => {
const lineCount = draft.split('\n').length;
try {
JSON.parse(draft);
return { valid: true, lineCount };
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
return {
valid: false,
lineCount,
errorLine: errorLineFromMessage(draft, message),
message,
};
}
}, [draft]);
const isDirty = draft !== appliedText;
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
} catch {
// Leave the draft untouched when it can't be parsed.
}
}, [draft]);
const reset = useCallback((): void => {
setDraft(appliedText);
}, [appliedText]);
const apply = useCallback(async (): Promise<void> => {
if (!validity.valid || !isDirty) {
return;
}
try {
setIsSaving(true);
const parsed = JSON.parse(draft) as Record<string, unknown>;
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(parsed));
toast.success('Dashboard updated');
refetch();
onApplied();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [
dashboardId,
validity.valid,
isDirty,
draft,
refetch,
onApplied,
showErrorModal,
]);
return {
draft,
setDraft,
validity,
isDirty,
isSaving,
format,
reset,
apply,
};
}

View File

@@ -12,6 +12,7 @@ import type {
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -139,7 +140,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
/>
</div>
<VariablesBar dashboard={dashboard} />
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow2}>
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<VariablesBar dashboard={dashboard} />
</div>
</section>
);
}

View File

@@ -13,7 +13,7 @@ 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 SectionTitleModal from '../SectionTitleModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader, {
type SectionDragHandle,
@@ -146,8 +146,10 @@ function Section({
)}
</div>
))}
<RenameSectionModal
<SectionTitleModal
open={isRenaming}
heading="Rename section"
okText="Rename"
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}

View File

@@ -2,21 +2,30 @@ import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface RenameSectionModalProps {
interface SectionTitleModalProps {
open: boolean;
/** Modal heading, e.g. "Rename section" / "New section". */
heading: string;
/** Confirm button label, e.g. "Rename" / "Create section". */
okText: string;
initialValue: string;
isSaving: boolean;
placeholder?: string;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
/** Title-entry modal shared by section create and rename. */
function SectionTitleModal({
open,
heading,
okText,
initialValue,
isSaving,
placeholder = 'Section name',
onClose,
onSubmit,
}: RenameSectionModalProps): JSX.Element {
}: SectionTitleModalProps): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
@@ -36,19 +45,19 @@ function RenameSectionModal({
return (
<Modal
open={open}
title="Rename section"
title={heading}
onCancel={onClose}
onOk={submit}
okText="Rename"
okText={okText}
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
testId="section-title-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
placeholder={placeholder}
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -61,4 +70,4 @@ function RenameSectionModal({
);
}
export default RenameSectionModal;
export default SectionTitleModal;

View File

@@ -12,6 +12,27 @@ import {
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the refetch to render the appended section, then scrolls
* it into view. Polls because `refetch` resolves before React commits the new
* section to the DOM; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
if (sections.length > prevCount) {
sections[sections.length - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (attempts > 0) {
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
}
}
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
@@ -42,10 +63,12 @@ export function useAddSection({ layouts }: Params): Result {
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
} finally {

View File

@@ -101,7 +101,7 @@ function VariableSelector({
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size="md" />
<SolidInfoCircle className={styles.infoIcon} size={14} />
</Tooltip>
) : null}
</Typography.Text>

View File

@@ -1,12 +1,55 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
min-width: 0;
}
.strip {
display: flow-root;
}
.stripExpanded {
display: flex;
flex-wrap: wrap;
gap: 12px;
gap: 8px;
padding-top: 12px;
overflow: visible;
clear: both;
.variableSlot,
.moreButton {
margin: 0;
}
@media (prefers-reduced-motion: no-preference) {
animation: variablesExpandIn 200ms ease-out;
}
}
@keyframes variablesExpandIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.variableSlot {
display: inline-flex;
align-items: center;
margin-right: 8px;
vertical-align: top;
}
.variableSlotHidden {
display: none;
}
.moreButton {
display: inline-flex;
vertical-align: top;
}
.variableItem {
@@ -21,7 +64,7 @@
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
@@ -33,8 +76,10 @@
}
.infoIcon {
margin-left: 4px;
display: inline-flex;
margin-left: 2px;
color: var(--l2-foreground);
vertical-align: middle;
}
.variableValue {
@@ -42,7 +87,7 @@
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l1-border);
border: 1px solid var(--l3-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
@@ -55,8 +100,6 @@
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;

View File

@@ -1,4 +1,9 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
@@ -11,33 +16,76 @@ interface VariablesBarProps {
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*
* The pills sit on the line left of the floated time selector and collapse the
* overflow behind a `+N` trigger. Expanding lets the bar wrap onto full-width
* lines that flow underneath the time selector. Every selector stays mounted
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
reserveWidth: 48,
enabled: !expanded,
});
if (variables.length === 0) {
return null;
}
const hasOverflow = overflowCount > 0;
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
<div
ref={containerRef}
className={cx(styles.strip, { [styles.stripExpanded]: expanded })}
>
{variables.map((variable, index) => (
<div
key={variable.name}
data-overflow-item="true"
className={cx(styles.variableSlot, {
[styles.variableSlotHidden]:
!expanded && hasOverflow && index >= visibleCount,
})}
>
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
</div>
))}
{hasOverflow && (
<span className={styles.moreButton}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
</span>
)}
</div>
</div>
);
}

View File

@@ -73,7 +73,7 @@ function ValueSelector({
return (
<CustomSelect
className={styles.select}
className={styles.control}
data-testid={testId}
options={optionData}
value={