Compare commits

...

6 Commits

Author SHA1 Message Date
Srikanth Chekuri
10cfa4853f Merge branch 'main' into nv/list-v2-no-error 2026-07-09 19:18:26 +05:30
Ashwin Bhatkal
c31642fe71 Merge remote-tracking branch 'origin/main' into nv/list-v2-no-error
# Conflicts:
#	frontend/src/pages/DashboardsListPageV2/components/ActionsPopover/ActionsPopover.tsx
#	frontend/src/pages/DashboardsListPageV2/components/DashboardRow/DashboardRow.tsx
2026-07-09 12:51:29 +05:30
Srikanth Chekuri
d3d4ba6c93 Merge branch 'main' into nv/list-v2-no-error 2026-07-08 19:55:10 +05:30
Ashwin Bhatkal
83a0a98c5d feat(dashboard-v2): surface legacy dashboards in the v2 list (#12024)
* feat(dashboard-v2): add legacy dashboard dialog

Surfaces a copyable dashboard ID and a Contact Support action for a
pre-v2 (legacy) dashboard that has no v2 spec to render.

* feat(dashboard-v2): hide v2-only actions for legacy dashboards

A legacy dashboard has no v2 spec, so view/open/copy-link/rename/
duplicate/lock don't apply. Gate them behind !isLegacy, leaving only
Delete (and dropping its now-orphan leading divider).

* feat(dashboard-v2): surface legacy dashboards in the v2 list

Flag legacy rows with a badge, block navigation, and open the legacy
dialog on click instead. Disable the pin action with an explanatory
tooltip and gate the row's actions menu to legacy-safe items.
2026-07-08 14:23:58 +05:30
Naman Verma
499b391284 chore: generate api specs 2026-07-08 14:16:12 +05:30
Naman Verma
66ed26723a fix: don't return error in v2 list dashboard api if there is a v1 dashboard 2026-07-08 14:16:12 +05:30
15 changed files with 884 additions and 223 deletions

View File

@@ -3148,6 +3148,8 @@ components:
type: string
image:
type: string
legacy:
type: boolean
locked:
type: boolean
name:
@@ -3180,6 +3182,7 @@ components:
- name
- tags
- spec
- legacy
- pinned
type: object
DashboardtypesListedDashboardV2:
@@ -3193,6 +3196,8 @@ components:
type: string
image:
type: string
legacy:
type: boolean
locked:
type: boolean
name:
@@ -3223,6 +3228,7 @@ components:
- name
- tags
- spec
- legacy
type: object
DashboardtypesListedDashboardV2Spec:
properties:

View File

@@ -5005,6 +5005,10 @@ export interface DashboardtypesListedDashboardForUserV2DTO {
* @type string
*/
image?: string;
/**
* @type boolean
*/
legacy: boolean;
/**
* @type boolean
*/
@@ -5080,6 +5084,10 @@ export interface DashboardtypesListedDashboardV2DTO {
* @type string
*/
image?: string;
/**
* @type boolean
*/
legacy: boolean;
/**
* @type boolean
*/

View File

@@ -0,0 +1,59 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ActionsPopover from './ActionsPopover';
const baseProps = {
link: '/dashboard/abc',
dashboardId: 'abc',
dashboardName: 'My Dashboard',
createdBy: 'someone-else@signoz.io',
isLocked: false,
tags: [],
canEdit: true,
onView: jest.fn(),
};
describe('ActionsPopover', () => {
it('shows the full set of actions for a v2 dashboard', async () => {
render(<ActionsPopover {...baseProps} />);
await userEvent.click(screen.getByTestId('dashboard-action-icon'));
await screen.findByTestId('dashboard-action-view');
expect(screen.getByTestId('dashboard-action-view')).toBeInTheDocument();
expect(
screen.getByTestId('dashboard-action-open-new-tab'),
).toBeInTheDocument();
expect(screen.getByTestId('dashboard-action-copy-link')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-action-rename')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-action-edit-tags')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-action-duplicate')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-action-delete')).toBeInTheDocument();
});
it('keeps only Delete for a legacy dashboard', async () => {
render(<ActionsPopover {...baseProps} isLegacy />);
await userEvent.click(screen.getByTestId('dashboard-action-icon'));
// Delete is the one action that still applies to a legacy blob.
await screen.findByTestId('dashboard-action-delete');
expect(screen.getByTestId('dashboard-action-delete')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-action-view')).not.toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-action-open-new-tab'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-action-copy-link'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-action-rename'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-action-edit-tags'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-action-duplicate'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('dashboard-action-lock')).not.toBeInTheDocument();
});
});

View File

@@ -46,6 +46,10 @@ interface Props {
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
canEdit: boolean;
onView: (event: React.MouseEvent<HTMLElement>) => void;
// A legacy (pre-v2) dashboard has no v2 spec, so the actions that operate on
// one (view, open, copy link, rename, edit tags, duplicate, lock) don't apply —
// only Delete is kept.
isLegacy?: boolean;
}
function ActionsPopover({
@@ -57,6 +61,7 @@ function ActionsPopover({
tags,
canEdit,
onView,
isLegacy = false,
}: Props): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const { safeNavigate } = useSafeNavigate();
@@ -109,128 +114,132 @@ function ActionsPopover({
// 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>
{canEdit && (
<Tooltip
placement="left"
title={
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
}
>
<span className={styles.menuItemWrap}>
{!isLegacy && (
<>
<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>
{canEdit && (
<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>
)}
{canEdit && (
<Tooltip
placement="left"
title={
isLocked
? 'This dashboard is locked, so its tags cannot be edited.'
: ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Tag size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsEditTagsOpen(true);
}
}}
testId="dashboard-action-edit-tags"
>
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
</Button>
</span>
</Tooltip>
)}
{canEdit && (
<Button
color="secondary"
className={styles.menuItem}
prefix={<PenLine size={14} />}
disabled={isLocked}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsRenameOpen(true);
}
runClone();
}}
testId="dashboard-action-rename"
testId="dashboard-action-duplicate"
>
Rename
Duplicate
</Button>
</span>
</Tooltip>
)}
{canEdit && (
<Tooltip
placement="left"
title={
isLocked
? 'This dashboard is locked, so its tags cannot be edited.'
: ''
}
>
<span className={styles.menuItemWrap}>
)}
{canToggleLock && (
<Button
color="secondary"
className={styles.menuItem}
prefix={<Tag size={14} />}
disabled={isLocked}
prefix={<LockKeyhole size={14} />}
loading={isTogglingLock}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsEditTagsOpen(true);
}
runLockToggle();
}}
testId="dashboard-action-edit-tags"
testId="dashboard-action-lock"
>
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</span>
</Tooltip>
)}
{canEdit && (
<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>
)}
</>
)}
{canEdit && (
<DeleteActionItem
@@ -238,6 +247,7 @@ function ActionsPopover({
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
showDivider={!isLegacy}
/>
)}
</div>

View File

@@ -23,6 +23,9 @@ interface Props {
dashboardName: string;
createdBy: string;
isLocked: boolean;
// Delete sits below the other actions, so it leads with a divider. When it's
// the only item (a legacy dashboard), the divider is suppressed.
showDivider?: boolean;
}
function DeleteActionItem({
@@ -30,6 +33,7 @@ function DeleteActionItem({
dashboardName,
createdBy,
isLocked,
showDivider = true,
}: Props): JSX.Element {
const { t } = useTranslation(['dashboard']);
const { user } = useAppContext();
@@ -100,7 +104,7 @@ function DeleteActionItem({
return (
<>
<Divider />
{showDivider && <Divider />}
<Tooltip placement="left" title={tooltip}>
<span className={styles.menuItemWrap}>
<Button

View File

@@ -55,6 +55,10 @@
overflow-wrap: anywhere;
}
.legacyBadge {
flex: none;
}
.tagsWithActions {
display: flex;
align-items: center;
@@ -71,6 +75,17 @@
color: var(--l3-foreground);
}
/* Wrap so the tooltip has a hoverable target even when the pin button is disabled
(a legacy dashboard can't be pinned). */
.pinButtonWrap {
display: inline-flex;
flex: none;
}
.pinButtonWrap button:disabled {
pointer-events: none;
}
.pinButton {
display: inline-flex;
align-items: center;

View File

@@ -0,0 +1,110 @@
import { render, screen, userEvent } from 'tests/test-utils';
import type { DashboardListItem } from '../../utils/helpers';
import DashboardRow from './DashboardRow';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockTogglePin = jest.fn();
jest.mock('../../hooks/usePinDashboard', () => ({
usePinDashboard: (): { togglePin: jest.Mock; isUpdating: boolean } => ({
togglePin: mockTogglePin,
isUpdating: false,
}),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
// Stub the actions menu so this suite stays focused on the row; the isLegacy
// wiring is asserted via the recorded prop, and the menu itself is covered by
// ActionsPopover's own tests.
jest.mock('../ActionsPopover/ActionsPopover', () => ({
__esModule: true,
default: ({ isLegacy }: { isLegacy?: boolean }): JSX.Element => (
<div data-testid="actions-popover" data-legacy={String(!!isLegacy)} />
),
}));
const makeDashboard = (
overrides: Partial<DashboardListItem> = {},
): DashboardListItem =>
({
id: 'dash-1',
legacy: false,
pinned: false,
locked: false,
image: '',
createdBy: 'alice@signoz.io',
updatedBy: 'alice@signoz.io',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
tags: [],
spec: { display: { name: 'My Dashboard' } },
...overrides,
}) as unknown as DashboardListItem;
const renderRow = (dashboard: DashboardListItem): void => {
render(
<DashboardRow
dashboard={dashboard}
index={0}
canEdit
showUpdatedAt={false}
showUpdatedBy={false}
/>,
);
};
describe('DashboardRow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('a v2 dashboard', () => {
it('navigates on click and shows no legacy badge', async () => {
renderRow(makeDashboard());
expect(screen.queryByTestId('dashboard-legacy-0')).not.toBeInTheDocument();
expect(screen.getByTestId('dashboard-pin-0')).not.toBeDisabled();
expect(screen.getByTestId('actions-popover')).toHaveAttribute(
'data-legacy',
'false',
);
await userEvent.click(screen.getByTestId('dashboard-title-0'));
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
});
});
describe('a legacy dashboard', () => {
it('badges the row, disables pin and gates the actions menu', () => {
renderRow(makeDashboard({ legacy: true }));
expect(screen.getByTestId('dashboard-legacy-0')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-pin-0')).toBeDisabled();
expect(screen.getByTestId('actions-popover')).toHaveAttribute(
'data-legacy',
'true',
);
});
it('opens the explanatory dialog instead of navigating', async () => {
renderRow(makeDashboard({ legacy: true }));
await userEvent.click(screen.getByTestId('dashboard-title-0'));
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(screen.getByTestId('legacy-dashboard-id')).toBeInTheDocument();
});
});
});

View File

@@ -1,4 +1,6 @@
import { useState } from 'react';
import TagBadge from 'components/TagBadge/TagBadge';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
@@ -18,6 +20,7 @@ import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import type { DashboardListItem } from '../../utils/helpers';
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
import ActionsPopover from '../ActionsPopover/ActionsPopover';
import LegacyDashboardDialog from '../LegacyDashboardDialog/LegacyDashboardDialog';
import styles from './DashboardRow.module.scss';
@@ -42,6 +45,11 @@ function DashboardRow({
const markViewed = useDashboardViewsStore((s) => s.markViewed);
const { togglePin, isUpdating } = usePinDashboard();
const [isLegacyDialogOpen, setIsLegacyDialogOpen] = useState(false);
// A legacy dashboard is stored in the pre-v2 shape and has no v2 spec, so it
// can't be opened in the new experience — clicking it explains this instead.
const isLegacy = !!dashboard.legacy;
const isPinned = !!dashboard.pinned;
const id = dashboard.id;
const name = dashboard.spec?.display?.name ?? '';
@@ -67,9 +75,17 @@ function DashboardRow({
return;
}
event.stopPropagation();
if (isLegacy) {
setIsLegacyDialogOpen(true);
void logEvent('Dashboard List: Clicked on legacy dashboard', {
dashboardId: id,
dashboardName: name,
});
return;
}
markViewed(id);
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
logEvent('Dashboard List: Clicked on dashboard', {
void logEvent('Dashboard List: Clicked on dashboard', {
dashboardId: id,
dashboardName: name,
});
@@ -77,9 +93,17 @@ function DashboardRow({
const onTogglePin = (event: React.MouseEvent<HTMLElement>): void => {
event.stopPropagation();
if (isLegacy) {
return;
}
togglePin(id, isPinned);
};
const pinLabel = isPinned ? 'Unpin dashboard' : 'Pin dashboard';
const pinTooltip = isLegacy
? "This dashboard isn't available in the new experience, so it can't be pinned"
: pinLabel;
// Only long titles are truncated, so only they need the full-name tooltip;
// wrapping conditionally avoids an empty hanging tooltip for short names.
const titleLink = (
@@ -95,120 +119,144 @@ function DashboardRow({
);
return (
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
{name.length > 50 ? (
<TooltipSimple title={name} side="bottom" disableHoverableContent>
{titleLink}
<>
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
{name.length > 50 ? (
<TooltipSimple title={name} side="bottom" disableHoverableContent>
{titleLink}
</TooltipSimple>
) : (
titleLink
)}
{isLegacy && (
<Badge
color="amber"
variant="outline"
className={styles.legacyBadge}
testId={`dashboard-legacy-${index}`}
>
Legacy
</Badge>
)}
</div>
<div className={styles.tagsWithActions}>
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
))}
{tags.length > 3 && (
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
)}
</div>
)}
</div>
{isLocked && (
<TooltipSimple
title="This dashboard is locked"
side="top"
disableHoverableContent
>
<span
className={styles.lockIcon}
data-testid={`dashboard-lock-${index}`}
>
<LockKeyhole size={14} />
</span>
</TooltipSimple>
) : (
titleLink
)}
</div>
<div className={styles.tagsWithActions}>
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
))}
{tags.length > 3 && (
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
)}
</div>
)}
</div>
{isLocked && (
<TooltipSimple
title="This dashboard is locked"
side="top"
disableHoverableContent
>
<span className={styles.lockIcon} data-testid={`dashboard-lock-${index}`}>
<LockKeyhole size={14} />
<TooltipSimple title={pinTooltip} side="top" disableHoverableContent>
<span className={styles.pinButtonWrap}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.pinButton, {
[styles.pinButtonOn]: isPinned && !isLegacy,
})}
aria-label={pinLabel}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating || isLegacy}
onClick={onTogglePin}
>
{isPinned ? (
<>
<Pin size={14} className={styles.pinnedIcon} />
<PinOff size={14} className={styles.unpinIcon} />
</>
) : (
<Pin size={14} />
)}
</Button>
</span>
</TooltipSimple>
)}
<TooltipSimple
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
side="top"
disableHoverableContent
>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.pinButton, { [styles.pinButtonOn]: 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>
</TooltipSimple>
<ActionsPopover
link={link}
dashboardId={id}
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
tags={tags}
canEdit={canEdit}
onView={onClickHandler}
/>
</div>
<div className={styles.details}>
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{formattedCreatedAt}</Typography.Text>
<ActionsPopover
link={link}
dashboardId={id}
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
tags={tags}
canEdit={canEdit}
onView={onClickHandler}
isLegacy={isLegacy}
/>
</div>
{createdBy && (
<div className={styles.createdBy}>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{createdBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{createdBy}</Typography.Text>
</div>
)}
{showUpdatedAt && (
<div className={styles.details}>
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{lastUpdatedLabel(updatedAt)}</Typography.Text>
<Typography.Text>{formattedCreatedAt}</Typography.Text>
</div>
)}
{updatedBy && showUpdatedBy && (
<div className={styles.updatedBy}>
<Typography.Text className={styles.byLabel}>
Last Updated By -
</Typography.Text>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{updatedBy.substring(0, 1).toUpperCase()}
</Typography.Text>
{createdBy && (
<div className={styles.createdBy}>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{createdBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{createdBy}</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{updatedBy}</Typography.Text>
</div>
)}
)}
{showUpdatedAt && (
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{lastUpdatedLabel(updatedAt)}</Typography.Text>
</div>
)}
{updatedBy && showUpdatedBy && (
<div className={styles.updatedBy}>
<Typography.Text className={styles.byLabel}>
Last Updated By -
</Typography.Text>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{updatedBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{updatedBy}</Typography.Text>
</div>
)}
</div>
</div>
</div>
{isLegacy && (
<LegacyDashboardDialog
open={isLegacyDialogOpen}
dashboardId={id}
dashboardName={name}
onClose={(): void => setIsLegacyDialogOpen(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,54 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
.description {
color: var(--l2-foreground);
font-size: 13px;
line-height: 20px;
strong {
color: var(--l1-foreground);
font-weight: 500;
}
}
.idField {
display: flex;
flex-direction: column;
gap: 6px;
}
.idLabel {
color: var(--l2-foreground);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.idRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 6px 6px 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
}
.idValue {
color: var(--l1-foreground);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
word-break: break-all;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,64 @@
import { render, screen, userEvent } from 'tests/test-utils';
import LegacyDashboardDialog from './LegacyDashboardDialog';
const mockCopy = jest.fn();
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, (value: string) => void] => [{}, mockCopy],
}));
const mockToastSuccess = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: (message: string): void => mockToastSuccess(message) },
}));
const mockContactSupport = jest.fn();
jest.mock('container/Integrations/utils', () => ({
handleContactSupport: (isCloud: boolean): void => mockContactSupport(isCloud),
}));
const DASHBOARD_ID = '0f9a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c';
describe('LegacyDashboardDialog', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const setup = (open = true): void => {
render(
<LegacyDashboardDialog
open={open}
dashboardId={DASHBOARD_ID}
dashboardName="My Legacy Dashboard"
onClose={jest.fn()}
/>,
);
};
it('surfaces the dashboard name and id', () => {
setup();
expect(screen.getByText('My Legacy Dashboard')).toBeInTheDocument();
expect(screen.getByTestId('legacy-dashboard-id')).toHaveTextContent(
DASHBOARD_ID,
);
});
it('copies the dashboard id and confirms with a toast', async () => {
setup();
await userEvent.click(screen.getByTestId('legacy-dashboard-copy-id'));
expect(mockCopy).toHaveBeenCalledWith(DASHBOARD_ID);
expect(mockToastSuccess).toHaveBeenCalledWith('Dashboard ID copied');
});
it('routes the user to support', async () => {
setup();
await userEvent.click(screen.getByTestId('legacy-dashboard-contact-support'));
expect(mockContactSupport).toHaveBeenCalledTimes(1);
});
it('renders nothing when closed', () => {
setup(false);
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,105 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight, Copy } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import styles from './LegacyDashboardDialog.module.scss';
interface LegacyDashboardDialogProps {
open: boolean;
dashboardId: string;
dashboardName: string;
onClose: () => void;
}
/**
* Explains why a legacy (pre-v2) dashboard can't be opened in the new experience
* and hands the user the dashboard ID to share with support. Legacy rows are
* surfaced by the list API with `legacy: true` but have no v2 spec to render.
*/
function LegacyDashboardDialog({
open,
dashboardId,
dashboardName,
onClose,
}: LegacyDashboardDialogProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { isCloudUser } = useGetTenantLicense();
const onCopyId = (): void => {
copyToClipboard(dashboardId);
toast.success('Dashboard ID copied');
};
return (
<DialogWrapper
title="This dashboard isn't available in the new experience"
open={open}
width="narrow"
onOpenChange={(next): void => {
if (!next) {
onClose();
}
}}
footer={
<div className={styles.footer}>
<Button
variant="ghost"
color="secondary"
size="md"
onClick={onClose}
testId="legacy-dashboard-close"
>
Close
</Button>
<Button
variant="solid"
color="primary"
size="md"
suffix={<ArrowUpRight size={14} />}
onClick={(): void => handleContactSupport(!!isCloudUser)}
testId="legacy-dashboard-contact-support"
>
Contact Support
</Button>
</div>
}
>
<div className={styles.body}>
<Typography.Text className={styles.description}>
<strong>{dashboardName || 'This dashboard'}</strong> hasn&apos;t been
migrated to the new dashboard experience yet, so it can&apos;t be opened
here. Share the dashboard ID below with support and we&apos;ll help you
move it over.
</Typography.Text>
<div className={styles.idField}>
<Typography.Text className={styles.idLabel}>Dashboard ID</Typography.Text>
<div className={styles.idRow}>
<Typography.Text
className={styles.idValue}
data-testid="legacy-dashboard-id"
>
{dashboardId}
</Typography.Text>
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Copy size={14} />}
aria-label="Copy dashboard ID"
onClick={onCopyId}
testId="legacy-dashboard-copy-id"
/>
</div>
</div>
</div>
</DialogWrapper>
);
}
export default LegacyDashboardDialog;

View File

@@ -71,7 +71,7 @@ func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *das
return nil, err
}
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags)
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags), nil
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
@@ -90,7 +90,7 @@ func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, user
return nil, err
}
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags)
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags), nil
}
func (module *module) fetchDashboardTags(ctx context.Context, orgID valuer.UUID, dashboardIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, []*tagtypes.Tag, error) {

View File

@@ -64,6 +64,13 @@ type (
ListableDashboard []*GettableDashboard
)
// readString reads a string field from the untyped data blob, yielding "" when
// the key is absent, null, or not a string.
func (d StorableDashboardData) readString(key string) string {
s, _ := d[key].(string)
return s
}
func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard, error) {
dashboardID, err := valuer.NewUUID(dashboard.ID)
if err != nil {

View File

@@ -122,6 +122,10 @@ type listedDashboardV2 struct {
Image string `json:"image,omitempty"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Spec listedDashboardV2Spec `json:"spec" required:"true"`
// Legacy marks a dashboard whose stored data is not yet in the v2 (perses)
// schema. Such rows are extracted best-effort from the v1 shape so the list
// still surfaces them; callers should route them to the legacy view.
Legacy bool `json:"legacy" required:"true"`
}
type listedDashboardV2Spec struct {
@@ -144,6 +148,41 @@ func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
}
}
// newListedDashboardForList builds the list view for a single dashboard. A row
// whose stored data is not in the v2 (perses) schema is extracted best-effort
// from the v1 shape and flagged Legacy, so one legacy or malformed dashboard
// can't fail the whole list.
func newListedDashboardForList(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
if v2, err := storable.ToDashboardV2(tags); err == nil {
return newListedDashboardV2(v2)
}
return newLegacyListedDashboardV2(storable, tags)
}
// newLegacyListedDashboardV2 pulls the display-relevant fields out of a v1
// dashboard blob. Column-backed fields (name, source, timestamps, …) come
// straight off the row; title/description/image/version are read leniently from
// the untyped v1 data, defaulting to zero when absent or of the wrong type.
func newLegacyListedDashboardV2(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
return &listedDashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
SchemaVersion: storable.Data.readString("version"),
Name: storable.Name,
Image: storable.Data.readString("image"),
Tags: tagtypes.NewGettableTagsFromTags(tags),
Spec: listedDashboardV2Spec{Display: Display{
Name: storable.Data.readString("title"),
Description: storable.Data.readString("description"),
}},
Legacy: true,
}
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
@@ -151,21 +190,17 @@ type ListableDashboardV2 struct {
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardV2 {
items := make([]*listedDashboardV2, len(dashboards))
for i, d := range dashboards {
v2, err := d.ToDashboardV2(tagsByEntity[d.ID])
if err != nil {
return nil, err
}
items[i] = newListedDashboardV2(v2)
items[i] = newListedDashboardForList(d, tagsByEntity[d.ID])
}
return &ListableDashboardV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
}
// listedDashboardForUserV2 is a listed dashboard plus the calling user's pin
@@ -190,15 +225,11 @@ type StorableDashboardWithPinInfo struct {
Pinned bool
}
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardForUserV2, error) {
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardForUserV2 {
items := make([]*listedDashboardForUserV2, len(rows))
for i, r := range rows {
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
items[i] = &listedDashboardForUserV2{
listedDashboardV2: *newListedDashboardV2(v2),
listedDashboardV2: *newListedDashboardForList(r.Dashboard, tagsByEntity[r.Dashboard.ID]),
Pinned: r.Pinned,
}
}
@@ -207,5 +238,5 @@ func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total i
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
}

View File

@@ -305,6 +305,146 @@ func TestNextCloneDisplayName(t *testing.T) {
}
}
func TestNewLegacyListedDashboardV2(t *testing.T) {
orgID := valuer.GenerateUUID()
dashboardID := valuer.GenerateUUID()
createdAt := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
t.Run("extracts display fields from a well-formed v1 blob", func(t *testing.T) {
storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: dashboardID},
TimeAuditable: types.TimeAuditable{CreatedAt: createdAt, UpdatedAt: updatedAt},
UserAuditable: types.UserAuditable{CreatedBy: "alice", UpdatedBy: "bob"},
OrgID: orgID,
Locked: true,
Source: SourceUser,
Name: "legacy-dashboard",
Data: StorableDashboardData{
"title": "Legacy Title",
"description": "an old v1 dashboard",
"image": "data:image/png;base64,xyz",
"version": "v5",
"widgets": []any{},
},
}
listed := newLegacyListedDashboardV2(storable, nil)
assert.True(t, listed.Legacy, "a non-v2 dashboard must be flagged legacy")
assert.Equal(t, storable.Identifiable, listed.Identifiable)
assert.Equal(t, storable.TimeAuditable, listed.TimeAuditable)
assert.Equal(t, storable.UserAuditable, listed.UserAuditable)
assert.Equal(t, orgID, listed.OrgID)
assert.True(t, listed.Locked)
assert.Equal(t, SourceUser, listed.Source)
assert.Equal(t, "legacy-dashboard", listed.Name, "name comes off the column, not the blob")
assert.Equal(t, "v5", listed.SchemaVersion)
assert.Equal(t, "data:image/png;base64,xyz", listed.Image)
assert.Equal(t, "Legacy Title", listed.Spec.Display.Name)
assert.Equal(t, "an old v1 dashboard", listed.Spec.Display.Description)
assert.Empty(t, listed.Tags, "v1 dashboards predate tags; nil converts to an empty, non-nil slice")
})
t.Run("yields zero values for absent or wrongly-typed fields", func(t *testing.T) {
storable := &StorableDashboard{
OrgID: orgID,
Source: SourceUser,
Data: StorableDashboardData{
"title": 42, // wrong type
"version": []any{"v5"}, // wrong type
"image": nil, // null
},
}
listed := newLegacyListedDashboardV2(storable, nil)
assert.True(t, listed.Legacy)
assert.Empty(t, listed.Spec.Display.Name)
assert.Empty(t, listed.SchemaVersion)
assert.Empty(t, listed.Image)
assert.Empty(t, listed.Tags, "nil tags convert to an empty, non-nil slice")
})
t.Run("tolerates entirely empty data", func(t *testing.T) {
storable := &StorableDashboard{OrgID: orgID, Source: SourceUser, Name: "bare"}
listed := newLegacyListedDashboardV2(storable, nil)
assert.True(t, listed.Legacy)
assert.Equal(t, "bare", listed.Name)
assert.Empty(t, listed.Spec.Display.Name)
})
}
func TestNewListableDashboardV2MixedSchemas(t *testing.T) {
orgID := valuer.GenerateUUID()
v2Dashboard := newTestDashboardV2(t, orgID, SourceUser)
v2Storable, err := v2Dashboard.ToStorableDashboard()
require.NoError(t, err)
v1Storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: orgID,
Source: SourceUser,
Name: "legacy-dashboard",
Data: StorableDashboardData{
"title": "Legacy Title",
"version": "v5",
"widgets": []any{},
},
}
tagsByEntity := map[valuer.UUID][]*tagtypes.Tag{
v2Storable.ID: v2Dashboard.Tags,
}
listable := NewListableDashboardV2([]*StorableDashboard{v2Storable, v1Storable}, 2, tagsByEntity, nil)
require.Len(t, listable.Dashboards, 2, "a single legacy dashboard must not drop rows from the list")
assert.Equal(t, int64(2), listable.Total)
v2Row := listable.Dashboards[0]
assert.False(t, v2Row.Legacy, "a v2 dashboard is not legacy")
assert.Equal(t, SchemaVersion, v2Row.SchemaVersion)
assert.Equal(t, "Test Dashboard", v2Row.Spec.Display.Name)
v1Row := listable.Dashboards[1]
assert.True(t, v1Row.Legacy, "a v1 dashboard is flagged legacy")
assert.Equal(t, "v5", v1Row.SchemaVersion)
assert.Equal(t, "Legacy Title", v1Row.Spec.Display.Name)
assert.Equal(t, "legacy-dashboard", v1Row.Name)
}
func TestNewListableDashboardForUserV2MixedSchemas(t *testing.T) {
orgID := valuer.GenerateUUID()
v2Dashboard := newTestDashboardV2(t, orgID, SourceUser)
v2Storable, err := v2Dashboard.ToStorableDashboard()
require.NoError(t, err)
v1Storable := &StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: orgID,
Source: SourceUser,
Name: "legacy-dashboard",
Data: StorableDashboardData{"title": "Legacy Title", "version": "v5"},
}
rows := []*StorableDashboardWithPinInfo{
{Dashboard: v2Storable, Pinned: true},
{Dashboard: v1Storable, Pinned: false},
}
listable := NewListableDashboardForUserV2(rows, 2, nil, nil)
require.Len(t, listable.Dashboards, 2)
assert.False(t, listable.Dashboards[0].Legacy)
assert.True(t, listable.Dashboards[0].Pinned)
assert.True(t, listable.Dashboards[1].Legacy)
assert.False(t, listable.Dashboards[1].Pinned)
}
func TestDashboardV2StorableRoundTrip(t *testing.T) {
orgID := valuer.GenerateUUID()
original := newTestDashboardV2(t, orgID, SourceIntegration)