Compare commits

..

6 Commits

Author SHA1 Message Date
SagarRajput-7
33d70f79f9 Revert "fix(settings): guard against non-APIError in logs retention error state"
This reverts commit fd2cc3145b.
2026-06-12 02:14:16 +05:30
SagarRajput-7
81e9482aaa Merge branch 'main' into sentry-fixes 2026-06-11 18:42:57 +05:30
SagarRajput-7
ed111e10d9 fix(settings): test fixes 2026-06-11 17:16:55 +05:30
SagarRajput-7
bfb8334737 Merge branch 'main' into sentry-fixes 2026-06-11 16:59:16 +05:30
SagarRajput-7
fd2cc3145b fix(settings): guard against non-APIError in logs retention error state 2026-06-11 16:53:41 +05:30
SagarRajput-7
cbef40d209 fix(settings): disable RouteTab tab panel animation to prevent insertBefore crash 2026-06-11 16:37:28 +05:30
66 changed files with 1028 additions and 3616 deletions

View File

@@ -74,6 +74,17 @@ describe('RouteTab component', () => {
expect(history.location.pathname).toBe('/tab2');
});
it('does not animate tab panels to prevent CSSMotion DOM corruption', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
const tabContent = container.querySelector('.ant-tabs-content');
expect(tabContent).not.toHaveClass('ant-tabs-content-animated');
});
it('calls onChangeHandler on tab change', () => {
const onChangeHandler = jest.fn();
const history = createMemoryHistory();

View File

@@ -59,7 +59,7 @@ function RouteTab({
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}
defaultActiveKey={currentRoute?.key || activeKey}
animated
animated={{ inkBar: true, tabPane: false }}
items={items}
tabBarExtraContent={
showRightSection && (

View File

@@ -28,40 +28,43 @@ import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
import styles from '../DashboardDescription.module.scss';
interface DashboardActionsProps {
title: string;
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
editDashboard: boolean;
isAuthor: boolean;
addPanelPermission: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
}
function DashboardActions({
title,
dashboard,
handle,
isDashboardLocked,
editDashboard,
isAuthor,
addPanelPermission,
onAddPanel,
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id ?? '';
const title = dashboard.spec?.display?.name ?? '';
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
const deleteDashboardMutation = useDeleteDashboard(id);
useEffect(() => {
if (state.error) {
@@ -100,7 +103,7 @@ function DashboardActions({
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
if (canEdit) {
if (!isDashboardLocked && editDashboard) {
editGroup.push({
key: 'rename',
label: 'Rename',
@@ -156,6 +159,7 @@ function DashboardActions({
);
}, [
isDashboardLocked,
editDashboard,
isAuthor,
user.role,
dashboard.createdBy,
@@ -165,60 +169,58 @@ function DashboardActions({
exportJSON,
setCopy,
dashboardDataJSON,
canEdit,
]);
return (
<div className={styles.dashboardActionsContainer}>
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<div className={styles.dashboardActionsSecondary}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</DropdownMenuSimple>
{!isDashboardLocked && editDashboard && (
<>
<Button
variant="solid"
color="secondary"
size="icon"
prefix={<Ellipsis size="md" />}
testId="options"
/>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
New Panel
Configure
</Button>
)}
</div>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
title={`Delete dashboard "${title}"?`}
description="This action cannot be undone."
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}

View File

@@ -0,0 +1,210 @@
.dashboardDescriptionContainer {
box-shadow: none;
border: none;
background: unset;
color: var(--l2-foreground);
:global(.ant-card-body) {
padding: 0px;
}
.dashboardDetails {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.leftSection {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
height: 40px;
.dashboardImg {
height: 16px;
width: 16px;
}
.dashboardTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.clickableTitle {
cursor: pointer;
}
.titleEdit {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.titleInput {
flex: 1;
min-width: 0;
max-width: 70%;
}
.titleEditActionButton {
--button-height: auto;
--button-padding: 4px;
flex-shrink: 0;
}
.titleSaveActionButton {
--button-border-color: var(--text-forest-700);
--button-outlined-foreground: var(--text-forest-700);
}
.publicDashboardIcon {
margin-right: 4px;
}
}
.rightSection {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
height: 40px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
}
}
.dashboardTags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboardDescriptionSection {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
}
}
.dashboardSettings {
width: 191px;
height: 302px;
flex-shrink: 0;
:global(.ant-popover-inner) {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menuContent {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
button {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
}
}
.section1,
.section2 {
border-bottom: 1px solid var(--l1-border);
}
.deleteDashboard button {
color: var(--bg-cherry-400) !important;
}
}
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -0,0 +1,32 @@
import { Badge } from '@signozhq/ui/badge';
import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface DashboardMetaProps {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
return (
<>
{tags.length > 0 && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} className={styles.tag}>
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className={styles.dashboardDescriptionSection}>
{description}
</section>
)}
</>
);
}
export default DashboardMeta;

View File

@@ -0,0 +1,116 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from '../DashboardDescription.module.scss';
interface DashboardTitleProps {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditable: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardTitle({
title,
image,
isPublicDashboard,
isDashboardLocked,
isEditable,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardTitleProps): JSX.Element {
const canEdit = isEditable && !isDashboardLocked;
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
{isEditing ? (
<div className={styles.titleEdit}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.titleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
size="icon"
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="destructive"
size="icon"
className={styles.titleEditActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.clickableTitle]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} className={styles.publicDashboardIcon} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
);
}
export default DashboardTitle;

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
@@ -12,32 +13,34 @@ import type {
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
import styles from './DashboardPageToolbar.module.scss';
// import VariablesBar from '../VariablesBar/VariablesBar';
import styles from './DashboardDescription.module.scss';
interface DashboardPageToolbarProps {
interface DashboardDescriptionProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tags = useMemo(
() =>
@@ -48,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
@@ -55,6 +59,9 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
@@ -103,7 +110,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onSave: onNameSave,
});
const onAddPanel = useCallback((): void => {
const onEmptyWidgetHandler = useCallback((): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
@@ -111,15 +118,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
}, [id, setIsPanelTypeSelectionModalOpen]);
return (
<section className={styles.dashboardPageToolbarContainer}>
<div className={styles.dashboardInfoWithActions}>
<DashboardInfo
<Card className={styles.dashboardDescriptionContainer}>
<DashboardHeader title={title} image={image} />
<section className={styles.dashboardDetails}>
<DashboardTitle
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={false}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
isEditable={editDashboard}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
@@ -128,21 +135,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onCancel={cancel}
/>
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
editDashboard={editDashboard}
isAuthor={isAuthor}
onAddPanel={onAddPanel}
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={startEdit}
/>
</div>
</section>
</section>
<DashboardMeta tags={tags} description={description} />
</Card>
);
}
{
/* <VariablesBar dashboard={dashboard} /> */
}
export default DashboardPageToolbar;
export default DashboardDescription;

View File

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

View File

@@ -1,61 +0,0 @@
.dashboardInfo {
display: flex;
flex-direction: column;
gap: 8px;
width: 40%;
@media (min-width: 1280px) {
width: 30%;
}
}
.dashboardTitleContainer {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.dashboardImage {
flex-shrink: 0;
}
.dashboardTitle {
flex: 1;
min-width: 0;
max-width: fit-content;
color: var(--l1-foreground);
font-size: 18px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboardTitleHover {
cursor: text !important;
}
.dashboardTitleEditor {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.dashboardTitleInput {
flex: 1;
min-width: 0;
}
.dashboardTitleActionButton {
flex-shrink: 0;
}
.dashboardTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View File

@@ -1,141 +0,0 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import styles from './DashboardInfo.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
title: string;
image: string;
tags: string[];
description: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardInfo({
title,
image,
tags,
description,
isPublicDashboard,
isDashboardLocked,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardInfoProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.dashboardInfo}>
<div className={styles.dashboardTitleContainer}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
{hasTags && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
</div>
)}
{hasDescription && (
<Typography.Text color="muted">{description}</Typography.Text>
)}
</div>
);
}
export default DashboardInfo;

View File

@@ -1,20 +0,0 @@
.dashboardPageToolbarContainer {
position: sticky;
top: 0;
z-index: 10;
color: var(--l2-foreground);
background-color: var(--l1-background);
padding: 16px;
box-shadow: 0 2px 2px 0px var(--l2-border);
}
.dashboardPageToolbarSubContainer {
width: 100%;
}
.dashboardInfoWithActions {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
}

View File

@@ -1,8 +1,3 @@
.tabsContent {
padding-left: 0 !important;
padding-right: 0 !important;
}
.placeholder {
padding: 24px;
}
@@ -14,10 +9,3 @@
line-height: 1;
padding-top: 4px;
}
// shared "settings card" wrapper, used by the dashboard-info form and cross-panel sync
.settingsCard {
padding: 24px 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
}

View File

@@ -1,5 +1,6 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Radio, Tooltip } from 'antd';
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
@@ -12,9 +13,7 @@ import {
import { getAbsoluteUrl } from 'utils/basePath';
import cx from 'classnames';
import SegmentedControl from '../SegmentedControl/SegmentedControl';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './CrossPanelSync.module.scss';
import styles from '../GeneralSettings.module.scss';
interface CrossPanelSyncProps {
dashboardId: string;
@@ -27,15 +26,12 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
useSyncTooltipFilterMode(dashboardId);
return (
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelsSyncSectionTitle}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<TooltipSimple
side="top"
withPortal={false}
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
@@ -44,7 +40,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<Typography.Link
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
@@ -52,14 +48,15 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
>
Learn more
<ExternalLink size={12} />
</Typography.Link>
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</TooltipSimple>
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
@@ -69,18 +66,19 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<SegmentedControl
testId="cursor-sync-mode"
<Radio.Group
value={cursorSyncMode}
onChange={setCursorSyncMode}
options={[
{ label: 'No Sync', value: DashboardCursorSync.None },
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
{ label: 'Tooltip', value: DashboardCursorSync.Tooltip },
]}
/>
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
@@ -92,25 +90,24 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
matching ones highlighted
</Typography.Text>
</div>
<SegmentedControl
testId="sync-tooltip-filter-mode"
<Radio.Group
value={syncTooltipFilterMode}
onChange={(value): void => {
onChange={(e): void => {
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: value,
mode: e.target.value,
});
setSyncTooltipFilterMode(value);
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
options={[
{ label: 'All', value: SyncTooltipFilterMode.All },
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
]}
/>
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</div>
</Col>
);
}

View File

@@ -0,0 +1,85 @@
import { Dispatch, SetStateAction } from 'react';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
import { Col, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import styles from '../GeneralSettings.module.scss';
const { Option } = Select;
interface GeneralFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function GeneralForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: GeneralFormProps): JSX.Element {
return (
<Col className={styles.overviewSettings}>
<Space direction="vertical" className={styles.formSpace}>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={image}
onChange={onImageChange}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</Space>
</Col>
);
}
export default GeneralForm;

View File

@@ -0,0 +1,238 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px 16px;
}
.overviewSettings {
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.formSpace {
width: 100%;
display: flex;
flex-direction: column;
gap: 21px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
display: flex;
align-items: center;
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './UnsavedChangesFooter.module.scss';
import styles from '../GeneralSettings.module.scss';
interface UnsavedChangesFooterProps {
count: number;
@@ -29,13 +29,13 @@ function UnsavedChangesFooter({
{count > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionButtons}>
<div className={styles.footerActionBtns}>
<Button
variant="ghost"
color="secondary"
disabled={isSaving}
prefix={<X size={14} />}
onClick={onDiscard}
className={styles.discardBtn}
>
Discard
</Button>
@@ -47,6 +47,7 @@ function UnsavedChangesFooter({
prefix={<Check size={14} />}
testId="save-dashboard-config"
onClick={onSave}
className={styles.saveBtn}
>
{t('save')}
</Button>

View File

@@ -11,22 +11,22 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import GeneralForm from './GeneralForm/GeneralForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
import styles from './Overview.module.scss';
import styles from './GeneralSettings.module.scss';
interface OverviewProps {
interface GeneralSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function Overview({ dashboard }: OverviewProps): JSX.Element {
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
const id = dashboard.id;
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard.tags ?? []),
@@ -64,7 +64,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
value,
});
if (updatedTitle !== title && updatedTitle !== '') {
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
@@ -89,6 +89,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
]);
const onSaveHandler = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
@@ -107,7 +110,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let numberOfUnsavedChanges = 0;
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
@@ -117,10 +120,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
numberOfUnsavedChanges += 1;
n += 1;
}
});
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
setNumberOfUnsavedChanges(n);
}, [
description,
image,
@@ -141,7 +144,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
return (
<div className={styles.overviewContent}>
<DashboardInfoForm
<GeneralForm
title={updatedTitle}
description={updatedDescription}
image={updatedImage}
@@ -164,4 +167,4 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
);
}
export default Overview;
export default GeneralSettings;

View File

@@ -1,86 +0,0 @@
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
}
.crossPanelsSyncSectionTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
}
.crossPanelSyncInfoIcon {
cursor: help;
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
max-width: 200px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
}
.crossPanelSyncTooltipDocLink {
color: var(--primary-background);
font-size: 12px;
margin-top: 16px;
vertical-align: middle;
// typography override
--typography-text-display: inline-flex;
align-items: center;
gap: 5px;
}
.crossPanelSyncRow {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px dashed var(--l2-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex: 1 1 80px;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}

View File

@@ -1,80 +0,0 @@
.formSpace {
display: flex;
flex-direction: column;
gap: 20px;
}
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
}
.nameIconInput {
display: flex;
gap: 4px;
[data-radix-popper-content-wrapper] {
z-index: 1100 !important;
}
}
.dashboardImageInput {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.dashboardImageOptions {
min-width: min-content;
}
.dashboardImageSelectItem {
width: min-content;
span {
vertical-align: middle;
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// the V1 tags input ships borderless; give the field a visible box to match
.tagsField {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
// background: var(--l3-background);
}

View File

@@ -1,101 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
interface DashboardInfoFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function DashboardInfoForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: DashboardInfoFormProps): JSX.Element {
return (
<div className={settingsStyles.settingsCard}>
<div className={styles.formSpace}>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
value={image}
onChange={(value): void => onImageChange(value as string)}
>
<SelectTrigger className={styles.dashboardImageInput} />
<SelectContent
className={styles.dashboardImageOptions}
withPortal={false}
>
{Base64Icons.map((icon) => (
<SelectItem
key={icon}
value={icon}
className={styles.dashboardImageSelectItem}
>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
testId="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Tags</Typography>
<div className={styles.tagsField}>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</div>
</div>
</div>
);
}
export default DashboardInfoForm;

View File

@@ -1,5 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
}

View File

@@ -1,61 +0,0 @@
.segmented {
// override RadioGroup's default vertical grid; lay segments out connected
display: inline-flex;
grid-auto-flow: column;
gap: 0;
flex-shrink: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
}
.segment {
position: relative;
display: flex;
align-items: center;
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
// the visible segment is the radio's label (htmlFor-wired, so clicks register)
label {
display: flex;
align-items: center;
min-height: 24px;
padding: 6px 14px;
font-family: Inter;
font-size: 13px;
line-height: 20px;
color: var(--l2-foreground);
white-space: nowrap;
cursor: pointer;
user-select: none;
}
}
// collapse the radio circle into a transparent full-cell click target
.segmentInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
// hide the default radio dot/indicator
* {
display: none;
}
}
// highlight the selected segment as a raised, lighter pill (data-state is a
// stable Radix attribute). --l3-background is the lightest layer, so lift it
// further with a subtle foreground tint rather than going darker.
.segmentInput[data-state='checked'] + label {
background: var(--l3-background);
color: var(--l1-foreground);
font-weight: 500;
}

View File

@@ -1,51 +0,0 @@
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import styles from './SegmentedControl.module.scss';
export interface SegmentedControlOption<T extends string> {
label: string;
value: T;
}
interface SegmentedControlProps<T extends string> {
value: T;
options: SegmentedControlOption<T>[];
onChange: (value: T) => void;
testId?: string;
}
/**
* Connected pill segmented control composed on top of @signozhq/ui RadioGroup:
* the radio circle is collapsed into a transparent full-cell click target and
* the label becomes the visible segment (highlighted via the radio's stable
* `data-state="checked"`). Keeps radio semantics + keyboard nav.
*/
function SegmentedControl<T extends string>({
value,
options,
onChange,
testId,
}: SegmentedControlProps<T>): JSX.Element {
return (
<RadioGroup
className={styles.segmented}
value={value}
onChange={(next): void => onChange(next as T)}
testId={testId}
>
{options.map((option) => (
<RadioGroupItem
key={option.value}
value={option.value}
containerClassName={styles.segment}
className={styles.segmentInput}
testId={testId ? `${testId}-${option.value}` : undefined}
>
{option.label}
</RadioGroupItem>
))}
</RadioGroup>
);
}
export default SegmentedControl;

View File

@@ -1,39 +0,0 @@
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 16px;
position: fixed;
bottom: 0;
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionButtons {
display: flex;
gap: 8px;
}

View File

@@ -1,103 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
function DynamicVariableFields({
attribute,
signal,
onChange,
onPreview,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
label: name,
value: name,
})),
[keyData],
);
const { data: valueData } = useGetFieldValues({
signal,
name: attribute,
enabled: !!attribute,
});
useEffect(() => {
const payload = valueData?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
onPreview(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
}
testId="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
</>
);
}
export default DynamicVariableFields;

View File

@@ -1,93 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
}
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
onChange,
onPreview,
onError,
}: QueryVariableFieldsProps): JSX.Element {
const [isRunning, setIsRunning] = useState(false);
const runTest = async (): Promise<void> => {
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);
}
};
return (
<div className={styles.queryContainer}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Query</Typography.Text>
</div>
<div className={styles.editorWrap}>
<Editor
language="sql"
value={queryValue}
onChange={(value): void => onChange(value)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
</div>
<div className={styles.testRow}>
<Button
variant="solid"
color="primary"
size="sm"
loading={isRunning}
disabled={!queryValue}
onClick={runTest}
testId="variable-test-run"
>
Test Run Query
</Button>
</div>
</div>
);
}
export default QueryVariableFields;

View File

@@ -1,310 +0,0 @@
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
built on @signozhq components where possible. antd is retained only for the
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px 20px;
}
/* VariableItemRow */
.row {
display: flex;
gap: 1rem;
margin-bottom: 0;
}
/* LabelContainer */
.labelContainer {
width: 200px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.column {
flex-direction: column;
gap: 8px;
}
.input,
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
.input,
.textarea {
width: 100%;
}
.defaultInput {
width: 342px;
}
.errorText {
font-size: 12px;
color: var(--bg-amber-500);
}
/* Variable type segmented group */
.typeSection {
align-items: center;
justify-content: space-between;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
}
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
}
.typeBtn {
--button-height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
margin-left: 4px;
}
/* Query */
.queryContainer {
display: flex;
flex-flow: column wrap;
gap: 1rem;
min-width: 0;
margin-bottom: 0;
}
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.testRow {
display: flex;
margin-top: 8px;
}
/* Custom — antd Collapse */
.customSection {
margin-bottom: 0;
}
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
border-bottom: none;
}
:global(.ant-collapse-header) {
align-items: center;
gap: 8px;
height: 38px;
padding: 12px;
background: var(--l3-background);
border-radius: 3px 3px 0 0;
}
:global(.ant-collapse-header-text) {
display: flex;
align-items: center;
gap: 10px;
padding: 1px 2px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
:global(.ant-collapse-content-box) {
padding: 0;
}
:global(.comma-input) {
height: 109px;
border: none;
}
}
/* Textbox */
.textboxSection {
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
/* Preview strip */
.previewSection {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.previewLabel {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 3px 0 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.previewValues {
display: flex;
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
overflow-y: auto;
}
.previewValues [data-slot='badge'] {
height: 30px;
align-items: center;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.previewError {
color: var(--bg-amber-500);
}
/* Sort / multi / all / default rows */
.sortSection,
.multiSection,
.allOptionSection,
.dynamicSection {
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0;
}
.sortSection {
align-items: center;
}
.rowLabel {
width: 339px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
}
.defaultValueSection .label {
display: block;
margin-bottom: 2px;
}
.defaultValueDesc {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
line-height: 18px;
letter-spacing: -0.06px;
}
.searchSelect {
width: 100%;
}
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -1,351 +0,0 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list.
*/
function VariableForm({
initial,
existingNames,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
return (
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
</div>
) : null}
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
</div>
) : null}
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
testId="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
) : null}
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
);
}
export default VariableForm;

View File

@@ -1,99 +0,0 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -1,101 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.titleRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--l1-foreground);
}
.subtitle {
font-size: 12px;
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.rowMain {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
}
.rowActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -1,139 +0,0 @@
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
/** Index whose delete is awaiting inline confirmation, if any. */
confirmingIndex: number | null;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
}
function VariablesList({
variables,
canEdit,
confirmingIndex,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
return (
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
))}
</div>
);
}
export default VariablesList;

View File

@@ -1,147 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
return null;
}
return editing.index === null
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (model: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
}
setEditing(null);
persist(next);
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onSave={handleFormSave}
/>
);
}
// Master view — the variables list.
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
{variables.length === 0 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
) : (
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
)}
</div>
);
}
export default VariablesSettings;

View File

@@ -1,51 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {
save: (variables: VariableFormModel[]) => Promise<boolean>;
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
const save = useCallback(
async (variables: VariableFormModel[]): Promise<boolean> => {
if (!dashboardId) {
return false;
}
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };
}

View File

@@ -1,154 +0,0 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec?.display;
const common: VariableFormModel = {
...base,
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
hidden: display?.hidden ?? false,
};
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
textValue: spec.value ?? '',
textConstant: spec.constant ?? false,
};
}
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const listCommon: VariableFormModel = {
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
return {
...listCommon,
type: 'CUSTOM',
customValue: plugin.spec.customValue ?? '',
};
}
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
return {
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
return {
...listCommon,
type: 'QUERY',
queryValue:
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
? (plugin.spec.queryValue ?? '')
: '',
};
}
function buildPlugin(
model: VariableFormModel,
): DashboardtypesVariablePluginDTO {
switch (model.type) {
case 'CUSTOM':
return {
kind: CustomPluginKind['signoz/CustomVariable'],
spec: { customValue: model.customValue },
};
case 'DYNAMIC':
return {
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
default:
return {
kind: QueryPluginKind['signoz/QueryVariable'],
spec: { queryValue: model.queryValue },
};
}
}
/** Flat form model → DTO envelope (for persistence). */
export function formModelToDto(
model: VariableFormModel,
): DashboardtypesVariableDTO {
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
return {
kind: TextEnvelopeKind.TextVariable,
spec: {
name: model.name,
display,
value: model.textValue,
constant: model.textConstant,
},
};
}
return {
kind: ListEnvelopeKind.ListVariable,
spec: {
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
},
};
}
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
export function variableTypeOf(
dto: DashboardtypesVariableDTO,
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -1,78 +0,0 @@
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}

View File

@@ -1,22 +0,0 @@
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
* member sets-or-replaces, so it works whether or not `variables` already exists.
*/
export function buildVariablesPatch(
variables: DashboardtypesVariableDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
return [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: variables,
},
];
}

View File

@@ -1,21 +1,11 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import {
TabItemProps,
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import { Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import Overview from './Overview';
import PublicDashboardSettings from './PublicDashboard';
import VariablesSettings from './Variables';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import styles from './DashboardSettings.module.scss';
@@ -23,68 +13,42 @@ interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
enum TabKeys {
OVERVIEW = 'Overview',
VARIABLES = 'Variables',
PUBLISH = 'Publish',
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
}
const prefixIcons: Record<TabKeys, JSX.Element> = {
[TabKeys.OVERVIEW]: <Table size={14} />,
[TabKeys.VARIABLES]: <Braces size={14} />,
[TabKeys.PUBLISH]: <Globe size={14} />,
};
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const items: TabItemProps[] = useMemo(
const items = useMemo(
() => [
{
key: TabKeys.OVERVIEW,
label: TabKeys.OVERVIEW,
children: <Overview dashboard={dashboard} />,
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
},
{
key: TabKeys.VARIABLES,
label: TabKeys.VARIABLES,
children: <VariablesSettings dashboard={dashboard} />,
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
},
...(enablePublicDashboard
? [
{
key: TabKeys.PUBLISH,
label: TabKeys.PUBLISH,
children: <PublicDashboardSettings dashboard={dashboard} />,
disabled: user?.role !== USER_ROLES.ADMIN,
},
]
: []),
],
[enablePublicDashboard, dashboard, user?.role],
[dashboard],
);
return (
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>
{prefixIcons[key]}
{key}
</TabsTrigger>
))}
</TabsList>
{items.map((item) => (
<TabsContent value={item.key} key={item.key} className={styles.tabsContent}>
{item.children}
</TabsContent>
))}
</TabsRoot>
);
return <Tabs defaultValue="general" items={items} />;
}
export default DashboardSettings;

