Compare commits

..

1 Commits

Author SHA1 Message Date
Naman Verma
dbb4eb9574 chore: validate layout size and positioning in backend (#11921)
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
* chore: validate layout size and positioning in backend

* test: add integratino test for layout validation

* test: use assert instead of require for non blocking check

* test: use assert instead of require for non blocking check

* fix: move require to assert for actual test checks
2026-07-02 11:07:58 +00:00
26 changed files with 926 additions and 876 deletions

View File

@@ -1,228 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesContentProps {
onCreateNewDashboard: () => void;
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
onCancel?: () => void;
}
// The template gallery (search + list + preview + create), extracted from the
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
export default function DashboardTemplatesContent({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesContentProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<div className="new-dashboard-templates-content-container">
{onCancel && (
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
)}
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,129 @@
import { Modal } from 'antd';
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
import NginxIcon from 'assets/CustomIcons/NginxIcon';
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
import RedisIcon from 'assets/CustomIcons/RedisIcon';
import cx from 'classnames';
import {
ConciergeBell,
DraftingCompass,
Drill,
Plus,
X,
} from '@signozhq/icons';
import { DashboardTemplate } from 'types/api/dashboard/getAll';
import DashboardTemplatesContent from './DashboardTemplatesContent';
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
import { filterTemplates } from '../utils';
import './DashboardTemplatesModal.styles.scss';
const templatesList: DashboardTemplate[] = [
{
name: 'Blank dashboard',
icon: <Drill />,
id: 'blank',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Alert Manager',
icon: <ConciergeBell />,
id: 'alertManager',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Apache',
icon: <ApacheIcon />,
id: 'apache',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Docker',
icon: <DockerIcon />,
id: 'docker',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Elasticsearch',
icon: <ElasticSearchIcon />,
id: 'elasticSearch',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MongoDB',
icon: <MongoDBIcon />,
id: 'mongoDB',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Heroku',
icon: <HerokuIcon />,
id: 'heroku',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Nginx',
icon: <NginxIcon />,
id: 'nginx',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Kubernetes',
icon: <KubernetesIcon />,
id: 'kubernetes',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'MySQL',
icon: <MySQLIcon />,
id: 'mySQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'PostgreSQL',
icon: <PostgreSQLIcon />,
id: 'postgreSQL',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
{
name: 'Redis',
icon: <RedisIcon />,
id: 'redis',
description: 'Create a custom dashboard from scratch.',
previewImage: redisTemplatePreviewUrl,
},
{
name: 'AWS',
icon: <DraftingCompass size={14} />,
id: 'aws',
description: 'Create a custom dashboard from scratch.',
previewImage: blankDashboardTemplatePreviewUrl,
},
];
interface DashboardTemplatesModalProps {
showNewDashboardTemplatesModal: boolean;
onCreateNewDashboard: () => void;
@@ -15,6 +135,20 @@ export default function DashboardTemplatesModal({
onCreateNewDashboard,
onCancel,
}: DashboardTemplatesModalProps): JSX.Element {
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
templatesList[0],
);
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
const handleDashboardTemplateSearch = (
event: ChangeEvent<HTMLInputElement>,
) => {
const searchText = event.target.value;
const filteredTemplates = filterTemplates(searchText, templatesList);
setDashboardTemplates(filteredTemplates);
};
return (
<Modal
wrapClassName="new-dashboard-templates-modal"
@@ -25,10 +159,75 @@ export default function DashboardTemplatesModal({
destroyOnClose
width="60vw"
>
<DashboardTemplatesContent
onCreateNewDashboard={onCreateNewDashboard}
onCancel={onCancel}
/>
<div className="new-dashboard-templates-content-container">
<div className="new-dashboard-templates-content-header">
<Typography.Text>New Dashboard</Typography.Text>
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
</div>
<div className="new-dashboard-templates-content">
<div className="new-dashboard-templates-list">
<Input
className="new-dashboard-templates-search"
placeholder="🔍 Search..."
onChange={handleDashboardTemplateSearch}
/>
<div className="templates-list">
{dashboardTemplates.map((template) => (
<div
className={cx(
'template-list-item',
selectedDashboardTemplate.id === template.id ? 'active' : '',
)}
key={template.name}
onClick={() => setSelectedDashboardTemplate(template)}
>
<div className="template-icon">{template.icon}</div>
<div className="template-name">{template.name}</div>
</div>
))}
</div>
</div>
<div className="new-dashboard-template-preview">
<div className="template-preview-header">
<div className="template-preview-title">
<div className="template-preview-icon">
{selectedDashboardTemplate.icon}
</div>
<div className="template-info">
<div className="template-name">{selectedDashboardTemplate.name}</div>
<div className="template-description">
{selectedDashboardTemplate.description}
</div>
</div>
</div>
<div className="create-dashboard-btn">
<Button
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
>
New dashboard
</Button>
</div>
</div>
<div className="template-preview-image">
<img
src={selectedDashboardTemplate.previewImage}
alt={`${selectedDashboardTemplate.name}-preview`}
/>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -32,34 +32,6 @@
cursor: help;
}
.publicLink {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.lockButton:disabled {
cursor: default;
}
.divider {
flex-shrink: 0;
width: 1px;

View File

@@ -1,12 +1,5 @@
import { KeyboardEvent } from 'react';
import {
Check,
Globe,
LockKeyhole,
LockKeyholeOpen,
SolidInfoCircle,
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';
@@ -14,7 +7,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
@@ -26,13 +18,7 @@ interface DashboardInfoProps {
tags: string[];
description: string;
isPublicDashboard: boolean;
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
publicUrl: string;
isDashboardLocked: boolean;
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
showLockToggle: boolean;
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
onToggleLock?: () => void;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
@@ -47,10 +33,7 @@ function DashboardInfo({
tags,
description,
isPublicDashboard,
publicUrl,
isDashboardLocked,
showLockToggle,
onToggleLock,
isEditing,
draft,
onDraftChange,
@@ -68,17 +51,6 @@ function DashboardInfo({
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -129,7 +101,7 @@ function DashboardInfo({
</Button>
</div>
) : (
<TooltipSimple title={title} disableHoverableContent>
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
@@ -143,7 +115,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple title={description} disableHoverableContent>
<TooltipSimple title={description}>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
@@ -153,38 +125,14 @@ function DashboardInfo({
)}
{isPublicDashboard && (
<TooltipSimple
title="This dashboard is publicly accessible. Click to open the public page."
disableHoverableContent
>
<button
type="button"
className={styles.publicLink}
aria-label="Open public dashboard"
data-testid="dashboard-public-link"
onClick={(): void => openInNewTab(publicUrl)}
>
<Globe size={14} />
</button>
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{showLockToggle && (
<TooltipSimple title={lockTooltip} disableHoverableContent>
<button
type="button"
className={styles.lockButton}
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
data-testid="dashboard-lock"
disabled={!onToggleLock}
onClick={onToggleLock}
>
{isDashboardLocked ? (
<LockKeyhole size={14} />
) : (
<LockKeyholeOpen size={14} />
)}
</button>
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
@@ -197,14 +145,14 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="sienna" variant="outline">
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="sienna"
color="warning"
variant="outline"
data-testid="dashboard-tags-overflow"
>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -15,12 +15,9 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -39,15 +36,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
useEffect(() => {
setIsDashboardLocked(!!dashboard.locked);
setShowLockToggle(!!dashboard.locked);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.id]);
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -69,36 +58,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) &&
dashboard.createdBy !== 'integration';
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
setShowLockToggle(true);
}
try {
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
refetch();
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, refetch, showErrorModal]);
@@ -146,11 +119,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isPublicDashboard={false}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}

View File

@@ -2,7 +2,6 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding-top: 2px;
color: var(--l3-foreground);
}

View File

@@ -26,19 +26,3 @@
padding: 0px;
}
}
.renameFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Wrap so the tooltip has a hoverable target even when the button is disabled. */
.menuItemWrap {
display: block;
width: 100%;
}
.menuItemWrap button:disabled {
pointer-events: none;
}

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { useMutation } from 'react-query';
import { generatePath } from 'react-router-dom';
import { Popover, Tooltip } from 'antd';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import {
@@ -9,28 +8,18 @@ import {
Expand,
EllipsisVertical,
Link2,
LockKeyhole,
PenLine,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import {
cloneDashboardV2,
invalidateListDashboardsForUserV2,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import DeleteActionItem from './DeleteActionItem';
import RenameDashboardModal from './RenameDashboardModal';
import styles from './ActionsPopover.module.scss';
interface Props {
@@ -53,7 +42,6 @@ function ActionsPopover({
const [, setCopy] = useCopyToClipboard();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isRenameOpen, setIsRenameOpen] = useState(false);
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
// by the caller; open the copy so it can be tweaked right away.
@@ -70,159 +58,85 @@ function ActionsPopover({
},
});
const queryClient = useQueryClient();
const { user } = useAppContext();
const isAuthor = user?.email === createdBy;
// Author/admin can lock-unlock (mirrors the detail-page gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) && createdBy !== 'integration';
const { mutate: runLockToggle, isLoading: isTogglingLock } = useMutation({
mutationFn: () =>
isLocked
? unlockDashboardV2({ id: dashboardId })
: lockDashboardV2({ id: dashboardId }),
onSuccess: async () => {
toast.success(isLocked ? 'Dashboard unlocked' : 'Dashboard locked');
await invalidateListDashboardsForUserV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
return (
<>
<Popover
content={
// Stop clicks inside the menu (incl. disabled items) from bubbling to the
// row's onClick, which would navigate to the dashboard.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- wrapper only guards propagation, not an interactive control
<div className={styles.content} onClick={(e): void => e.stopPropagation()}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<Tooltip
placement="left"
title={
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<PenLine size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsRenameOpen(true);
}
}}
testId="dashboard-action-rename"
>
Rename
</Button>
</span>
</Tooltip>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
{canToggleLock && (
<Button
color="secondary"
className={styles.menuItem}
prefix={<LockKeyhole size={14} />}
loading={isTogglingLock}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runLockToggle();
}}
testId="dashboard-action-lock"
>
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
)}
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
<Popover
content={
<div className={styles.content}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Expand size={14} />}
onClick={onView}
testId="dashboard-action-view"
>
View
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<SquareArrowOutUpRight size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
testId="dashboard-action-open-new-tab"
>
Open in New Tab
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Link2 size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
testId="dashboard-action-copy-link"
>
Copy Link
</Button>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button
size="icon"
variant="ghost"
color="secondary"
testId="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
>
<EllipsisVertical size={14} />
</Button>
</Popover>
<RenameDashboardModal
open={isRenameOpen}
dashboardId={dashboardId}
currentName={dashboardName}
onClose={(): void => setIsRenameOpen(false)}
/>
</>
<EllipsisVertical size={14} />
</Button>
</Popover>
);
}

View File

@@ -8,7 +8,7 @@ import { toast } from '@signozhq/ui/sonner';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import deleteDashboard from 'api/v1/dashboards/id/delete';
import { invalidateListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { invalidateListDashboardsV2 } from 'api/generated/services/dashboard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -44,7 +44,7 @@ function DeleteActionItem({
toast.success(
t('dashboard:delete_dashboard_success', { name: dashboardName }),
);
await invalidateListDashboardsForUserV2(queryClient);
await invalidateListDashboardsV2(queryClient);
},
onError: (error: APIError) => {
showErrorModal(error);
@@ -101,25 +101,23 @@ function DeleteActionItem({
<>
<Divider />
<Tooltip placement="left" title={tooltip}>
<span className={styles.menuItemWrap}>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
</span>
<Button
variant="ghost"
color="destructive"
className={styles.menuItem}
prefix={<Trash2 size={14} />}
disabled={isDisabled}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isDisabled) {
openConfirm();
}
}}
testId="dashboard-action-delete"
>
Delete Dashboard
</Button>
</Tooltip>
{contextHolder}
</>

View File

@@ -1,118 +0,0 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
// eslint-disable-next-line no-restricted-imports -- list rename targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
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 styles from './ActionsPopover.module.scss';
interface Props {
open: boolean;
dashboardId: string;
currentName: string;
onClose: () => void;
}
/** Renames a dashboard from the list via a `/spec/display/name` patch, then refreshes the list. */
function RenameDashboardModal({
open,
dashboardId,
currentName,
onClose,
}: Props): JSX.Element {
const [name, setName] = useState(currentName);
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
// Reset the field to the current name whenever the modal (re)opens.
useEffect(() => {
if (open) {
setName(currentName);
}
}, [open, currentName]);
const { mutate: runRename, isLoading } = useMutation({
mutationFn: () => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: name.trim(),
},
];
return patchDashboardV2({ id: dashboardId }, ops);
},
onSuccess: async () => {
toast.success('Dashboard renamed');
await invalidateListDashboardsForUserV2(queryClient);
onClose();
},
onError: (error: APIError) => {
showErrorModal(error);
},
});
const trimmed = name.trim();
const canSave = trimmed.length > 0 && trimmed !== currentName && !isLoading;
return (
<DialogWrapper
title="Rename dashboard"
open={open}
width="narrow"
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
footer={
<div className={styles.renameFooter}>
<Button
variant="ghost"
color="secondary"
size="md"
onClick={onClose}
testId="rename-dashboard-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
disabled={!canSave}
loading={isLoading}
onClick={(): void => runRename()}
testId="rename-dashboard-submit"
>
Save
</Button>
</div>
}
>
<Input
value={name}
autoFocus
placeholder="Dashboard name"
testId="rename-dashboard-input"
onChange={(e): void => setName(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' && canSave) {
runRename();
}
}}
/>
</DialogWrapper>
);
}
export default RenameDashboardModal;

View File

@@ -52,7 +52,6 @@
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.tagsWithActions {
@@ -63,14 +62,6 @@
justify-content: flex-end;
}
.lockIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--l3-foreground);
}
.pinBtn {
display: inline-flex;
align-items: center;
@@ -105,24 +96,6 @@
}
}
/* Pinned rows show the filled pin by default; the unpin action only appears when
the pin button itself is hovered. */
.pinnedIcon {
display: inline-flex;
}
.unpinIcon {
display: none;
}
.pinBtn:hover .pinnedIcon {
display: none;
}
.pinBtn:hover .unpinIcon {
display: inline-flex;
}
.tags {
display: flex;
flex-wrap: wrap;

View File

@@ -1,7 +1,7 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock, LockKeyhole, Pin, PinOff } from '@signozhq/icons';
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
@@ -79,7 +79,7 @@ function DashboardRow({
<div className={styles.titleBlock}>
<Tooltip
title={name.length > 50 ? name : ''}
placement="bottom"
placement="left"
overlayClassName="titleTooltipOverlay"
>
<div className={styles.titleLink} onClick={onClickHandler}>
@@ -111,36 +111,17 @@ function DashboardRow({
)}
</div>
{isLocked && (
<Tooltip title="This dashboard is locked" placement="top">
<span className={styles.lockIcon} data-testid={`dashboard-lock-${index}`}>
<LockKeyhole size={14} />
</span>
</Tooltip>
)}
<Tooltip
<button
type="button"
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
placement="top"
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
<button
type="button"
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
{isPinned ? (
<>
<Pin size={14} className={styles.pinnedIcon} />
<PinOff size={14} className={styles.unpinIcon} />
</>
) : (
<Pin size={14} />
)}
</button>
</Tooltip>
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
{canAct && (
<ActionsPopover

View File

@@ -102,8 +102,8 @@ function FilterZone({
/>
{!isEmpty && (
<Button
variant="outlined"
color="primary"
variant="ghost"
color="secondary"
size="sm"
prefix={<X size={12} />}
onClick={onClearAll}

View File

@@ -3,7 +3,7 @@ import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { ArrowDown, ArrowUp, Check, Columns3 } from '@signozhq/icons';
import { ArrowDown, ArrowUp, Check, HdmiPort } from '@signozhq/icons';
import {
DashboardtypesListOrderDTO,
@@ -191,7 +191,7 @@ function ListHeader({
aria-label="Columns"
testId="configure-columns-trigger"
>
<Columns3 size={14} />
<HdmiPort size={14} />
</Button>
</Tooltip>
</Popover>

View File

@@ -58,7 +58,6 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
variables: [],
},
});
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);

View File

@@ -20,11 +20,7 @@ import JsonEditor from './JsonEditor';
import styles from './NewDashboardModal.module.scss';
interface Props {
onClose: () => void;
}
function ImportJsonPanel({ onClose }: Props): JSX.Element {
function ImportJsonPanel(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation(['dashboard', 'common']);
const { showErrorModal } = useErrorModal();
@@ -63,7 +59,6 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
const payload = normalizeToPostable(parsed);
const response = await createDashboardV2(payload);
onClose();
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);

View File

@@ -43,12 +43,12 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
{
key: 'template',
label: 'From a template',
children: <TemplatesPanel onClose={onClose} />,
children: <TemplatesPanel />,
},
{
key: 'import',
label: 'Import JSON',
children: <ImportJsonPanel onClose={onClose} />,
children: <ImportJsonPanel />,
},
]}
/>

View File

@@ -1,66 +1,136 @@
import { useState } from 'react';
import { generatePath } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { toast } from '@signozhq/ui/sonner';
import { ExternalLink, LoaderCircle } from '@signozhq/icons';
import { AxiosError } from 'axios';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import DashboardTemplatesContent from 'container/ListOfDashboard/DashboardTemplates/DashboardTemplatesContent';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { openInNewTab } from 'utils/navigation';
import { normalizeToPostable } from './importUtils';
import JsonEditor from './JsonEditor';
import { useDashboardTemplates } from './templatesData';
import styles from './NewDashboardModal.module.scss';
interface Props {
onClose: () => void;
}
// Until the templates BE API lands, the V2 "From a template" tab embeds the V1
// template gallery inline (no modal-in-modal). The V1 templates are placeholders,
// so the action creates a blank dashboard.
function TemplatesPanel({ onClose }: Props): JSX.Element {
// Browse the template gallery (mock data until the API lands): pick one on the
// left to preview its JSON on the right, then use it or open the docs.
function TemplatesPanel(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const { data, isLoading } = useDashboardTemplates(true);
const templates = data ?? [];
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const handleCreate = async (): Promise<void> => {
if (creating) {
const selected = templates.find((t) => t.id === selectedId) ?? templates[0];
const handleUse = async (): Promise<void> => {
if (!selected) {
return;
}
try {
setCreating(true);
logEvent('Dashboard List: Use template clicked', {});
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: 'Sample Dashboard' },
layouts: [],
panels: {},
variables: [],
},
});
onClose();
logEvent('Dashboard List: Use template clicked', { template: selected.id });
const parsed = JSON.parse(selected.json) as Record<string, unknown>;
const created = await createDashboardV2(normalizeToPostable(parsed));
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
toast.error(
(e as AxiosError).toString() || 'Failed to create from template',
);
setCreating(false);
}
};
if (isLoading) {
return (
<div className={styles.panel}>
<div className={styles.loading}>
<LoaderCircle size={18} className={styles.spinner} />
<span>Loading templates</span>
</div>
</div>
);
}
return (
<div className={styles.panel}>
<div className="new-dashboard-templates-modal">
<DashboardTemplatesContent
onCreateNewDashboard={(): void => {
void handleCreate();
}}
/>
<div className={styles.templatesLayout}>
<div className={styles.templatesList}>
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cx(styles.templateItem, {
[styles.templateItemActive]: selected?.id === template.id,
})}
data-testid={`template-${template.id}`}
onClick={(): void => setSelectedId(template.id)}
>
<span className={styles.templateName}>{template.name}</span>
<span className={styles.templateCat}>{template.category}</span>
</button>
))}
</div>
{selected && (
<div className={styles.templatesPreview}>
<div className={styles.previewHead}>
<div>
<Typography.Text className={styles.cardName}>
{selected.name}
</Typography.Text>
<Typography.Text className={styles.cardDesc}>
{selected.description}
</Typography.Text>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ExternalLink size={13} />}
onClick={(): void => openInNewTab(selected.href)}
testId="template-docs"
>
Docs
</Button>
</div>
<JsonEditor value={selected.json} readOnly height="240px" />
<div className={styles.footer}>
<Button
variant="solid"
color="primary"
size="md"
loading={creating}
testId="use-template"
onClick={(): void => {
void handleUse();
}}
>
Use template
</Button>
</div>
</div>
)}
</div>
<div className={styles.requestRow}>
<RequestDashboardBtn />
</div>
</div>
);

View File

@@ -0,0 +1,106 @@
import { useQuery, type UseQueryResult } from 'react-query';
export interface DashboardTemplate {
id: string;
name: string;
description: string;
category: string;
href: string;
// Importable dashboard definition previewed in the gallery (mock for now).
json: string;
}
// A representative dashboard definition for a template — mock until the API
// returns real ones.
const buildTemplateJson = (
name: string,
description: string,
category: string,
): string =>
JSON.stringify(
{
schemaVersion: 'v6',
generateName: true,
tags: [{ key: 'category', value: category.toLowerCase() }],
spec: {
display: { name, description },
layouts: [],
panels: {},
variables: [],
},
},
null,
2,
);
// Mock catalogue until the templates API lands. Mirrors the public gallery at
// https://signoz.io/docs/dashboards/dashboard-templates/overview/
const BASE_TEMPLATES: Omit<DashboardTemplate, 'json'>[] = [
{
id: 'apm',
name: 'APM Metrics',
description: 'Latency, error rate, and throughput across your services.',
category: 'APM',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/apm/',
},
{
id: 'hostmetrics',
name: 'Host Metrics',
description: 'CPU, memory, disk, and network for your hosts.',
category: 'Infra',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/hostmetrics/',
},
{
id: 'kubernetes',
name: 'Kubernetes Pod Metrics',
description: 'Pod, node, and container health for your clusters.',
category: 'Infra',
href:
'https://signoz.io/docs/dashboards/dashboard-templates/kubernetes-pod-metrics-detailed/',
},
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Connections, throughput, and query performance.',
category: 'Databases',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/postgresql/',
},
{
id: 'redis',
name: 'Redis',
description: 'Memory, commands, and hit-rate for Redis instances.',
category: 'Databases',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/redis/',
},
{
id: 'nginx',
name: 'NGINX',
description: 'Request rate, connections, and error responses.',
category: 'Web servers',
href: 'https://signoz.io/docs/dashboards/dashboard-templates/nginx/',
},
];
const MOCK_TEMPLATES: DashboardTemplate[] = BASE_TEMPLATES.map((t) => ({
...t,
json: buildTemplateJson(t.name, t.description, t.category),
}));
// TODO(@AshwinBhatkal): replace with the real templates API when available.
// The small delay simulates the network round-trip so the loading state is
// exercised (a real API call won't resolve instantly).
const fetchDashboardTemplates = (): Promise<DashboardTemplate[]> =>
new Promise((resolve) => {
setTimeout(() => resolve(MOCK_TEMPLATES), 600);
});
export function useDashboardTemplates(
enabled: boolean,
): UseQueryResult<DashboardTemplate[]> {
return useQuery({
queryKey: ['dashboard-templates'],
queryFn: fetchDashboardTemplates,
enabled,
staleTime: Infinity,
});
}

View File

@@ -54,17 +54,5 @@
}
.submit {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
}
.cmdHint {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
background: var(--l2-background);
}

View File

@@ -8,9 +8,8 @@ import {
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
import { CornerDownLeft, Search } from '@signozhq/icons';
import cx from 'classnames';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import {
applyKeySuggestion,
@@ -41,8 +40,6 @@ function SearchBar({
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
@@ -58,11 +55,6 @@ function SearchBar({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onSubmit();
return;
}
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
@@ -98,7 +90,7 @@ function SearchBar({
<Button
variant="ghost"
color="secondary"
size="sm"
size="icon"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
@@ -108,15 +100,7 @@ function SearchBar({
}}
onClick={onSubmit}
>
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</Button>
}
value={value}

View File

@@ -138,14 +138,33 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
// validateLayouts rejects grid items referencing a panel that doesn't exist.
const maxLayoutsPerDashboard = 500
// validateLayouts validates the dashboard's layouts: bounded section count,
// per-item geometry, resolvable panel references, and no panel placed twice.
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
// runs here so its errors can name the layout by index.
func (d *DashboardSpec) validateLayouts() error {
if len(d.Layouts) > maxLayoutsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
}
// Could enforce this but skipping for now: panels in no grid item (orphans)
// are allowed.
// The frontend keys each grid item by its panel id, so placing one panel in
// two grid items collides; reject duplicate references dashboard-wide. Maps
// each referenced panel key to the path of the item that first placed it.
referencedPanels := make(map[string]string, len(d.Panels))
for li, layout := range d.Layouts {
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
if !ok {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if err := validateGridLayoutGeometry(grid, li); err != nil {
return err
}
for ii, item := range grid.Items {
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
if item.Content == nil {
@@ -158,6 +177,10 @@ func (d *DashboardSpec) validateLayouts() error {
if _, ok := d.Panels[key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
}
if firstPath, dup := referencedPanels[key]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
}
referencedPanels[key] = path
}
}
return nil

View File

@@ -299,19 +299,22 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
// p2 fills the right half of row 0, so p1 can only move to a fresh row
// without tripping overlap validation.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
// The first item used to live at y=0, now lives at y=6.
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
assert.Contains(t, raw, `"width":3`)
})
t.Run("rename layout row title", func(t *testing.T) {
@@ -321,11 +324,12 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
@@ -25,19 +26,19 @@ func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
require.NoError(t, err, "expected valid dashboard")
assert.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
require.NoError(t, err, "expected valid dashboard")
assert.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := unmarshalDashboard([]byte("not json"))
require.Error(t, err, "expected error for invalid JSON")
assert.Error(t, err, "expected error for invalid JSON")
}
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
@@ -60,11 +61,11 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown panel plugin kind",
assert.Contains(t, err.Error(), "unknown panel plugin kind",
"outer wrap should not smother the inner UnmarshalJSON message")
require.Contains(t, err.Error(), `"NonExistentPanel"`,
assert.Contains(t, err.Error(), `"NonExistentPanel"`,
"the offending value should still appear in the error")
require.Contains(t, err.Error(), "allowed values:",
assert.Contains(t, err.Error(), "allowed values:",
"the allowed-values hint should still appear in the error")
assert.True(t, errors.Ast(err, errors.TypeInvalidInput),
@@ -77,7 +78,7 @@ func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := unmarshalDashboard(data)
require.NoError(t, err, "expected valid")
assert.NoError(t, err, "expected valid")
}
func TestValidateOnlyVariables(t *testing.T) {
@@ -109,7 +110,7 @@ func TestValidateOnlyVariables(t *testing.T) {
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.NoError(t, err, "expected valid")
assert.NoError(t, err, "expected valid")
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
@@ -136,7 +137,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for duplicate variable name")
require.Contains(t, err.Error(), `duplicate variable name "env"`)
assert.Contains(t, err.Error(), `duplicate variable name "env"`)
}
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
@@ -163,19 +164,19 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.Error(t, err, "expected error for invalid variable name %q", name)
require.Contains(t, err.Error(), "is not a correct name")
assert.Contains(t, err.Error(), "is not a correct name")
})
}
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.NoError(t, err, "expected valid variable name %q", name)
assert.NoError(t, err, "expected valid variable name %q", name)
})
}
t.Run("digits only", func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName("123"))
require.Error(t, err)
require.Contains(t, err.Error(), "cannot contain only digits")
assert.Contains(t, err.Error(), "cannot contain only digits")
})
}
@@ -199,7 +200,7 @@ func TestInvalidatePanelKey(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel key")
require.Contains(t, err.Error(), "is not a correct name")
assert.Contains(t, err.Error(), "is not a correct name")
}
func TestInvalidateListVariableCrossFields(t *testing.T) {
@@ -225,30 +226,30 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
require.Error(t, err)
require.Contains(t, err.Error(), "customAllValue cannot be set")
assert.Contains(t, err.Error(), "customAllValue cannot be set")
})
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
require.Error(t, err)
require.Contains(t, err.Error(), "allowMultiple")
assert.Contains(t, err.Error(), "allowMultiple")
})
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
require.Error(t, err)
require.Contains(t, err.Error(), "allowMultiple")
assert.Contains(t, err.Error(), "allowMultiple")
})
t.Run("valid sort is accepted", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
require.NoError(t, err)
assert.NoError(t, err)
})
t.Run("unknown sort is rejected", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort")
assert.Contains(t, err.Error(), "unknown sort")
})
}
@@ -275,7 +276,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for empty variable name")
require.Contains(t, err.Error(), "name cannot be empty")
assert.Contains(t, err.Error(), "name cannot be empty")
})
}
}
@@ -414,7 +415,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -435,7 +436,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
assert.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
func TestInvalidateLayoutPanelReferences(t *testing.T) {
@@ -488,11 +489,11 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard(tt.data)
if tt.wantContain == "" {
require.NoError(t, err)
assert.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain)
})
}
}
@@ -570,7 +571,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -649,7 +650,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
}
})
}
@@ -874,7 +875,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -907,7 +908,7 @@ func TestThresholdLabelOptional(t *testing.T) {
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Len(t, spec.Thresholds, 1)
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
assert.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
})
}
}
@@ -924,7 +925,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel-without-queries to be rejected")
require.Contains(t, err.Error(), "panel must have one query")
assert.Contains(t, err.Error(), "panel must have one query")
}
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
@@ -942,7 +943,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with explicit empty queries array to be rejected")
require.Contains(t, err.Error(), "panel must have one query")
assert.Contains(t, err.Error(), "panel must have one query")
}
// Rendering multiple data sources in a single panel is supported via
@@ -965,7 +966,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with two top-level queries to be rejected")
require.Contains(t, err.Error(), "panel must have one query")
assert.Contains(t, err.Error(), "panel must have one query")
}
func TestValidateRequiredFields(t *testing.T) {
@@ -1053,7 +1054,7 @@ func TestValidateRequiredFields(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -1081,14 +1082,14 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
require.IsType(t, &TimeSeriesPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
assert.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
@@ -1131,8 +1132,8 @@ func TestNumberPanelDefaults(t *testing.T) {
spec := d.Panels["p1"].Spec.Plugin.Spec.(*NumberPanelSpec)
require.Len(t, spec.Thresholds, 1, "expected 1 threshold")
require.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
require.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
assert.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
assert.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
// Marshal back and verify defaults in JSON output.
output, err := json.Marshal(d)
@@ -1163,7 +1164,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardSpec
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
assert.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}
// TestStorageRoundTrip simulates the future DB store/load cycle:
@@ -1329,9 +1330,9 @@ func TestGenerateDashboardName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
got := generateDashboardName(tt.input)
require.NotEmpty(t, got)
require.LessOrEqual(t, len(got), 63)
require.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
assert.NotEmpty(t, got)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
if tt.wantPrefix == "" {
assert.Len(t, got, dashboardNameSuffixLen, "expected the bare random suffix")
@@ -1346,8 +1347,8 @@ func TestGenerateDashboardName(t *testing.T) {
t.Run("prefix is truncated to leave room for the suffix", func(t *testing.T) {
input := strings.Repeat("a", 100)
got := generateDashboardName(input)
require.LessOrEqual(t, len(got), 63)
require.Empty(t, validation.IsDNS1123Label(got))
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got))
assert.Equal(t, len(got), 63, "expected the result to be padded to the max DNS-1123 length")
})
@@ -1435,10 +1436,130 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
_, err := unmarshalDashboard(tc.data)
if tc.wantErr {
require.Error(t, err)
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.NoError(t, err)
}
})
}
}
func TestValidateGridGeometry(t *testing.T) {
tests := []struct {
scenario string
items []dashboard.GridItem
expectErrContain string
}{
{
scenario: "valid side-by-side items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "valid full-width item",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
expectErrContain: "",
},
{
scenario: "stacked items do not overlap",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "zero width",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
expectErrContain: "width must be at least 1",
},
{
scenario: "zero height",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
expectErrContain: "height must be at least 1",
},
{
scenario: "negative x",
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x must not be negative",
},
{
scenario: "negative y",
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
expectErrContain: "y must not be negative",
},
{
scenario: "width wider than grid",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
expectErrContain: "width (13) exceeds grid width 12",
},
{
scenario: "x at grid width",
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
expectErrContain: "x (12) must be less than grid width 12",
},
{
scenario: "x plus width overflows grid",
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x (8) + width (6) exceeds grid width 12",
},
{
scenario: "overlapping items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
expectErrContain: "items[0] and items[1] overlap",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
if test.expectErrContain == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectErrContain)
})
}
}
func TestValidateGridItemLimit(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
require.Error(t, err)
assert.Contains(t, err.Error(), "maximum is")
}
// Both panel refs are valid, so this errors only if geometry validation runs on
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "overlap")
}
// The frontend keys each grid item by its panel id, so the same panel placed by
// two grid items crashes the section; the backend rejects it dashboard-wide. The
// two items are side by side so they clear the overlap check first.
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "already placed")
// Both offending grid items are named.
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}