View File

@@ -1,98 +0,0 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValues(commaValuesParser(variable.customValue), variable.sort).map(
String,
)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
};
return (
<div className={styles.variable} data-testid={`variable-${variable.name}`}>
<Typography.Text className={styles.label}>${variable.name}</Typography.Text>
{renderControl()}
</div>
);
}
export default VariableSelector;

View File

@@ -1,29 +0,0 @@
.bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--l1-border);
}
.variable {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
.select {
min-width: 160px;
}
.input {
min-width: 160px;
}

View File

@@ -1,45 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
if (variables.length === 0) {
return null;
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}
export default VariablesBar;

View File

@@ -1,56 +0,0 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelectionMap } from './selectionTypes';
function formatQueryValue(val: string): string {
const num = Number(val);
if (!Number.isNaN(num) && Number.isFinite(num)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter expression from the OTHER dynamic variables' current
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
* dynamic variable's option list is scoped by its sibling selections. Variables
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
* the V1 dynamic-variable runtime.
*/
export function buildExistingDynamicVariableQuery(
variables: VariableFormModel[],
selections: VariableSelectionMap,
currentName: string,
): string {
const parts: string[] = [];
variables.forEach((variable) => {
if (
variable.name === currentName ||
variable.type !== 'DYNAMIC' ||
!variable.dynamicAttribute
) {
return;
}
const selection = selections[variable.name];
if (!selection || selection.allSelected) {
return;
}
const raw = Array.isArray(selection.value)
? selection.value
: [selection.value];
const valid = raw
.filter((v) => v !== null && v !== undefined && v !== '')
.map((v) => String(v));
if (valid.length > 0) {
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
}
});
return parts.join(' AND ');
}

View File

@@ -1,16 +0,0 @@
/** A user-selected variable value at runtime (not persisted to the spec). */
export type SelectedVariableValue =
| string
| number
| boolean
| (string | number | boolean)[]
| null;
export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
}
/** Selected values for a dashboard's variables, keyed by variable name. */
export type VariableSelectionMap = Record<string, VariableSelection>;

View File

@@ -1,31 +0,0 @@
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
export function isResolved(selection?: VariableSelection): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
return true;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,
): Record<string, SelectedVariableValue> {
const payload: Record<string, SelectedVariableValue> = {};
Object.entries(selection).forEach(([name, sel]) => {
payload[name] = sel.value;
});
return payload;
}