View File

@@ -322,6 +322,55 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return nil
}
const (
gridColumnCount = 12
maxItemsPerGridLayout = 100
)
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
// position, and intra-section overlap), which Perses does not. It reads only the
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
// solely to name the layout in error paths.
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
if len(spec.Items) > maxItemsPerGridLayout {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
}
for i, item := range spec.Items {
// The width/x bounds keep x+width small enough not to overflow.
switch {
case item.Width < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
case item.Height < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
case item.X < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
case item.Y < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
case item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
case item.X >= gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
case item.X+item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
}
// Could cap y/height but skipping for now: the grid grows vertically
// without limit (frontend autoSize), so "too big" has no natural bound.
}
// Two items overlap iff their rectangles intersect on both axes.
overlap := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
for i := 0; i < len(spec.Items); i++ {
for j := i + 1; j < len(spec.Items); j++ {
if overlap(spec.Items[i], spec.Items[j]) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
}
}
}
return nil
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},

View File

@@ -173,6 +173,125 @@ def test_create_rejects_too_many_tags(
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_invalid_grid_layout(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def panel(name: str) -> dict:
return {
"kind": "Panel",
"spec": {
"display": {"name": name},
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
},
}
},
}
],
},
}
# Two grid items reference valid, distinct panels but share cells, so the
# overlap is the only violation.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-overlap",
"spec": {
"display": {"name": "Rejects Overlap"},
"panels": {"p1": panel("P1"), "p2": panel("P2")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "overlap" in response.json()["error"]["message"]
# One panel placed by two grid items (side by side, so they clear the overlap
# check first). The frontend keys grid items by panel id, so this is rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-multiref",
"spec": {
"display": {"name": "Rejects Multiref"},
"panels": {"p1": panel("P1")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "already placed" in response.json()["error"]["message"]
# More grid items than allowed. The item-count check runs before the
# panel-ref check, so content-less items suffice here.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-too-many-items",
"spec": {
"display": {"name": "Rejects Too Many"},
"layouts": [
{
"kind": "Grid",
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "maximum" in response.json()["error"]["message"]
@pytest.mark.parametrize(
"params",
[