View File

@@ -1,79 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: variable.dynamicSignal,
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValues(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -1,89 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValues(data.payload.variableValues ?? [], variable.sort).map(
String,
);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -1,31 +0,0 @@
import { Input } from '@signozhq/ui/input';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/** Free-text variable input. */
function TextSelector({
selection,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
return (
<Input
className={styles.input}
value={typeof selection.value === 'string' ? selection.value : ''}
placeholder="Enter a value"
onChange={(e): void =>
onChange({ value: e.target.value, allSelected: false })
}
testId={testId}
/>
);
}
export default TextSelector;

View File

@@ -1,94 +0,0 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface ValueSelectorProps {
options: string[];
multiSelect: boolean;
showAllOption: boolean;
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
*/
function ValueSelector({
options,
multiSelect,
showAllOption,
loading,
selection,
onChange,
testId,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
);
if (multiSelect) {
const value = selection.allSelected
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={value}
loading={loading}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}
return (
<CustomSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={
selection.value == null || Array.isArray(selection.value)
? undefined
: String(selection.value)
}
loading={loading}
showSearch
placeholder="Select value"
onChange={(next): void =>
onChange({ value: next == null ? '' : String(next), allSelected: false })
}
/>
);
}
export default ValueSelector;

View File

@@ -1,41 +0,0 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection } from './selectionTypes';
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option — so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -1,116 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
return { value: def, allSelected: false };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
/**
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
}

View File

@@ -1,199 +0,0 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
* "depends on" another variable when its query text references that variable
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
* changes, its dependent QUERY variables must refetch. Ported from the V1
* dashboard-variables runtime; operates on the V2 flat variable model.
*/
export type VariableGraph = Record<string, string[]>;
export interface VariableDependencyData {
/** Topological order of variables (parents before children). */
order: string[];
/** Direct children (dependents) of each variable. */
graph: VariableGraph;
/** Direct parents of each variable. */
parentGraph: VariableGraph;
/** All transitive descendants of each variable (precomputed). */
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
/** Names of QUERY variables whose query references `variableName`. */
function getDependents(
variableName: string,
variables: VariableFormModel[],
): string[] {
return variables
.filter(
(v) =>
v.type === 'QUERY' &&
!!v.name &&
textContainsVariableReference(v.queryValue || '', variableName),
)
.map((v) => v.name);
}
/** variable name → its direct dependents (children). */
export function buildDependencies(
variables: VariableFormModel[],
): VariableGraph {
const graph: VariableGraph = {};
variables.forEach((v) => {
if (v.name) {
graph[v.name] = getDependents(v.name, variables);
}
});
return graph;
}
/** Invert a child graph into a parent graph. */
export function buildParentGraph(graph: VariableGraph): VariableGraph {
const parents: VariableGraph = {};
Object.keys(graph).forEach((node) => {
parents[node] = parents[node] ?? [];
});
Object.entries(graph).forEach(([node, children]) => {
children.forEach((child) => {
parents[child] = parents[child] ?? [];
parents[child].push(node);
});
});
return parents;
}
function collectCyclePath(
graph: VariableGraph,
start: string,
end: string,
): string[] {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
}
function detectCycle(
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
let cycleNodes: string[] | null = null;
(graph[node] || []).some((neighbor) => {
if (!visited.has(neighbor)) {
const found = detectCycle(graph, neighbor, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
} else if (recStack.has(neighbor)) {
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
}
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
export function buildDependencyData(
dependencies: VariableGraph,
): VariableDependencyData {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
Object.keys(dependencies).forEach((node) => {
inDegree[node] = inDegree[node] ?? 0;
adjList[node] = adjList[node] ?? [];
(dependencies[node] || []).forEach((child) => {
inDegree[child] = inDegree[child] ?? 0;
inDegree[child] += 1;
adjList[node].push(child);
});
});
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const found = detectCycle(dependencies, node, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
}
return false;
});
// Topological sort (Kahn's algorithm).
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
const order: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
order.push(current);
(adjList[current] || []).forEach((neighbor) => {
inDegree[neighbor] -= 1;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = order.length !== Object.keys(dependencies).length;
// Transitive descendants: walk topo order in reverse.
const transitiveDescendants: VariableGraph = {};
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
const desc = new Set<string>();
(adjList[node] || []).forEach((child) => {
desc.add(child);
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
});
transitiveDescendants[node] = Array.from(desc);
}
return {
order,
graph: adjList,
parentGraph: buildParentGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/** Compute the full dependency data straight from the variable list. */
export function computeVariableDependencies(
variables: VariableFormModel[],
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}

View File

@@ -0,0 +1,57 @@
.dashboardBreadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
padding-left: 8px;
.linkToPreviousPage {
// Collapse the design-system Button's fixed-height/padding box so it hugs
// the label like inline text (the breadcrumb is text, not a chunky button).
--button-height: auto;
--button-padding: 0;
--button-gap: 4px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
}
.currentPage {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.currentPage:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboardIconImage {
height: 14px;
width: 14px;
}
}

View File

@@ -0,0 +1,63 @@
import { useCallback } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import styles from './DashboardBreadcrumbs.module.scss';
interface DashboardBreadcrumbsProps {
title: string;
image: string;
}
function DashboardBreadcrumbs({
title,
image,
}: DashboardBreadcrumbsProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
if (dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
search: `?${dashboardsListQueryParamsString}`,
});
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate]);
return (
<div className={styles.dashboardBreadcrumbs}>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutGrid size={14} />}
onClick={goToListPage}
className={styles.linkToPreviousPage}
testId="dashboard-breadcrumb-list"
>
Dashboard
</Button>
<div>/</div>
<div className={styles.currentPage}>
<img
src={image}
alt="dashboard-icon"
className={styles.dashboardIconImage}
/>
<Typography.Text>{title}</Typography.Text>
</div>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -0,0 +1,9 @@
.dashboardHeader {
border-bottom: 1px solid var(--l1-border);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}

View File

@@ -0,0 +1,22 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import styles from './DashboardHeader.module.scss';
interface DashboardHeaderProps {
title: string;
image: string;
}
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
return (
<div className={styles.dashboardHeader}>
<DashboardBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -1,53 +0,0 @@
import { useMemo } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '@signozhq/ui/breadcrumb';
interface DashboardPageBreadcrumbsProps {
title: string;
image: string;
}
function DashboardPageBreadcrumbs({
title,
image,
}: DashboardPageBreadcrumbsProps): JSX.Element {
const dashboardPageLink = useMemo(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
return dashboardsListQueryParamsString
? `${ROUTES.ALL_DASHBOARD}?${dashboardsListQueryParamsString}`
: ROUTES.ALL_DASHBOARD;
}, []);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink icon={<LayoutGrid size={14} />} href={dashboardPageLink}>
Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
{title}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
export default DashboardPageBreadcrumbs;

View File

@@ -1,9 +0,0 @@
.dashboardPageHeader {
border-bottom: 1px solid var(--l2-border);
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 14px;
height: 48px;
width: 100%;
}

View File

@@ -1,25 +0,0 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import styles from './DashboardPageHeader.module.scss';
interface DashboardPageHeaderProps {
title: string;
image: string;
}
function DashboardPageHeader({
title,
image,
}: DashboardPageHeaderProps): JSX.Element {
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardPageHeader);

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
@@ -6,12 +6,10 @@ import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelec
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
import { Base64Icons } from './DashboardSettings/Overview/utils';
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -22,49 +20,32 @@ function DashboardContainer({
dashboard,
refetch,
}: DashboardContainerProps): JSX.Element {
useEffect(() => {
document.title = dashboard.name;
}, [dashboard.name]);
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const isEditable = !dashboard.locked && editDashboard;
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
});
}, [
dashboard.id,
dashboard.locked,
editDashboardPermission,
refetch,
setEditContext,
]);
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
}, [dashboard.id, isEditable, refetch, setEditContext]);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
<DashboardDescription
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
trigger; navigates to the widget editor route on selection. */}

View File

@@ -13,7 +13,7 @@ import type { GridItem } from './utils';
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/Overview and DashboardDescription).
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesPatchOpDTO;

View File

@@ -1,55 +0,0 @@
import type { StateCreator } from 'zustand';
import type {
VariableSelection,
VariableSelectionMap,
} from '../../VariablesBar/selectionTypes';
import type { DashboardStore } from '../useDashboardStore';
/**
* Runtime variable selection — the values the user picks in the variable bar.
* Keyed by dashboardId → variable name. Frontend-only and persisted to
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
setVariableValue: (
dashboardId: string,
name: string,
selection: VariableSelection,
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
}
export const createVariableSelectionSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
variableValues: {
...variableValues,
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
},
});
},
setVariableValues: (dashboardId, values): void => {
const { variableValues } = get();
set({
variableValues: { ...variableValues, [dashboardId]: values },
});
},
});
/** Selector: the selection map for a dashboard (empty if none). */
export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? {};

View File

@@ -9,36 +9,25 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
export type DashboardStore = EditContextSlice & CollapseSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
* - variable-selection: runtime variable values (frontend-only, persisted).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
},
),
);

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
@@ -15,6 +16,13 @@ function DashboardPageV2(): JSX.Element {
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;