Compare commits

..

4 Commits

Author SHA1 Message Date
Nityananda Gohain
6a7afa71be Merge branch 'main' into issue_8965 2026-05-22 21:43:07 +05:30
nityanandagohain
3a8a9eaef3 fix: cleanup query from tests 2026-05-22 21:40:33 +05:30
nityanandagohain
ad34f2c620 fix: update comments 2026-05-22 13:10:59 +05:30
nityanandagohain
aa140b3456 feat: trace based filters for logs, supporting aggregations as well 2026-05-21 18:47:25 +05:30
99 changed files with 557 additions and 7139 deletions

View File

@@ -11,5 +11,4 @@ export enum FeatureKeys {
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
DASHBOARD_V2 = 'dashboard_v2',
}

View File

@@ -42,5 +42,4 @@ export enum LOCALSTORAGE {
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
}

View File

@@ -1,43 +0,0 @@
.settings-container-root {
.ant-drawer-wrapper-body {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
.ant-drawer-header-title {
gap: 16px;
.ant-drawer-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
.ant-drawer-close {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
.ant-drawer-body {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -1,34 +0,0 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import './SettingsDrawer.styles.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName="settings-container-root"
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -1,411 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
Check,
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
Globe,
LockKeyhole,
PenLine,
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
import DashboardSettingsV2 from '../DashboardSettings';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardVariablesV2 from '../DashboardVariablesV2';
import SettingsDrawer from './SettingsDrawer';
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
import type { V2Dashboard } from '../utils';
interface DashboardDescriptionV2Props {
dashboard: V2Dashboard | undefined;
handle: FullScreenHandle;
onRefetch: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
const { dashboard, handle, onRefetch } = props;
const id = dashboard?.id ?? '';
const isDashboardLocked = !!dashboard?.locked;
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const title = dashboard?.data?.spec?.display?.name ?? '';
const description = dashboard?.data?.spec?.display?.description ?? '';
const image = dashboard?.data?.metadata?.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard?.data?.metadata?.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard?.data?.metadata?.tags],
);
const dashboardVariables = dashboard?.data?.spec?.variables ?? [];
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
useState<boolean>(false);
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
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 { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const { t } = useTranslation(['dashboard', 'common']);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
if (dashboard) setUpdatedTitle(title);
}, [dashboard, title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) return;
setIsDashbordSettingsOpen(false);
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
notifications.success({ message: 'Dashboard unlocked' });
} else {
await lockDashboardV2({ id });
notifications.success({ message: 'Dashboard locked' });
}
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
notifications.success({ message: 'Dashboard renamed successfully' });
setIsRenameDashboardOpen(false);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
notifications.info({
message: 'V2 panel editor coming next',
});
};
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
notifications.error({
message: t('something_went_wrong', { ns: 'common' }),
});
}
if (state.value) {
notifications.success({ message: t('success', { ns: 'common' }) });
}
}, [state.error, state.value, t, notifications]);
const dashboardDataJSON = (): string =>
JSON.stringify(dashboard?.data ?? {}, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const onConfigureClick = (): void => {
setIsSettingsDrawerOpen(true);
};
const onSettingsDrawerClose = (): void => {
setIsSettingsDrawerOpen(false);
};
return (
<Card className="dashboard-description-container">
<DashboardHeader title={title} image={image} />
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />
<Tooltip title={title.length > 30 ? title : ''}>
<Typography.Text
className="dashboard-title"
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</Tooltip>
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
rootClassName="dashboard-settings"
content={
<div className="menu-content">
<section className="section-1">
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
dashboard?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboard?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</Tooltip>
)}
{!isDashboardLocked && editDashboard && (
<Button
type="text"
icon={<PenLine size={14} />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
type="text"
icon={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className="section-2">
<Button
type="text"
icon={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashbordSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashbordSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboard?.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
icon={<Ellipsis size={14} />}
type="text"
className="icons"
data-testid="options"
/>
</Popover>
{!isDashboardLocked && editDashboard && (
<>
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<Plus size="md" />}
type="primary"
data-testid="add-panel-header"
>
New Panel
</Button>
)}
</div>
</section>
{tags.length > 0 && (
<div className="dashboard-tags">
{tags.map((tag) => (
<Tag key={tag} className="tag">
{tag}
</Tag>
))}
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">{description}</section>
)}
{dashboardVariables.length > 0 && (
<section className="dashboard-variables">
<DashboardVariablesV2
dashboardId={id}
variables={dashboardVariables}
/>
</section>
)}
<Modal
open={isRenameDashboardOpen}
title="Rename Dashboard"
onOk={onNameChangeHandler}
onCancel={(): void => {
setIsRenameDashboardOpen(false);
}}
rootClassName="rename-dashboard"
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={onNameChangeHandler}
disabled={isRenameLoading}
>
Rename Dashboard
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => setIsRenameDashboardOpen(false)}
>
Cancel
</Button>
</div>
}
>
<div className="dashboard-content">
<Typography.Text className="name-text">Enter a new name</Typography.Text>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</div>
</Modal>
</Card>
);
}
export default DashboardDescriptionV2;

View File

@@ -1,227 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.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 {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,357 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
import type { V2Dashboard } from '../../utils';
const { Option } = Select;
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
// AddTags component (which expects string[]), and back on save.
//
// V2 tags require both `key` and `value` to be non-empty server-side
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
return tagStrings
.map((s) => {
const trimmed = s.trim();
const idx = trimmed.indexOf(':');
if (idx === -1) return { key: trimmed, value: trimmed };
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
return { key, value: value || key };
})
.filter((t) => t.key.length > 0);
}
function GeneralDashboardSettingsV2({
dashboard,
onRefetch,
}: Props): JSX.Element {
const id = dashboard?.id ?? '';
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(id);
const title = dashboard?.data?.spec?.display?.name ?? '';
const description = dashboard?.data?.spec?.display?.description ?? '';
const image = dashboard?.data?.metadata?.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard?.data?.metadata?.tags ?? []),
[dashboard?.data?.metadata?.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
const [updatedDescription, setUpdatedDescription] = useState<string>(
description,
);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] = useState<number>(
0,
);
const { t } = useTranslation('common');
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
// Sync state when dashboard refetches after a save
useEffect(() => {
setUpdatedTitle(title);
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard?.updatedAt]);
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/metadata/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/metadata/tags', stringsToTags(updatedTags)));
}
return ops;
};
const onSaveHandler = async (): Promise<void> => {
if (!id) return;
const ops = buildPatch();
if (ops.length === 0) return;
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
notifications.success({ message: 'Dashboard updated' });
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
};
useEffect(() => {
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) n += 1;
});
setNumberOfUnsavedChanges(n);
}, [
description,
image,
tagsAsStrings,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = (): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDescription(description);
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '21px',
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{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={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
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}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
disabled={isSaving}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
>
Discard
</Button>
<Button
style={{ margin: '16px 0' }}
disabled={isSaving}
loading={isSaving}
icon={<Check size={14} />}
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
)}
</div>
);
}
export default GeneralDashboardSettingsV2;

View File

@@ -1,20 +0,0 @@
import { Button as ButtonComponent, Drawer } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
margin-top: 0.5rem;
`;
export const Button = styled(ButtonComponent)`
&&& {
display: flex;
align-items: center;
}
`;
export const DrawerContainer = styled(Drawer)`
.ant-drawer-header {
padding: 0;
border: none;
}
`;

File diff suppressed because one or more lines are too long

View File

@@ -1,46 +0,0 @@
import { Collapse, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
customValue: string;
onChange: (v: string) => void;
error?: string;
}
function CustomFields({ customValue, onChange, error }: Props): JSX.Element {
return (
<VariableItemRow className="variable-custom-section">
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<>
<Input.TextArea
value={customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onChange(e.target.value)}
data-testid="variable-custom-value-v2"
/>
{error ? (
<div>
<Typography.Text color="warning">{error}</Typography.Text>
</div>
) : null}
</>
),
},
]}
/>
</VariableItemRow>
);
}
export default CustomFields;

View File

@@ -1,74 +0,0 @@
import { useCallback, useMemo } from 'react';
import DynamicVariable from 'container/DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/DynamicVariable/DynamicVariable';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
interface Props {
dynamicName: string;
dynamicSignal: TelemetrytypesSignalDTO | undefined;
onNameChange: (v: string) => void;
onSignalChange: (v: TelemetrytypesSignalDTO | undefined) => void;
error?: string;
}
// V1 DynamicVariable stores the source as a UI-friendly label:
// 'All telemetry' | 'Logs' | 'Metrics' | 'Traces'. V2 stores the API enum
// signal value: undefined (= all) | 'metrics' | 'traces' | 'logs'. We convert
// at this boundary so the V1 component can stay untouched.
const ALL_TELEMETRY = 'All telemetry';
function signalToV1Source(
signal: TelemetrytypesSignalDTO | undefined,
): string {
if (signal === TelemetrytypesSignalDTO.logs) return 'Logs';
if (signal === TelemetrytypesSignalDTO.metrics) return 'Metrics';
if (signal === TelemetrytypesSignalDTO.traces) return 'Traces';
return ALL_TELEMETRY;
}
function v1SourceToSignal(
source: string,
): TelemetrytypesSignalDTO | undefined {
if (source === 'Logs') return TelemetrytypesSignalDTO.logs;
if (source === 'Metrics') return TelemetrytypesSignalDTO.metrics;
if (source === 'Traces') return TelemetrytypesSignalDTO.traces;
return undefined;
}
function DynamicFields({
dynamicName,
dynamicSignal,
onNameChange,
onSignalChange,
error,
}: Props): JSX.Element {
const v1Value = useMemo(
() => ({ name: dynamicName, value: signalToV1Source(dynamicSignal) }),
[dynamicName, dynamicSignal],
);
const setV1Value: React.Dispatch<
React.SetStateAction<{ name: string; value: string } | undefined>
> = useCallback(
(action) => {
const next =
typeof action === 'function' ? action(v1Value) : action;
if (!next) return;
if (next.name !== dynamicName) onNameChange(next.name);
const nextSignal = v1SourceToSignal(next.value);
if (nextSignal !== dynamicSignal) onSignalChange(nextSignal);
},
[v1Value, dynamicName, dynamicSignal, onNameChange, onSignalChange],
);
return (
<div className="variable-dynamic-section">
<DynamicVariable
setDynamicVariablesSelectedValue={setV1Value}
dynamicVariablesSelectedValue={v1Value}
errorAttributeKeyMessage={error}
/>
</div>
);
}
export default DynamicFields;

View File

@@ -1,43 +0,0 @@
import { Button } from 'antd';
import { Check, X } from '@signozhq/icons';
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
saving: boolean;
canSave: boolean;
onSave: () => void;
onCancel: () => void;
}
function Footer({ saving, canSave, onSave, onCancel }: Props): JSX.Element {
return (
<div className="variable-item-footer">
<VariableItemRow>
<Button
type="default"
onClick={onCancel}
icon={<X size={14} />}
className="footer-btn-discard"
disabled={saving}
data-testid="variable-cancel-v2"
>
Discard
</Button>
<Button
type="primary"
onClick={onSave}
icon={<Check size={14} />}
className="footer-btn-save"
loading={saving}
disabled={!canSave || saving}
data-testid="variable-save-v2"
>
Save Variable
</Button>
</VariableItemRow>
</div>
);
}
export default Footer;

View File

@@ -1,80 +0,0 @@
import type { V2VariableKind } from '../types';
import AllOptionRow from './ListOptions/AllOptionRow';
import CapturingRegexpRow from './ListOptions/CapturingRegexpRow';
import CustomAllValueRow from './ListOptions/CustomAllValueRow';
import DefaultValueRow from './ListOptions/DefaultValueRow';
import MultiSelectRow from './ListOptions/MultiSelectRow';
import SortRow from './ListOptions/SortRow';
interface Props {
kind: V2VariableKind;
allowAllValue: boolean;
allowMultiple: boolean;
sort: string;
defaultValue: string;
customAllValue: string;
capturingRegexp: string;
previewValues: string[];
onAllowAllChange: (v: boolean) => void;
onAllowMultipleChange: (v: boolean) => void;
onSortChange: (v: string) => void;
onDefaultValueChange: (v: string) => void;
onCustomAllValueChange: (v: string) => void;
onCapturingRegexpChange: (v: string) => void;
}
function ListBasicOptions({
kind,
allowAllValue,
allowMultiple,
sort,
defaultValue,
customAllValue,
capturingRegexp,
previewValues,
onAllowAllChange,
onAllowMultipleChange,
onSortChange,
onDefaultValueChange,
onCustomAllValueChange,
onCapturingRegexpChange,
}: Props): JSX.Element {
return (
<>
<SortRow sort={sort} onChange={onSortChange} />
<MultiSelectRow
allowMultiple={allowMultiple}
onChange={(v): void => {
onAllowMultipleChange(v);
if (!v) onAllowAllChange(false);
}}
/>
{allowMultiple && kind !== 'DYNAMIC' ? (
<AllOptionRow
allowAllValue={allowAllValue}
onChange={onAllowAllChange}
/>
) : null}
{allowAllValue ? (
<CustomAllValueRow
customAllValue={customAllValue}
onChange={onCustomAllValueChange}
/>
) : null}
{kind === 'QUERY' || kind === 'DYNAMIC' ? (
<CapturingRegexpRow
capturingRegexp={capturingRegexp}
onChange={onCapturingRegexpChange}
/>
) : null}
<DefaultValueRow
kind={kind}
defaultValue={defaultValue}
previewValues={previewValues}
onChange={onDefaultValueChange}
/>
</>
);
}
export default ListBasicOptions;

View File

@@ -1,28 +0,0 @@
import { Switch } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
allowAllValue: boolean;
onChange: (v: boolean) => void;
}
function AllOptionRow({ allowAllValue, onChange }: Props): JSX.Element {
return (
<VariableItemRow className="all-option-section">
<LabelContainer>
<Typography className="typography-variables">
Include an option for ALL values
</Typography>
</LabelContainer>
<Switch
checked={allowAllValue}
onChange={onChange}
data-testid="variable-allow-all-v2"
/>
</VariableItemRow>
);
}
export default AllOptionRow;

View File

@@ -1,43 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
capturingRegexp: string;
onChange: (v: string) => void;
}
function CapturingRegexpRow({
capturingRegexp,
onChange,
}: Props): JSX.Element {
return (
<VariableItemRow className="capturing-regexp-section">
<LabelContainer>
<Typography
className="typography-variables"
style={{ display: 'block' }}
>
Capturing regex
</Typography>
<Typography
className="default-value-description"
style={{ display: 'block' }}
>
Regex applied to each value; the first capture group becomes the
selectable option.
</Typography>
</LabelContainer>
<Input
value={capturingRegexp}
placeholder="e.g. env-(.*)-\\d+"
onChange={(e): void => onChange(e.target.value)}
style={{ width: 400 }}
data-testid="variable-capturing-regexp-v2"
/>
</VariableItemRow>
);
}
export default CapturingRegexpRow;

View File

@@ -1,42 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
customAllValue: string;
onChange: (v: string) => void;
}
function CustomAllValueRow({
customAllValue,
onChange,
}: Props): JSX.Element {
return (
<VariableItemRow className="custom-all-value-section">
<LabelContainer>
<Typography
className="typography-variables"
style={{ display: 'block' }}
>
Custom &quot;ALL&quot; value
</Typography>
<Typography
className="default-value-description"
style={{ display: 'block' }}
>
Literal value emitted when the user picks ALL (e.g. * or .*).
</Typography>
</LabelContainer>
<Input
value={customAllValue}
placeholder="Leave blank to send the full union of values"
onChange={(e): void => onChange(e.target.value)}
style={{ width: 400 }}
data-testid="variable-custom-all-value-v2"
/>
</VariableItemRow>
);
}
export default CustomAllValueRow;

View File

@@ -1,43 +0,0 @@
import CustomSelect from 'components/NewSelect/CustomSelect';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
import type { V2VariableKind } from '../../types';
interface Props {
kind: V2VariableKind;
defaultValue: string;
previewValues: string[];
onChange: (v: string) => void;
}
function DefaultValueRow({
kind,
defaultValue,
previewValues,
onChange,
}: Props): JSX.Element {
const description =
kind === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value';
return (
<VariableItemRow className="default-value-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
<Typography className="default-value-description">
{description}
</Typography>
</LabelContainer>
<CustomSelect
placeholder="Select a default value"
value={defaultValue}
onChange={(v): void => onChange((v as string) ?? '')}
options={previewValues.map((v) => ({ label: v, value: v }))}
/>
</VariableItemRow>
);
}
export default DefaultValueRow;

View File

@@ -1,28 +0,0 @@
import { Switch } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
allowMultiple: boolean;
onChange: (v: boolean) => void;
}
function MultiSelectRow({ allowMultiple, onChange }: Props): JSX.Element {
return (
<VariableItemRow className="multiple-values-section">
<LabelContainer>
<Typography className="typography-variables">
Enable multiple values to be checked
</Typography>
</LabelContainer>
<Switch
checked={allowMultiple}
onChange={onChange}
data-testid="variable-allow-multiple-v2"
/>
</VariableItemRow>
);
}
export default MultiSelectRow;

View File

@@ -1,29 +0,0 @@
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
import { SORT_OPTIONS } from '../../types';
interface Props {
sort: string;
onChange: (v: string) => void;
}
function SortRow({ sort, onChange }: Props): JSX.Element {
return (
<VariableItemRow className="sort-values-section">
<LabelContainer>
<Typography className="typography-variables">Sort Values</Typography>
</LabelContainer>
<Select
value={sort}
onChange={onChange}
options={SORT_OPTIONS}
className="sort-input"
data-testid="variable-sort-v2"
/>
</VariableItemRow>
);
}
export default SortRow;

View File

@@ -1,59 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
name: string;
description: string;
onNameChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
nameError?: string;
}
function NameDisplay({
name,
description,
onNameChange,
onDescriptionChange,
nameError,
}: Props): JSX.Element {
return (
<>
<VariableItemRow className="variable-name-section">
<LabelContainer>
<Typography className="typography-variables">Name</Typography>
</LabelContainer>
<div>
<Input
placeholder="Unique name of the variable"
value={name}
className="name-input"
onChange={(e): void => onNameChange(e.target.value)}
data-testid="variable-name-v2"
/>
{nameError ? (
<div>
<Typography.Text color="warning">{nameError}</Typography.Text>
</div>
) : null}
</div>
</VariableItemRow>
<VariableItemRow className="variable-description-section">
<LabelContainer>
<Typography className="typography-variables">Description</Typography>
</LabelContainer>
<Input.TextArea
value={description}
placeholder="Enter a description for the variable"
className="description-input"
rows={3}
onChange={(e): void => onDescriptionChange(e.target.value)}
data-testid="variable-description-v2"
/>
</VariableItemRow>
</>
);
}
export default NameDisplay;

View File

@@ -1,33 +0,0 @@
import { Tag } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { orange } from '@ant-design/colors';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
previewValues: string[];
error?: string | null;
}
function PreviewValues({ previewValues, error }: Props): JSX.Element {
return (
<VariableItemRow className="variables-preview-section">
<LabelContainer style={{ width: '100%' }}>
<Typography className="typography-variables">
Preview of Values
</Typography>
</LabelContainer>
<div className="preview-values">
{error ? (
<Typography style={{ color: orange[5] }}>{error}</Typography>
) : (
previewValues.map((v, idx) => (
<Tag key={`${v}${idx}`}>{v.toString()}</Tag>
))
)}
</div>
</VariableItemRow>
);
}
export default PreviewValues;

View File

@@ -1,66 +0,0 @@
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import Editor from 'components/Editor';
import { LabelContainer } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
queryValue: string;
onChange: (v: string) => void;
onTestRun?: () => void;
testRunLoading?: boolean;
error?: string;
}
function QueryFields({
queryValue,
onChange,
onTestRun,
testRunLoading,
error,
}: Props): JSX.Element {
return (
<div className="query-container">
<LabelContainer>
<Typography>Query</Typography>
</LabelContainer>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
language="sql"
value={queryValue}
onChange={onChange}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
{onTestRun ? (
<Button
type="primary"
size="small"
onClick={onTestRun}
style={{ position: 'absolute', bottom: 0 }}
loading={testRunLoading}
>
Test Run Query
</Button>
) : null}
{error ? (
<div>
<Typography.Text color="warning">{error}</Typography.Text>
</div>
) : null}
</div>
</div>
);
}
export default QueryFields;

View File

@@ -1,37 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
textValue: string;
onChange: (v: string) => void;
error?: string;
}
function TextFields({ textValue, onChange, error }: Props): JSX.Element {
return (
<VariableItemRow className="variable-textbox-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
</LabelContainer>
<div>
<Input
value={textValue}
className="default-input"
onChange={(e): void => onChange(e.target.value)}
placeholder="Enter a default value (if any)..."
style={{ width: 400 }}
data-testid="variable-text-value-v2"
/>
{error ? (
<div>
<Typography.Text color="warning">{error}</Typography.Text>
</div>
) : null}
</div>
</VariableItemRow>
);
}
export default TextFields;

View File

@@ -1,126 +0,0 @@
import { Button, Tag } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
import type { V2VariableKind } from '../types';
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
interface Props {
kind: V2VariableKind;
onChange: (kind: V2VariableKind) => void;
}
function TypeSelector({ kind, onChange }: Props): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<VariableItemRow className="variable-type-section">
<LabelContainer className="variable-type-label-container">
<Typography className="typography-variables">Variable Type</Typography>
<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}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
</LabelContainer>
<div className="variable-type-btn-group">
<Button
type="text"
icon={<Pyramid size={14} />}
className={cx(
'variable-type-btn',
kind === 'DYNAMIC' ? 'selected' : '',
)}
onClick={(): void => onChange('DYNAMIC')}
data-testid="variable-type-dynamic-v2"
>
Dynamic
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
Beta
</Tag>
</Button>
<Button
type="text"
icon={<ClipboardType size={14} />}
className={cx(
'variable-type-btn',
kind === 'TEXT' ? 'selected' : '',
)}
onClick={(): void => onChange('TEXT')}
data-testid="variable-type-text-v2"
>
Textbox
</Button>
<Button
type="text"
icon={<LayoutList size={14} />}
className={cx(
'variable-type-btn',
kind === 'CUSTOM' ? 'selected' : '',
)}
onClick={(): void => onChange('CUSTOM')}
data-testid="variable-type-custom-v2"
>
Custom
</Button>
<Button
type="text"
icon={<DatabaseZap size={14} />}
className={cx(
'variable-type-btn',
kind === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => onChange('QUERY')}
data-testid="variable-type-query-v2"
>
Query
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
Not Recommended
</Tag>
<div onClick={(e): void => e.stopPropagation()}>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
</div>
</Button>
</div>
</VariableItemRow>
);
}
export default TypeSelector;

View File

@@ -1,188 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from 'antd';
import { ArrowLeft } from '@signozhq/icons';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { draftToVariableDTO, validateDraft } from '../draft';
import type { SaveCallback, VariableDraft, V2VariableKind } from '../types';
import CustomFields from './CustomFields';
import DynamicFields from './DynamicFields';
import Footer from './Footer';
import ListBasicOptions from './ListBasicOptions';
import NameDisplay from './NameDisplay';
import PreviewValues from './PreviewValues';
import QueryFields from './QueryFields';
import TextFields from './TextFields';
import TypeSelector from './TypeSelector';
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
interface Props {
initialDraft: VariableDraft;
existingNames: string[];
saving: boolean;
onSave: SaveCallback;
onCancel: () => void;
}
/**
* Editor for a single V2 variable.
*
* Type-switch contract: changing `kind` does NOT clear the per-kind fields
* the user already typed. They remain in local state and are restored if the
* user navigates back to the same kind. Only the fields relevant to the
* active `kind` are written into the V2 envelope on save (see
* `draftToVariableDTO`).
*/
function VariableItem({
initialDraft,
existingNames,
saving,
onSave,
onCancel,
}: Props): JSX.Element {
const [draft, setDraft] = useState<VariableDraft>(initialDraft);
const update = useCallback(
<K extends keyof VariableDraft>(key: K, value: VariableDraft[K]): void => {
setDraft((prev) => ({ ...prev, [key]: value }));
},
[],
);
const onKindChange = useCallback(
(kind: V2VariableKind): void => {
// Retain every other field — only the discriminator changes.
update('kind', kind);
},
[update],
);
const namesExcludingSelf = useMemo(
() => existingNames.filter((n) => n !== initialDraft.name),
[existingNames, initialDraft.name],
);
const validationError = useMemo(
() => validateDraft(draft, namesExcludingSelf),
[draft, namesExcludingSelf],
);
// Local preview values — currently populated only for CUSTOM (CSV parse).
// Query / Dynamic previews are wired in the variable execution subsystem.
const previewValues = useMemo<string[]>(() => {
if (draft.kind === 'CUSTOM') {
return commaValuesParser(draft.customValue).map((v) => String(v));
}
return [];
}, [draft.kind, draft.customValue]);
const handleSave = useCallback((): void => {
if (validationError) return;
onSave(draftToVariableDTO(draft));
}, [draft, validationError, onSave]);
const errorFor = (
field: NonNullable<typeof validationError>['field'],
): string | undefined => {
if (validationError && validationError.field === field) {
return validationError.message;
}
return undefined;
};
const showListOptions =
draft.kind === 'QUERY' || draft.kind === 'CUSTOM' || draft.kind === 'DYNAMIC';
return (
<>
<div className="variable-item-container">
<div className="all-variables">
<Button
type="text"
className="all-variables-btn"
icon={<ArrowLeft size={14} />}
onClick={onCancel}
>
All variables
</Button>
</div>
<div className="variable-item-content">
<NameDisplay
name={draft.name}
description={draft.displayName}
onNameChange={(v): void => update('name', v)}
onDescriptionChange={(v): void => update('displayName', v)}
nameError={errorFor('name')}
/>
<TypeSelector kind={draft.kind} onChange={onKindChange} />
{draft.kind === 'DYNAMIC' ? (
<DynamicFields
dynamicName={draft.dynamicName}
dynamicSignal={draft.dynamicSignal}
onNameChange={(v): void => update('dynamicName', v)}
onSignalChange={(v): void => update('dynamicSignal', v)}
error={errorFor('dynamicName')}
/>
) : null}
{draft.kind === 'QUERY' ? (
<QueryFields
queryValue={draft.queryValue}
onChange={(v): void => update('queryValue', v)}
error={errorFor('queryValue')}
/>
) : null}
{draft.kind === 'CUSTOM' ? (
<CustomFields
customValue={draft.customValue}
onChange={(v): void => update('customValue', v)}
error={errorFor('customValue')}
/>
) : null}
{draft.kind === 'TEXT' ? (
<TextFields
textValue={draft.textValue}
onChange={(v): void => update('textValue', v)}
error={errorFor('textValue')}
/>
) : null}
{showListOptions ? (
<>
<PreviewValues previewValues={previewValues} />
<ListBasicOptions
kind={draft.kind}
allowAllValue={draft.allowAllValue}
allowMultiple={draft.allowMultiple}
sort={draft.sort}
defaultValue={draft.defaultValue}
customAllValue={draft.customAllValue}
capturingRegexp={draft.capturingRegexp}
previewValues={previewValues}
onAllowAllChange={(v): void => update('allowAllValue', v)}
onAllowMultipleChange={(v): void => update('allowMultiple', v)}
onSortChange={(v): void => update('sort', v)}
onDefaultValueChange={(v): void => update('defaultValue', v)}
onCustomAllValueChange={(v): void =>
update('customAllValue', v)
}
onCapturingRegexpChange={(v): void =>
update('capturingRegexp', v)
}
/>
</>
) : null}
</div>
</div>
<Footer
saving={saving}
canSave={!validationError}
onSave={handleSave}
onCancel={onCancel}
/>
</>
);
}
export default VariableItem;

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
import type { RowProps } from 'antd';
import { GripVertical } from '@signozhq/icons';
/**
* Sortable table row that injects a drag handle into the `name` cell —
* matches V1's [DashboardVariableSettings/index.tsx:31](TableRow component).
*/
function TableRow({ children, ...props }: RowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
// @ts-expect-error — antd Table's RowProps doesn't type the data-row-key it injects
id: props['data-row-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
};
return (
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child) => {
const childElement = child as React.ReactElement;
if (childElement.key === 'name') {
return React.cloneElement(childElement, {
key: 'name-with-drag',
children: (
<div className="variable-name-drag">
<GripVertical
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
style={{ touchAction: 'none', cursor: 'move' }}
size="md"
{...listeners}
/>
{child}
</div>
),
});
}
return childElement;
})}
</tr>
);
}
export default TableRow;

View File

@@ -1,50 +0,0 @@
import { Button, Space, Tag } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { PenLine, Trash2 } from '@signozhq/icons';
interface Props {
description: string;
kindLabel: string;
onEdit: () => void;
onDelete: () => void;
}
/**
* Right cell of the variable table — description text + edit/delete actions.
* Variable name + kind tag render in the left cell via column config.
*/
function VariableRow({
description,
kindLabel,
onEdit,
onDelete,
}: Props): JSX.Element {
return (
<div className="variable-description-actions">
<Typography.Text className="variable-description">
{description}
</Typography.Text>
<Space className="actions-btns">
<Tag>{kindLabel}</Tag>
<Button
type="text"
onClick={onEdit}
className="edit-variable-button"
data-testid="variable-edit-v2"
>
<PenLine size={14} />
</Button>
<Button
type="text"
onClick={onDelete}
className="delete-variable-button"
data-testid="variable-delete-v2"
>
<Trash2 size={14} />
</Button>
</Space>
</div>
);
}
export default VariableRow;

View File

@@ -1,119 +0,0 @@
import { Empty, Table } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import type { DashboardtypesVariableDTO } from 'api/generated/services/sigNoz.schemas';
import { getVariableKindLabel, getVariableName } from '../draft';
import TableRow from './TableRow';
import VariableRow from './VariableRow';
import '../../../../DashboardContainer/DashboardSettings/DashboardSettings.styles.scss';
interface TableEntry {
key: string;
name: string;
description: string;
kindLabel: string;
index: number;
}
interface Props {
variables: DashboardtypesVariableDTO[];
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onReorder: (next: DashboardtypesVariableDTO[]) => void;
}
function VariableList({
variables,
onEdit,
onDelete,
onReorder,
}: Props): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 1 },
}),
);
if (variables.length === 0) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>
No variables yet. Click &quot;Add variable&quot; to create one.
</Typography.Text>
}
/>
);
}
const dataSource: TableEntry[] = variables.map((v, idx) => ({
key: getVariableName(v) || String(idx),
name: getVariableName(v),
description:
(v.spec as { display?: { name?: string } })?.display?.name ?? '',
kindLabel: getVariableKindLabel(v),
index: idx,
}));
const columns = [
{
title: 'Variable',
dataIndex: 'name',
key: 'name',
width: '50%',
},
{
title: 'Description',
key: 'description',
width: '50%',
render: (entry: TableEntry): JSX.Element => (
<VariableRow
description={entry.description}
kindLabel={entry.kindLabel}
onEdit={(): void => onEdit(entry.index)}
onDelete={(): void => onDelete(entry.index)}
/>
),
},
];
const onDragEnd = ({ active, over }: DragEndEvent): void => {
if (!over || active.id === over.id) return;
const fromIdx = dataSource.findIndex((d) => d.key === active.id);
const toIdx = dataSource.findIndex((d) => d.key === over.id);
if (fromIdx < 0 || toIdx < 0) return;
onReorder(arrayMove(variables, fromIdx, toIdx));
};
return (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
>
<SortableContext items={dataSource.map((d) => d.key)}>
<Table
components={{ body: { row: TableRow } }}
rowKey="key"
columns={columns}
pagination={false}
dataSource={dataSource}
className="dashboard-variable-settings-table"
/>
</SortableContext>
</DndContext>
);
}
export default VariableList;

View File

@@ -1,202 +0,0 @@
import { v4 as generateUUID } from 'uuid';
import type {
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardtypesListVariableSpecDTO,
DashboardTextVariableSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { V2VariableKind, VariableDraft } from './types';
export function emptyDraft(): VariableDraft {
return {
id: generateUUID(),
kind: 'QUERY',
name: '',
displayName: '',
allowAllValue: false,
allowMultiple: false,
sort: 'none',
defaultValue: '',
customAllValue: '',
capturingRegexp: '',
queryValue: '',
customValue: '',
dynamicName: '',
dynamicSignal: undefined,
textValue: '',
};
}
/**
* Hydrate the relevant slot from a V2 envelope; other slots stay empty.
*/
export function variableDTOToDraft(
dto: DashboardtypesVariableDTO,
): VariableDraft {
const base = emptyDraft();
if (dto.kind === 'TextVariable') {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...base,
kind: 'TEXT',
name: spec?.name ?? '',
displayName: spec?.display?.name ?? '',
textValue: spec?.value ?? '',
};
}
// ListVariable
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const pluginKind = spec?.plugin?.kind;
let kind: V2VariableKind = 'QUERY';
if (pluginKind === 'signoz/DynamicVariable') kind = 'DYNAMIC';
else if (pluginKind === 'signoz/CustomVariable') kind = 'CUSTOM';
else if (pluginKind === 'signoz/QueryVariable') kind = 'QUERY';
const draft: VariableDraft = {
...base,
kind,
name: spec?.name ?? '',
displayName: spec?.display?.name ?? '',
allowAllValue: !!spec?.allowAllValue,
allowMultiple: !!spec?.allowMultiple,
sort: spec?.sort ?? 'none',
defaultValue: typeof spec?.defaultValue === 'string' ? spec.defaultValue : '',
customAllValue: spec?.customAllValue ?? '',
capturingRegexp: spec?.capturingRegexp ?? '',
};
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
if (kind === 'QUERY') {
draft.queryValue = (pluginSpec?.queryValue as string) ?? '';
} else if (kind === 'CUSTOM') {
draft.customValue = (pluginSpec?.customValue as string) ?? '';
} else if (kind === 'DYNAMIC') {
draft.dynamicName = (pluginSpec?.name as string) ?? '';
draft.dynamicSignal = pluginSpec?.signal as TelemetrytypesSignalDTO | undefined;
}
return draft;
}
/**
* Serialize draft to a V2 envelope, reading ONLY the fields relevant to the
* active kind. Other fields the user touched stay in React state and are
* silently dropped.
*/
export function draftToVariableDTO(
draft: VariableDraft,
): DashboardtypesVariableDTO {
const display = draft.displayName ? { name: draft.displayName } : undefined;
if (draft.kind === 'TEXT') {
return ({
kind: 'TextVariable',
spec: {
name: draft.name,
display,
value: draft.textValue,
},
} as unknown) as DashboardtypesVariableDTO;
}
let plugin: DashboardtypesVariablePluginDTO | undefined;
if (draft.kind === 'QUERY') {
plugin = ({
kind: 'signoz/QueryVariable',
spec: { queryValue: draft.queryValue },
} as unknown) as DashboardtypesVariablePluginDTO;
} else if (draft.kind === 'CUSTOM') {
plugin = ({
kind: 'signoz/CustomVariable',
spec: { customValue: draft.customValue },
} as unknown) as DashboardtypesVariablePluginDTO;
} else if (draft.kind === 'DYNAMIC') {
plugin = ({
kind: 'signoz/DynamicVariable',
spec: {
name: draft.dynamicName,
signal: draft.dynamicSignal,
},
} as unknown) as DashboardtypesVariablePluginDTO;
}
const spec: DashboardtypesListVariableSpecDTO = {
name: draft.name,
display,
allowAllValue: draft.allowAllValue,
allowMultiple: draft.allowMultiple,
sort: draft.sort,
plugin,
// VariableDefaultValueDTO is an open `{[key]: unknown}` shape, so a bare
// string isn't structurally assignable. We cast at the boundary.
defaultValue: draft.defaultValue
? ((draft.defaultValue as unknown) as DashboardtypesListVariableSpecDTO['defaultValue'])
: undefined,
customAllValue: draft.customAllValue || undefined,
capturingRegexp: draft.capturingRegexp || undefined,
};
return ({
kind: 'ListVariable',
spec,
} as unknown) as DashboardtypesVariableDTO;
}
export interface DraftValidationError {
field:
| 'name'
| 'queryValue'
| 'customValue'
| 'dynamicName'
| 'textValue'
| 'cycle';
message: string;
}
export function validateDraft(
draft: VariableDraft,
existingNames: string[],
): DraftValidationError | null {
const trimmedName = draft.name.trim();
if (!trimmedName) {
return { field: 'name', message: 'Variable name is required' };
}
if (/\s/.test(trimmedName)) {
return { field: 'name', message: 'Variable name cannot contain whitespace' };
}
if (existingNames.includes(trimmedName)) {
return { field: 'name', message: 'Variable name already exists' };
}
if (draft.kind === 'QUERY' && !draft.queryValue.trim()) {
return { field: 'queryValue', message: 'Query is required' };
}
if (draft.kind === 'CUSTOM' && !draft.customValue.trim()) {
return { field: 'customValue', message: 'Custom values are required' };
}
if (draft.kind === 'DYNAMIC' && !draft.dynamicName.trim()) {
return { field: 'dynamicName', message: 'Attribute name is required' };
}
if (draft.kind === 'TEXT' && !draft.textValue.trim()) {
return { field: 'textValue', message: 'Default text value is required' };
}
return null;
}
export function getVariableName(dto: DashboardtypesVariableDTO): string {
if (dto.kind === 'TextVariable') {
return (dto.spec as DashboardTextVariableSpecDTO)?.name ?? '';
}
return (dto.spec as DashboardtypesListVariableSpecDTO)?.name ?? '';
}
export function getVariableKindLabel(dto: DashboardtypesVariableDTO): string {
if (dto.kind === 'TextVariable') return 'Text';
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const pluginKind = spec?.plugin?.kind;
if (pluginKind === 'signoz/DynamicVariable') return 'Dynamic';
if (pluginKind === 'signoz/CustomVariable') return 'Custom';
return 'Query';
}

View File

@@ -1,156 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from 'antd';
import { Plus } from '@signozhq/icons';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
buildDependencyMap,
detectCycle,
} from '../../DashboardVariablesV2/dependencyGraph';
import type { V2Dashboard } from '../../utils';
import {
emptyDraft,
getVariableName,
variableDTOToDraft,
} from './draft';
import type { VariableDraft } from './types';
import VariableItem from './VariableItem';
import VariableList from './VariableList';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
type EditorState =
| { kind: 'closed' }
| { kind: 'add'; draft: VariableDraft }
| { kind: 'edit'; index: number; draft: VariableDraft };
function VariablesSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
const dashboardId = dashboard?.id ?? '';
const variables = useMemo<DashboardtypesVariableDTO[]>(
() => dashboard?.data?.spec?.variables ?? [],
[dashboard?.data?.spec?.variables],
);
const [editor, setEditor] = useState<EditorState>({ kind: 'closed' });
const [saving, setSaving] = useState<boolean>(false);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const existingNames = useMemo(() => variables.map(getVariableName), [
variables,
]);
const persistVariables = useCallback(
async (next: DashboardtypesVariableDTO[]): Promise<void> => {
if (!dashboardId) return;
const cycle = detectCycle(buildDependencyMap(next));
if (cycle.hasCycle) {
notifications.error({
message: `Cyclic variable dependency: ${cycle.cycle?.join(' → ')}`,
});
return;
}
setSaving(true);
try {
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: next,
},
];
await patchDashboardV2({ id: dashboardId }, patch);
notifications.success({ message: 'Variables updated' });
onRefetch();
setEditor({ kind: 'closed' });
} catch (error) {
showErrorModal(error as APIError);
} finally {
setSaving(false);
}
},
[dashboardId, notifications, onRefetch, showErrorModal],
);
const handleSave = useCallback(
async (dto: DashboardtypesVariableDTO): Promise<void> => {
if (editor.kind === 'add') {
await persistVariables([...variables, dto]);
} else if (editor.kind === 'edit') {
const next = variables.slice();
next[editor.index] = dto;
await persistVariables(next);
}
},
[editor, variables, persistVariables],
);
const handleDelete = useCallback(
async (index: number): Promise<void> => {
const next = variables.slice();
next.splice(index, 1);
await persistVariables(next);
},
[variables, persistVariables],
);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
}}
>
{editor.kind === 'closed' ? (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
icon={<Plus size={14} />}
onClick={(): void =>
setEditor({ kind: 'add', draft: emptyDraft() })
}
data-testid="add-variable-v2"
>
Add variable
</Button>
</div>
<VariableList
variables={variables}
onEdit={(index): void =>
setEditor({
kind: 'edit',
index,
draft: variableDTOToDraft(variables[index]),
})
}
onDelete={handleDelete}
onReorder={persistVariables}
/>
</>
) : (
<VariableItem
initialDraft={editor.draft}
existingNames={existingNames}
saving={saving}
onSave={handleSave}
onCancel={(): void => setEditor({ kind: 'closed' })}
/>
)}
</div>
);
}
export default VariablesSettingsV2;

View File

@@ -1,61 +0,0 @@
import type {
DashboardtypesVariableDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
export type V2VariableKind = 'QUERY' | 'CUSTOM' | 'DYNAMIC' | 'TEXT';
/**
* Internal editor state. Holds every per-kind field so that switching `kind`
* does not discard user input. Only the fields relevant to the active kind
* are written into the resulting V2 envelope on save.
*/
export interface VariableDraft {
id: string; // local identifier for list keys; not persisted to V2
kind: V2VariableKind;
name: string;
displayName: string;
// Shared by all List variants (QUERY / CUSTOM / DYNAMIC)
allowAllValue: boolean;
allowMultiple: boolean;
sort: string;
defaultValue: string;
// V2-only: literal value emitted when the user picks "ALL"
customAllValue: string;
// V2-only: regex applied to query/dynamic results to extract the actual value
capturingRegexp: string;
// QUERY
queryValue: string;
// CUSTOM
customValue: string;
// DYNAMIC
dynamicName: string;
dynamicSignal: TelemetrytypesSignalDTO | undefined;
// TEXT
textValue: string;
}
export type SaveCallback = (dto: DashboardtypesVariableDTO) => void;
export const VARIABLE_KIND_LABEL: Record<V2VariableKind, string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
DYNAMIC: 'Dynamic',
TEXT: 'Text',
};
// V2 supports a finer sort taxonomy than V1: separate alphabetical and
// numerical orderings (V1 only exposed Disabled / Ascending / Descending).
// Values match the strings used in the perses fixture and backend.
export const SORT_OPTIONS: { label: string; value: string }[] = [
{ label: 'Disabled', value: 'none' },
{ label: 'Alphabetical ascending', value: 'alphabetical-asc' },
{ label: 'Alphabetical descending', value: 'alphabetical-desc' },
{ label: 'Numerical ascending', value: 'numerical-asc' },
{ label: 'Numerical descending', value: 'numerical-desc' },
];

View File

@@ -1,70 +0,0 @@
import { Button, Empty, Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Braces, Globe, Table } from '@signozhq/icons';
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
import GeneralDashboardSettingsV2 from './General';
import VariablesSettingsV2 from './Variables';
import type { V2Dashboard } from '../utils';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function Placeholder({ message }: { message: string }): JSX.Element {
return (
<div style={{ padding: 24 }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />}>
General
</Button>
),
key: 'general',
children: (
<GeneralDashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
),
},
{
label: (
<Button type="text" icon={<Braces size={14} />}>
Variables
</Button>
),
key: 'variables',
children: (
<VariablesSettingsV2 dashboard={dashboard} onRefetch={onRefetch} />
),
},
{
label: (
<Button type="text" icon={<Globe size={14} />}>
Publish
</Button>
),
key: 'public-dashboard',
children: (
<Placeholder message="V2 public dashboard publishing coming next." />
),
},
];
return <Tabs items={items} />;
}
export default DashboardSettingsV2;

View File

@@ -1,135 +0,0 @@
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { referencedVariables } from './substitution';
/**
* Extracts the strings on a variable that may contain `$var` references —
* i.e. the dependency edges out of this variable.
*
* Currently only QUERY variables produce dependencies (their `queryValue`
* may reference other variables). CUSTOM and DYNAMIC plugin specs don't
* embed substitutable strings, and TEXT variables are leaf nodes.
*/
function dependencyStrings(dto: DashboardtypesVariableDTO): string[] {
if (dto.kind !== 'ListVariable') return [];
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const pluginKind = spec?.plugin?.kind;
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
if (pluginKind === 'signoz/QueryVariable') {
return [String(pluginSpec?.queryValue ?? '')];
}
return [];
}
function nameOf(dto: DashboardtypesVariableDTO): string {
return (dto.spec as { name?: string })?.name ?? '';
}
/**
* Direct dependencies for each variable (name → set of names it references).
*/
export function buildDependencyMap(
variables: DashboardtypesVariableDTO[],
): Record<string, Set<string>> {
const knownNames = new Set(variables.map(nameOf).filter(Boolean));
const deps: Record<string, Set<string>> = {};
variables.forEach((v) => {
const name = nameOf(v);
if (!name) return;
const refs = new Set<string>();
dependencyStrings(v).forEach((s) => {
referencedVariables(s).forEach((ref) => {
if (ref !== name && knownNames.has(ref)) refs.add(ref);
});
});
deps[name] = refs;
});
return deps;
}
export interface CycleResult {
hasCycle: boolean;
cycle?: string[];
}
/**
* Detect a cycle via DFS; returns the participating names in traversal order.
* Used at save time and to guard re-resolution.
*/
export function detectCycle(
deps: Record<string, Set<string>>,
): CycleResult {
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color: Record<string, number> = {};
const stack: string[] = [];
const names = Object.keys(deps);
names.forEach((n) => {
color[n] = WHITE;
});
function visit(node: string): string[] | null {
color[node] = GRAY;
stack.push(node);
for (const next of deps[node] ?? []) {
if (color[next] === GRAY) {
const idx = stack.indexOf(next);
return stack.slice(idx).concat(next);
}
if (color[next] === WHITE) {
const found = visit(next);
if (found) return found;
}
}
stack.pop();
color[node] = BLACK;
return null;
}
for (const n of names) {
if (color[n] === WHITE) {
const cycle = visit(n);
if (cycle) return { hasCycle: true, cycle };
}
}
return { hasCycle: false };
}
/**
* Kahn's algorithm — returns variable names in dependency order
* (dependencies first). If there's a cycle the result excludes the
* participating nodes; combine with `detectCycle` for validation.
*/
export function topoSort(
deps: Record<string, Set<string>>,
): string[] {
const incoming: Record<string, number> = {};
const downstream: Record<string, string[]> = {};
Object.keys(deps).forEach((n) => {
incoming[n] = 0;
downstream[n] = [];
});
Object.entries(deps).forEach(([n, refs]) => {
refs.forEach((ref) => {
incoming[n] += 1;
downstream[ref] = downstream[ref] ?? [];
downstream[ref].push(n);
});
});
const queue: string[] = Object.keys(incoming).filter((n) => incoming[n] === 0);
const out: string[] = [];
while (queue.length > 0) {
const n = queue.shift() as string;
out.push(n);
(downstream[n] ?? []).forEach((next) => {
incoming[next] -= 1;
if (incoming[next] === 0) queue.push(next);
});
}
return out;
}

View File

@@ -1,81 +0,0 @@
import { useEffect, useMemo } from 'react';
import type {
DashboardtypesListVariableSpecDTO,
DashboardTextVariableSpecDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { buildDependencyMap, detectCycle, topoSort } from './dependencyGraph';
import VariableSelector from './selectors/VariableSelector';
import { useVariableSelectionStore } from './state/selectionStore';
import '../../DashboardContainer/DashboardVariablesSelection/DashboardVariableSelection.styles.scss';
interface Props {
dashboardId: string;
variables: DashboardtypesVariableDTO[] | undefined;
}
function nameOf(v: DashboardtypesVariableDTO): string {
return (
(v.spec as DashboardtypesListVariableSpecDTO | DashboardTextVariableSpecDTO)
?.name ?? ''
);
}
function kindHint(v: DashboardtypesVariableDTO): 'list' | 'text' {
return v.kind === 'TextVariable' ? 'text' : 'list';
}
function DashboardVariablesV2({ dashboardId, variables }: Props): JSX.Element | null {
const hydrate = useVariableSelectionStore((s) => s.hydrate);
// Build hints map (variable-name → list/text) so the store can decode the URL.
const hints = useMemo<Record<string, 'list' | 'text'>>(() => {
const out: Record<string, 'list' | 'text'> = {};
(variables ?? []).forEach((v) => {
const n = nameOf(v);
if (n) out[n] = kindHint(v);
});
return out;
}, [variables]);
useEffect(() => {
if (!dashboardId) return;
hydrate(dashboardId, hints);
}, [dashboardId, hints, hydrate]);
// Sort variables in dependency order so dependent resolvers see fresh
// selections from their parents. (Render order doesn't affect the React
// Query cache but it does affect *visual* order.)
const ordered = useMemo(() => {
if (!variables?.length) return [];
const deps = buildDependencyMap(variables);
const cycle = detectCycle(deps);
if (cycle.hasCycle) {
// Render in the original order; the cycle is surfaced separately at save
// time via validateDraft. Resolution will still execute; it just won't
// converge.
return variables;
}
const order = topoSort(deps);
const byName: Record<string, DashboardtypesVariableDTO> = {};
variables.forEach((v) => {
const n = nameOf(v);
if (n) byName[n] = v;
});
return order.map((n) => byName[n]).filter(Boolean);
}, [variables]);
if (!variables || variables.length === 0) return null;
return (
<div className="variables-container">
{ordered.map((v) => (
<VariableSelector key={nameOf(v)} variable={v} />
))}
</div>
);
}
export default DashboardVariablesV2;

View File

@@ -1,29 +0,0 @@
/**
* Applies V2 `capturingRegexp` to each value: if the regex matches and has a
* capture group, replace the value with the first capture; otherwise keep
* the raw value. Invalid regex silently passes values through.
*
* Empty results (no match at all) are filtered out — they would be useless
* as selectable options.
*/
export function applyCapturingRegexp(
values: string[],
pattern: string | undefined | null,
): string[] {
if (!pattern) return values;
let re: RegExp;
try {
re = new RegExp(pattern);
} catch {
return values;
}
const out: string[] = [];
values.forEach((v) => {
const m = re.exec(v);
if (!m) return;
out.push(m[1] !== undefined ? m[1] : m[0]);
});
return out;
}

View File

@@ -1,37 +0,0 @@
/**
* Apply V2 sort modes to a resolved value list.
*
* Sort values come from the perses spec — `none`, `alphabetical-asc`,
* `alphabetical-desc`, `numerical-asc`, `numerical-desc`. Numerical sort
* falls back to string compare for values that aren't numbers so we never
* throw away non-numeric entries.
*/
export function applySort(
values: string[],
sort: string | null | undefined,
): string[] {
if (!sort || sort === 'none' || values.length <= 1) return values;
const copy = values.slice();
if (sort === 'alphabetical-asc') {
copy.sort((a, b) => a.localeCompare(b));
} else if (sort === 'alphabetical-desc') {
copy.sort((a, b) => b.localeCompare(a));
} else if (sort === 'numerical-asc' || sort === 'numerical-desc') {
copy.sort((a, b) => {
const na = Number(a);
const nb = Number(b);
const aFinite = Number.isFinite(na);
const bFinite = Number.isFinite(nb);
if (aFinite && bFinite) {
return sort === 'numerical-asc' ? na - nb : nb - na;
}
// Mixed numeric/non-numeric: keep non-numerics at the end, sorted alpha.
if (aFinite) return -1;
if (bFinite) return 1;
return sort === 'numerical-asc'
? a.localeCompare(b)
: b.localeCompare(a);
});
}
return copy;
}

View File

@@ -1,18 +0,0 @@
/**
* Output of resolving a single list variable. Text variables don't go
* through resolution — their value is the literal string.
*/
export interface ResolvedValues {
values: string[];
status: 'idle' | 'loading' | 'success' | 'error';
error?: string;
}
export const idle: ResolvedValues = { values: [], status: 'idle' };
export const loading: ResolvedValues = { values: [], status: 'loading' };
export function success(values: string[]): ResolvedValues {
return { values, status: 'success' };
}
export function failure(error: string): ResolvedValues {
return { values: [], status: 'error', error };
}

View File

@@ -1,15 +0,0 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { success, type ResolvedValues } from './types';
/**
* CUSTOM variables: the comma-separated user input is the value list.
* No network call, purely client-side.
*/
export function useCustomResolver(customValue: string): ResolvedValues {
return useMemo(
() => success(commaValuesParser(customValue).map((v) => String(v))),
[customValue],
);
}

View File

@@ -1,50 +0,0 @@
import { useSelector } from 'react-redux';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { failure, idle, loading, success, type ResolvedValues } from './types';
function signalToV1(
signal: TelemetrytypesSignalDTO | undefined,
): 'traces' | 'logs' | 'metrics' | undefined {
if (signal === TelemetrytypesSignalDTO.traces) return 'traces';
if (signal === TelemetrytypesSignalDTO.logs) return 'logs';
if (signal === TelemetrytypesSignalDTO.metrics) return 'metrics';
return undefined;
}
/**
* DYNAMIC variables: telemetry attribute lookup.
* - `signal === undefined` → search across all telemetry types.
* - Otherwise scoped to the specific signal.
*
* Uses the existing V1 hook directly; the API is V2-shape-agnostic.
*/
export function useDynamicResolver(
attributeName: string,
signal: TelemetrytypesSignalDTO | undefined,
): ResolvedValues {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const enabled = !!attributeName;
const { data, isLoading, isError, error } = useGetFieldValues({
signal: signalToV1(signal),
name: attributeName,
enabled,
startUnixMilli: minTime,
endUnixMilli: maxTime,
});
if (!enabled) return idle;
if (isLoading) return loading;
if (isError) {
return failure(
(error as Error)?.message ?? 'Failed to resolve dynamic variable',
);
}
return success(data?.data?.normalizedValues ?? []);
}

View File

@@ -1,78 +0,0 @@
import { useQuery } from 'react-query';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import { substituteVariables } from '../substitution';
import type { SelectionsByName } from '../state/types';
import { failure, idle, loading, success, type ResolvedValues } from './types';
/**
* Reduce the user's V2 selections to the V1 `PayloadVariables` shape the
* variables/query endpoint expects (a plain name → selected-value map).
*/
function selectionsToPayload(
selections: SelectionsByName,
): PayloadVariables {
const out: PayloadVariables = {};
Object.entries(selections).forEach(([name, sel]) => {
if (!sel) return;
if (sel.kind === 'text') {
out[name] = sel.value;
} else if (sel.allSelected) {
// Endpoint understands `__ALL__`-style markers via the substitution
// done client-side; leave the value out so server doesn't double up.
// (Callers using IN ($var) expand via substituteVariables instead.)
} else if (sel.values.length === 1) {
out[name] = sel.values[0];
} else {
out[name] = sel.values;
}
});
return out;
}
interface UseQueryResolverArgs {
variableName: string;
queryValue: string;
selections: SelectionsByName;
enabled: boolean;
}
/**
* QUERY variables: substitute `$var` references using current selections,
* then POST to `/api/v2/variables/query`. React Query caches per
* (name, substitutedQuery) so re-render with the same inputs reuses results.
*/
export function useQueryResolver({
variableName,
queryValue,
selections,
enabled,
}: UseQueryResolverArgs): ResolvedValues {
const substituted = substituteVariables(queryValue, selections);
const { data, isLoading, isError, error } = useQuery({
queryKey: ['v2-variable-query', variableName, substituted],
queryFn: () =>
dashboardVariablesQuery({
query: substituted,
variables: selectionsToPayload(selections),
}),
enabled: enabled && !!substituted,
refetchOnWindowFocus: false,
});
if (!enabled || !substituted) return idle;
if (isLoading) return loading;
if (isError) {
return failure(
(error as { details?: { error?: string } })?.details?.error ??
(error as Error)?.message ??
'Variable query failed',
);
}
const payload = (data as { payload?: { variableValues?: unknown[] } } | undefined)
?.payload;
const values = (payload?.variableValues ?? []).map((v) => String(v));
return success(values);
}

View File

@@ -1,74 +0,0 @@
import { useMemo } from 'react';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useVariableSelectionStore } from '../state/selectionStore';
import { applyCapturingRegexp } from './capturingRegexp';
import { applySort } from './sorting';
import { useCustomResolver } from './useCustomResolver';
import { useDynamicResolver } from './useDynamicResolver';
import { useQueryResolver } from './useQueryResolver';
import { idle, success, type ResolvedValues } from './types';
interface UseResolveVariableArgs {
variable: DashboardtypesVariableDTO;
}
/**
* Routes a variable to the correct resolver hook and applies the V2
* post-processing pipeline:
*
* raw values → capturingRegexp → sort → final list
*
* Text variables short-circuit since they don't have a value list.
*/
export function useResolveVariable({
variable,
}: UseResolveVariableArgs): ResolvedValues {
const selections = useVariableSelectionStore((s) => s.selections);
// Read all fields up front so the React Query / hook order is stable
// across renders (hooks must not be called conditionally).
const isText = variable.kind === 'TextVariable';
const listSpec = (variable.spec as DashboardtypesListVariableSpecDTO) ?? {};
const pluginKind = listSpec.plugin?.kind;
const pluginSpec = (listSpec.plugin?.spec as Record<string, unknown> | undefined) ?? {};
const name = listSpec?.name ?? '';
const customValue = (pluginSpec.customValue as string) ?? '';
const queryValue = (pluginSpec.queryValue as string) ?? '';
const dynName = (pluginSpec.name as string) ?? '';
const dynSignal = pluginSpec.signal as TelemetrytypesSignalDTO | undefined;
const customRes = useCustomResolver(
pluginKind === 'signoz/CustomVariable' ? customValue : '',
);
const dynRes = useDynamicResolver(
pluginKind === 'signoz/DynamicVariable' ? dynName : '',
dynSignal,
);
const queryRes = useQueryResolver({
variableName: name,
queryValue: pluginKind === 'signoz/QueryVariable' ? queryValue : '',
selections,
enabled: pluginKind === 'signoz/QueryVariable',
});
const raw: ResolvedValues = useMemo(() => {
if (isText) return success([]);
if (pluginKind === 'signoz/CustomVariable') return customRes;
if (pluginKind === 'signoz/DynamicVariable') return dynRes;
if (pluginKind === 'signoz/QueryVariable') return queryRes;
return idle;
}, [isText, pluginKind, customRes, dynRes, queryRes]);
return useMemo(() => {
if (raw.status !== 'success') return raw;
const afterRegex = applyCapturingRegexp(raw.values, listSpec.capturingRegexp);
const afterSort = applySort(afterRegex, listSpec.sort);
return success(afterSort);
}, [raw, listSpec.capturingRegexp, listSpec.sort]);
}

View File

@@ -1,89 +0,0 @@
import { useMemo } from 'react';
import SelectVariableInput from 'container/DashboardContainer/DashboardVariablesSelection/SelectVariableInput';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { ResolvedValues } from '../resolution/types';
import type { VariableSelection } from '../state/types';
interface Props {
variableId: string;
resolved: ResolvedValues;
selection: VariableSelection | undefined;
allowMultiple: boolean;
allowAllValue: boolean;
defaultValue: string;
onChange: (selection: VariableSelection) => void;
onClear: () => void;
}
function selectionToValue(
selection: VariableSelection | undefined,
defaultValue: string,
allowMultiple: boolean,
): string | string[] | undefined {
if (selection && selection.kind === 'list') {
if (selection.allSelected) return ALL_SELECT_VALUE;
if (allowMultiple) return selection.values;
return selection.values[0];
}
if (defaultValue) return allowMultiple ? [defaultValue] : defaultValue;
return undefined;
}
/**
* QUERY / CUSTOM / DYNAMIC variables share the same dropdown UX: a list of
* options + an optional ALL entry + single / multi-select. Reuses V1's
* `SelectVariableInput` so visuals match exactly.
*/
function ListVariableSelector({
variableId,
resolved,
selection,
allowMultiple,
allowAllValue,
defaultValue,
onChange,
onClear,
}: Props): JSX.Element {
const options = useMemo(
() => resolved.values.map((v) => ({ label: v, value: v })),
[resolved.values],
);
const value = selectionToValue(selection, defaultValue, allowMultiple);
return (
<SelectVariableInput
variableId={variableId}
options={options}
value={value}
enableSelectAll={allowAllValue}
isMultiSelect={allowMultiple}
loading={resolved.status === 'loading'}
errorMessage={resolved.error ?? null}
onChange={(next): void => {
if (Array.isArray(next)) {
// Multi-select. Antd's CustomMultiSelect emits the ALL sentinel
// when the user toggles the "Select all" row.
const hasAll = next.includes(ALL_SELECT_VALUE);
onChange({
kind: 'list',
values: hasAll ? [] : next,
allSelected: hasAll,
});
} else if (next === ALL_SELECT_VALUE) {
onChange({ kind: 'list', values: [], allSelected: true });
} else {
onChange({
kind: 'list',
values: next ? [next] : [],
allSelected: false,
});
}
}}
onClear={onClear}
/>
);
}
export default ListVariableSelector;

View File

@@ -1,27 +0,0 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { SolidInfoCircle } from '@signozhq/icons';
interface Props {
name: string;
description?: string;
}
/**
* V1-style label: `$name` + an info tooltip if a description is set.
* Mirrors [DashboardVariablesSelection/VariableItem.tsx:34-42](V1).
*/
function SelectorLabel({ name, description }: Props): JSX.Element {
return (
<Typography.Text className="variable-name" truncate={1}>
${name}
{description ? (
<Tooltip title={description}>
<SolidInfoCircle className="info-icon" size="md" />
</Tooltip>
) : null}
</Typography.Text>
);
}
export default SelectorLabel;

View File

@@ -1,37 +0,0 @@
import { useEffect, useState } from 'react';
import { Input } from 'antd';
interface Props {
value: string;
onCommit: (v: string) => void;
}
/**
* Text variable input — commits on blur (and on Enter), matching V1's
* `TextboxVariableInput` UX which avoids re-fetching panels on every
* keystroke.
*/
function TextVariableSelector({ value, onCommit }: Props): JSX.Element {
const [draft, setDraft] = useState<string>(value);
useEffect(() => {
setDraft(value);
}, [value]);
const commit = (): void => {
if (draft !== value) onCommit(draft);
};
return (
<Input
className="variable-select"
value={draft}
onChange={(e): void => setDraft(e.target.value)}
onBlur={commit}
onPressEnter={commit}
data-testid="text-variable-input-v2"
/>
);
}
export default TextVariableSelector;

View File

@@ -1,92 +0,0 @@
import { useCallback } from 'react';
import type {
DashboardtypesListVariableSpecDTO,
DashboardTextVariableSpecDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useResolveVariable } from '../resolution/useResolveVariable';
import { useVariableSelectionStore } from '../state/selectionStore';
import type { VariableSelection } from '../state/types';
import ListVariableSelector from './ListVariableSelector';
import SelectorLabel from './SelectorLabel';
import TextVariableSelector from './TextVariableSelector';
interface Props {
variable: DashboardtypesVariableDTO;
}
/**
* Routes one variable to its kind-specific selector. Owns the selection
* store binding so the kind-specific components stay dumb.
*/
function VariableSelector({ variable }: Props): JSX.Element | null {
const isText = variable.kind === 'TextVariable';
const spec = variable.spec as
| DashboardtypesListVariableSpecDTO
| DashboardTextVariableSpecDTO
| undefined;
const name = spec?.name ?? '';
const selection = useVariableSelectionStore((s) =>
name ? s.selections[name] : undefined,
);
const setSelection = useVariableSelectionStore((s) => s.setSelection);
const resolved = useResolveVariable({ variable });
const setListSelection = useCallback(
(next: VariableSelection): void => setSelection(name, next),
[name, setSelection],
);
const clearSelection = useCallback((): void => setSelection(name, undefined), [
name,
setSelection,
]);
if (!name) return null;
const description = spec?.display?.name ?? '';
if (isText) {
const textSpec = spec as DashboardTextVariableSpecDTO;
const current =
selection?.kind === 'text' ? selection.value : textSpec?.value ?? '';
return (
<div className="variable-item">
<SelectorLabel name={name} description={description} />
<div className="variable-value">
<TextVariableSelector
value={current}
onCommit={(v): void => setSelection(name, { kind: 'text', value: v })}
/>
</div>
</div>
);
}
const listSpec = spec as DashboardtypesListVariableSpecDTO;
const defaultValue =
typeof listSpec?.defaultValue === 'string'
? (listSpec.defaultValue as string)
: '';
return (
<div className="variable-item">
<SelectorLabel name={name} description={description} />
<div className="variable-value">
<ListVariableSelector
variableId={name}
resolved={resolved}
selection={selection}
allowMultiple={!!listSpec?.allowMultiple}
allowAllValue={!!listSpec?.allowAllValue}
defaultValue={defaultValue}
onChange={setListSelection}
onClear={clearSelection}
/>
</div>
</div>
);
}
export default VariableSelector;

View File

@@ -1,36 +0,0 @@
import type { SelectionsByName } from './types';
const STORAGE_PREFIX = 'dashboard-v2-variables';
function storageKey(dashboardId: string): string {
return `${STORAGE_PREFIX}:${dashboardId}`;
}
export function loadSelectionsFromStorage(
dashboardId: string,
): SelectionsByName {
if (!dashboardId) return {};
try {
const raw = window.localStorage.getItem(storageKey(dashboardId));
if (!raw) return {};
const parsed = JSON.parse(raw) as SelectionsByName;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export function saveSelectionsToStorage(
dashboardId: string,
selections: SelectionsByName,
): void {
if (!dashboardId) return;
try {
window.localStorage.setItem(
storageKey(dashboardId),
JSON.stringify(selections),
);
} catch {
// quota / availability issues — selection still lives in memory + URL
}
}

View File

@@ -1,63 +0,0 @@
import { create } from 'zustand';
import {
loadSelectionsFromStorage,
saveSelectionsToStorage,
} from './localStorage';
import type { SelectionsByName, VariableSelection } from './types';
import { readSelectionsFromUrl, writeSelectionsToUrl } from './urlSync';
interface SelectionStoreState {
dashboardId: string;
selections: SelectionsByName;
/**
* Hydrate from URL → fallback to LocalStorage. Called once per dashboard
* load. `hints` lets URL decoding pick list vs text encoding.
*/
hydrate: (
dashboardId: string,
hints: Record<string, 'list' | 'text'>,
) => void;
/**
* Set / clear the selection for a single variable. Persists to both
* LocalStorage and URL.
*/
setSelection: (name: string, selection: VariableSelection | undefined) => void;
reset: () => void;
}
export const useVariableSelectionStore = create<SelectionStoreState>(
(set, get) => ({
dashboardId: '',
selections: {},
hydrate: (dashboardId, hints): void => {
const fromUrl = readSelectionsFromUrl(hints);
const fromStorage = loadSelectionsFromStorage(dashboardId);
// URL wins over LocalStorage (shareable links override personal
// preferences).
const merged: SelectionsByName = { ...fromStorage, ...fromUrl };
set({ dashboardId, selections: merged });
},
setSelection: (name, selection): void => {
const { dashboardId, selections } = get();
const next: SelectionsByName = { ...selections };
if (selection === undefined) {
delete next[name];
} else {
next[name] = selection;
}
set({ selections: next });
saveSelectionsToStorage(dashboardId, next);
writeSelectionsToUrl(next);
},
reset: (): void => {
set({ dashboardId: '', selections: {} });
},
}),
);

View File

@@ -1,20 +0,0 @@
/**
* A single variable's selected value.
*
* - `kind: 'list'` is used for QUERY / CUSTOM / DYNAMIC list variables.
* - `allSelected: true` represents the user picking "ALL"; `values` is
* ignored in that case.
* - `values` is an array even for single-select to keep the shape uniform;
* single-select uses index 0.
* - `kind: 'text'` is the TextVariable case: one freeform string.
*/
export type VariableSelection =
| { kind: 'list'; values: string[]; allSelected: boolean }
| { kind: 'text'; value: string };
/**
* Map of `variable name` → selection. Per dashboard, in memory + persisted.
*/
export type SelectionsByName = Record<string, VariableSelection | undefined>;
export const ALL_SENTINEL = '__ALL__';

View File

@@ -1,72 +0,0 @@
import { ALL_SENTINEL, type SelectionsByName, type VariableSelection } from './types';
const URL_PREFIX = 'var-';
/**
* Encodes a single selection into a URL-safe string. Compact format:
* - text variable → the freeform string
* - list (ALL) → "__ALL__"
* - list (single) → "value"
* - list (multi) → "v1,v2,v3"
*/
function encodeSelection(sel: VariableSelection): string {
if (sel.kind === 'text') return sel.value;
if (sel.allSelected) return ALL_SENTINEL;
return sel.values.join(',');
}
function decodeSelection(
raw: string,
hint: 'list' | 'text',
): VariableSelection {
if (hint === 'text') return { kind: 'text', value: raw };
if (raw === ALL_SENTINEL) {
return { kind: 'list', values: [], allSelected: true };
}
const values = raw ? raw.split(',') : [];
return { kind: 'list', values, allSelected: false };
}
/**
* Reads `var-<name>=<encoded>` params off the current location.
* `hints` tells us each variable's kind (list vs text) for decoding.
*/
export function readSelectionsFromUrl(
hints: Record<string, 'list' | 'text'>,
): SelectionsByName {
const out: SelectionsByName = {};
if (typeof window === 'undefined') return out;
const params = new URLSearchParams(window.location.search);
params.forEach((value, key) => {
if (!key.startsWith(URL_PREFIX)) return;
const name = key.slice(URL_PREFIX.length);
const hint = hints[name];
if (!hint) return;
out[name] = decodeSelection(value, hint);
});
return out;
}
/**
* Writes the current selections into the URL, replacing any previous
* `var-*` params. Uses `replaceState` so it doesn't pollute history.
*/
export function writeSelectionsToUrl(selections: SelectionsByName): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
// Strip existing var-* params
const keysToDelete: string[] = [];
params.forEach((_, key) => {
if (key.startsWith(URL_PREFIX)) keysToDelete.push(key);
});
keysToDelete.forEach((k) => params.delete(k));
Object.entries(selections).forEach(([name, sel]) => {
if (!sel) return;
params.set(`${URL_PREFIX}${name}`, encodeSelection(sel));
});
const search = params.toString();
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`;
window.history.replaceState(window.history.state, '', nextUrl);
}

View File

@@ -1,56 +0,0 @@
import { ALL_SENTINEL, type SelectionsByName } from './state/types';
/**
* Replaces `$varname` references in a string with the current selection.
*
* - text selection → the freeform string
* - list, allSelected → ALL_SENTINEL (callers decide whether to expand to
* all known values or to send the literal marker)
* - list, single value → that value
* - list, multi values → comma-joined; brackets if caller wraps with IN ()
*
* Variable names match `[a-zA-Z_][a-zA-Z0-9_.]*` so dotted attribute keys
* like `$service.name` work. Substitution is non-recursive (we don't expand
* `$other` if a value happens to contain another reference).
*/
const VARIABLE_REF = /\$([a-zA-Z_][a-zA-Z0-9_.]*)/g;
function selectionToString(
selection: SelectionsByName[string],
): string | null {
if (!selection) return null;
if (selection.kind === 'text') return selection.value;
if (selection.allSelected) return ALL_SENTINEL;
if (selection.values.length === 0) return '';
return selection.values.join(',');
}
export function substituteVariables(
template: string,
selections: SelectionsByName,
): string {
if (!template) return template;
return template.replace(VARIABLE_REF, (match, name: string) => {
const sel = selections[name];
const value = selectionToString(sel);
// Leave unresolved references intact so the consumer can decide how to
// handle them (better than producing silent partial substitutions).
return value === null ? match : value;
});
}
/**
* Lists the variable names referenced in a string. Used by the dependency
* graph (Phase 5).
*/
export function referencedVariables(template: string): string[] {
if (!template) return [];
const out = new Set<string>();
let match: RegExpExecArray | null;
const re = new RegExp(VARIABLE_REF.source, 'g');
// eslint-disable-next-line no-cond-assign
while ((match = re.exec(template)) !== null) {
out.add(match[1]);
}
return Array.from(out);
}

View File

@@ -1,97 +0,0 @@
import { useMemo } from 'react';
import { Tag, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
interface Props {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
}
function PanelV2({ panel, panelId }: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const headerTitle = useMemo(() => {
if (!description) return name;
return (
<Tooltip title={description}>
<span>{name}</span>
</Tooltip>
);
}, [name, description]);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
background: 'var(--bg-ink-400, #0b0c0e)',
border: '1px solid var(--bg-slate-400, #1d212d)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
className="drag-handle"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid var(--bg-slate-400, #1d212d)',
cursor: 'grab',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}
>
<Typography.Text
style={{
margin: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{headerTitle}
</Typography.Text>
<Tag style={{ marginInlineEnd: 0 }}>{kind}</Tag>
</div>
<EllipsisVertical size={14} />
</div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
color: 'var(--bg-vanilla-400, #8993ae)',
fontSize: 12,
textAlign: 'center',
}}
>
<div>
<div style={{ marginBottom: 6 }}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering coming next
</div>
</div>
</div>
</div>
);
}
export default PanelV2;

View File

@@ -1,95 +0,0 @@
import { useMemo, useState } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { DashboardSectionV2 } from '../utils';
import PanelV2 from './PanelV2';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
section: DashboardSectionV2;
}
function SectionGrid({ items }: { items: DashboardSectionV2['items'] }): JSX.Element {
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
return (
<ResponsiveGridLayout
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={rglLayout}
draggableHandle=".drag-handle"
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<PanelV2 panel={item.panel} panelId={item.id} />
</div>
))}
</ResponsiveGridLayout>
);
}
function Section({ section }: Props): JSX.Element {
// Local toggle override — initial state from layout spec; user can
// expand/collapse without persisting.
const [open, setOpen] = useState<boolean>(section.open);
if (!section.title) {
// Untitled section — render just the grid (no header chrome).
return <SectionGrid items={section.items} />;
}
return (
<div
style={{
marginBottom: 12,
border: '1px solid var(--bg-slate-500)',
borderRadius: 4,
}}
data-testid={`dashboard-section-${section.id}`}
>
<Button
type="text"
onClick={(): void => setOpen((v) => !v)}
icon={open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
style={{
width: '100%',
justifyContent: 'flex-start',
padding: '8px 12px',
borderBottom: open ? '1px solid var(--bg-slate-500)' : 'none',
}}
data-testid={`dashboard-section-toggle-${section.id}`}
>
<Typography.Text style={{ marginLeft: 4 }}>
{section.title}
</Typography.Text>
{section.repeatVariable ? (
<Typography.Text style={{ marginLeft: 8, opacity: 0.6 }}>
(repeats per ${section.repeatVariable})
</Typography.Text>
) : null}
</Button>
{open ? <SectionGrid items={section.items} /> : null}
</div>
);
}
export default Section;

View File

@@ -1,51 +0,0 @@
import { useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { layoutsToSections } from '../utils';
import Section from './Section';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
}
function GridCardLayoutV2({ layouts, panels }: Props): JSX.Element {
const sections = useMemo(() => layoutsToSections(layouts, panels), [
layouts,
panels,
]);
const isEmpty = sections.length === 0 || sections.every((s) => s.items.length === 0);
if (isEmpty) {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
}
return (
<>
{sections.map((section) => (
<Section key={section.id} section={section} />
))}
</>
);
}
export default GridCardLayoutV2;

View File

@@ -1,63 +0,0 @@
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
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;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboard-icon-image {
height: 14px;
width: 14px;
}
}

View File

@@ -1,58 +0,0 @@
import { useCallback } from 'react';
import { Button } from 'antd';
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 { LayoutGrid } from '@signozhq/icons';
import { Base64Icons } from '../../../DashboardContainer/DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
interface Props {
title: string;
image?: string;
}
function DashboardBreadcrumbs({ title, image }: Props): 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="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={goToListPage}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img
src={image || Base64Icons[0]}
alt="dashboard-icon"
className="dashboard-icon-image"
/>
{title}
</Button>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -1,9 +0,0 @@
.dashboard-header {
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

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

View File

@@ -1,35 +0,0 @@
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import DashboardDescriptionV2 from './DashboardDescriptionV2';
import GridCardLayoutV2 from './GridCardLayoutV2';
import type { V2Dashboard } from './utils';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function DashboardContainerV2({ dashboard, onRefetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const spec = dashboard?.data?.spec;
return (
<FullScreen handle={fullScreenHandle}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<DashboardDescriptionV2
dashboard={dashboard}
handle={fullScreenHandle}
onRefetch={onRefetch}
/>
<div style={{ flex: 1, padding: '12px 24px', overflow: 'auto' }}>
<GridCardLayoutV2
layouts={spec?.layouts}
panels={spec?.panels ?? undefined}
/>
</div>
</div>
</FullScreen>
);
}
export default DashboardContainerV2;

View File

@@ -1,111 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type V2Dashboard = DashboardtypesGettableDashboardV2DTO;
export interface GridItemV2 {
id: string;
x: number;
y: number;
width: number;
height: number;
panel: DashboardtypesPanelDTO | undefined;
}
const PANEL_REF_PREFIX = '#/spec/panels/';
export function extractPanelIdFromRef(ref: string | undefined): string | null {
if (!ref) return null;
if (!ref.startsWith(PANEL_REF_PREFIX)) return null;
return ref.slice(PANEL_REF_PREFIX.length);
}
export function flattenGridLayout(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): GridItemV2[] {
if (!layouts?.length) return [];
const items: GridItemV2[] = [];
layouts.forEach((layoutEnvelope) => {
if (layoutEnvelope?.kind !== 'Grid') return;
const gridItems = layoutEnvelope.spec?.items ?? [];
gridItems.forEach((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) return;
items.push({
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
});
});
});
return items;
}
/**
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
* `display.title`, it renders with a collapsible header; otherwise it is a
* "default" untitled section (visually just the grid).
*/
export interface DashboardSectionV2 {
id: string;
title: string | undefined;
open: boolean;
items: GridItemV2[];
repeatVariable: string | undefined;
}
export function layoutsToSections(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): DashboardSectionV2[] {
if (!layouts?.length) return [];
return layouts
.map((layoutEnvelope, idx) => {
if (layoutEnvelope?.kind !== 'Grid') return null;
const spec = layoutEnvelope.spec;
const items: GridItemV2[] = (spec?.items ?? [])
.map((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) return null;
return {
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
};
})
.filter((it): it is GridItemV2 => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: `section-${idx}`,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};
})
.filter((s): s is DashboardSectionV2 => s !== null);
}
export function getPanelKindLabel(panel: DashboardtypesPanelDTO | undefined): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) return 'unknown';
return kind.replace(/^signoz\//, '');
}

View File

@@ -1,9 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsDashboardV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find((flag) => flag.name === FeatureKeys.DASHBOARD_V2)?.active,
);
}

View File

@@ -1,47 +0,0 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import DashboardContainerV2 from 'container/DashboardContainerV2';
function DashboardPageV2(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
const name = dashboard?.data?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>{(error as Error)?.message}</Typography.Text>
</div>
);
}
return (
<DashboardContainerV2
dashboard={dashboard}
onRefetch={(): void => {
refetch();
}}
/>
);
}
export default DashboardPageV2;

View File

@@ -1,3 +1,8 @@
import DashboardPageV2 from './DashboardPageV2';
function DashboardPageV2(): JSX.Element {
return (
<div>
<h1>Dashboard Page V2</h1>
</div>
);
}
export default DashboardPageV2;

View File

@@ -1,35 +0,0 @@
.page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
gap: 8px;
height: 48px;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
}
.icon {
color: var(--l2-foreground);
}
.text {
color: var(--muted-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}

View File

@@ -1,41 +0,0 @@
import { useState } from 'react';
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
import { Typography } from '@signozhq/ui/typography';
import { LayoutGrid } from '@signozhq/icons';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardsList from './components/DashboardsList';
import styles from './DashboardsListPageV2.module.scss';
function DashboardsListPageV2(): JSX.Element {
const [showBanner, setShowBanner] = useState(true);
return (
<div className={styles.page}>
{showBanner && (
<AnnouncementBanner
type="warning"
onClose={(): void => setShowBanner(false)}
>
You&apos;re on the V2 dashboards page. If you landed here unintentionally,
please reach out to Ashwin.
</AnnouncementBanner>
)}
<div className={styles.header}>
<div className={styles.headerLeft}>
<LayoutGrid size={14} className={styles.icon} />
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
</div>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
<DashboardsList />
</div>
);
}
export default DashboardsListPageV2;

View File

@@ -1,44 +0,0 @@
.content {
display: flex;
flex-direction: column;
}
.actionButton {
display: flex;
width: 100%;
justify-content: flex-start;
gap: 8px;
padding: 8px 12px;
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
border-radius: 0;
}
.actionButton:hover {
background: color-mix(in srgb, var(--card) 60%, transparent);
}
.triggerIcon {
margin-left: 16px;
flex: 0 0 auto;
cursor: pointer;
}
:global(.dashboardActionsPopover) {
:global(.ant-popover-inner) {
width: 187px;
height: auto;
flex-shrink: 0;
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%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0px;
}
}

View File

@@ -1,96 +0,0 @@
import { Button, Popover } from 'antd';
import {
Expand,
EllipsisVertical,
Link2,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import styles from './ActionsPopover.module.scss';
interface Props {
link: string;
dashboardId: string;
dashboardName: string;
createdBy: string;
isLocked: boolean;
onView: (event: React.MouseEvent<HTMLElement>) => void;
}
function ActionsPopover({
link,
dashboardId,
dashboardName,
createdBy,
isLocked,
onView,
}: Props): JSX.Element {
const [, setCopy] = useCopyToClipboard();
return (
<Popover
content={
<div className={styles.content}>
<Button
type="text"
className={styles.actionButton}
icon={<Expand size={12} />}
onClick={onView}
>
View
</Button>
<Button
type="text"
className={styles.actionButton}
icon={<SquareArrowOutUpRight size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(link);
}}
>
Open in New Tab
</Button>
<Button
type="text"
className={styles.actionButton}
icon={<Link2 size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(link));
}}
>
Copy Link
</Button>
<DeleteButton
name={dashboardName}
id={dashboardId}
isLocked={isLocked}
createdBy={createdBy}
/>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboardActionsPopover"
trigger="click"
>
<EllipsisVertical
className={styles.triggerIcon}
size={14}
data-testid="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
/>
</Popover>
);
}
export default ActionsPopover;

View File

@@ -1,160 +0,0 @@
.content {
display: flex;
flex-direction: column;
gap: 14px;
}
.preview {
display: flex;
padding: 12px 14.634px;
flex-direction: column;
align-items: flex-start;
gap: 7.317px;
border-radius: 4px;
border: 0.915px solid var(--l1-border);
background: var(--l2-background);
}
.previewHeader {
display: flex;
gap: 4px;
align-items: center;
}
.previewIcon {
height: 14px;
width: 14px;
}
.previewTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 12.805px;
font-style: normal;
font-weight: 500;
line-height: 18.293px;
letter-spacing: -0.064px;
}
.previewDetails {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.previewRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.formattedTime {
display: flex;
gap: 4px;
align-items: center;
color: var(--l2-foreground);
font-family: Inter;
font-size: 12.805px;
font-style: normal;
font-weight: 400;
line-height: 16.463px;
letter-spacing: -0.064px;
}
.user {
display: flex;
align-items: center;
gap: 4px;
}
.userTag {
width: 12px;
height: 12px;
display: flex;
justify-content: center;
align-items: center;
color: var(--l2-foreground);
font-size: 8px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: normal;
letter-spacing: -0.05px;
border-radius: 12.805px;
background-color: var(--l1-background);
}
.userLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12.805px;
font-weight: 400;
line-height: 16.463px;
letter-spacing: -0.064px;
}
.action {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0px 0px 0px 14.634px;
}
.actionLeft {
display: flex;
gap: 6px;
align-items: center;
}
.connectionLine {
border-top: 1px dashed var(--l1-border);
min-width: 20px;
flex-grow: 1;
margin: 0px 8px;
}
.actionRight {
display: flex;
align-items: center;
}
.saveChanges {
display: flex;
width: 100%;
height: 32px;
padding: 8px 16px;
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
}
:global(.configureMetadataModalRoot) {
:global(.ant-modal-content) {
width: 500px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--card);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
}
:global(.ant-modal-header) {
background: var(--card);
padding: 16px;
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
}
:global(.ant-modal-body) {
padding: 14px 16px;
}
:global(.ant-modal-footer) {
margin-top: 0px;
padding: 4px 16px 16px 16px;
}
}

View File

@@ -1,244 +0,0 @@
import { useEffect, useState } from 'react';
import { Button, Modal, Switch } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CalendarClock, Check, Clock4 } from '@signozhq/icons';
import dayjs from 'dayjs';
import { get, isEmpty } from 'lodash-es';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
import type { DashboardListItem } from '../../utils';
import {
DynamicColumns,
useDashboardsListVisibleColumnsStore,
type DashboardDynamicColumns,
} from './useDynamicColumns';
import styles from './ConfigureMetadataModal.module.scss';
interface Props {
open: boolean;
previewDashboard: DashboardListItem | undefined;
onClose: () => void;
}
function lastUpdatedLabel(time: string | undefined): string {
if (!time || isEmpty(time)) {
return 'No updates yet!';
}
const diff = dayjs();
const ref = dayjs(time);
const months = diff.diff(ref, 'months');
if (months > 0) {
return `Last Updated ${months} months ago`;
}
const days = diff.diff(ref, 'days');
if (days > 0) {
return `Last Updated ${days} days ago`;
}
const hours = diff.diff(ref, 'hours');
if (hours > 0) {
return `Last Updated ${hours} hrs ago`;
}
const minutes = diff.diff(ref, 'minutes');
if (minutes > 0) {
return `Last Updated ${minutes} mins ago`;
}
const seconds = diff.diff(ref, 'seconds');
return `Last Updated ${seconds} sec ago`;
}
function ConfigureMetadataModal({
open,
previewDashboard,
onClose,
}: Props): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const storedColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
);
const setStoredColumns = useDashboardsListVisibleColumnsStore(
(s) => s.setVisibleColumns,
);
const [draftColumns, setDraftColumns] =
useState<DashboardDynamicColumns>(storedColumns);
useEffect(() => {
if (open) {
setDraftColumns(storedColumns);
}
}, [open, storedColumns]);
const handleSave = (): void => {
setStoredColumns(draftColumns);
onClose();
};
const previewImage = previewDashboard?.data?.metadata?.image || Base64Icons[0];
const previewName = previewDashboard?.data?.spec?.display?.name;
const previewCreatedBy = previewDashboard?.createdBy;
const previewUpdatedBy = previewDashboard?.updatedBy;
const previewUpdatedAt = previewDashboard?.updatedAt;
const formattedCreatedAt = previewDashboard
? formatTimezoneAdjustedTimestamp(
get(previewDashboard, 'createdAt', '') as string,
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
)
: '';
return (
<Modal
open={open}
onCancel={onClose}
title="Configure Metadata"
footer={
<Button
type="text"
icon={<Check size={14} />}
className={styles.saveChanges}
onClick={handleSave}
>
Save Changes
</Button>
}
rootClassName="configureMetadataModalRoot"
>
<div className={styles.content}>
<div className={styles.preview}>
<section className={styles.previewHeader}>
<img
src={previewImage}
alt="dashboard-image"
className={styles.previewIcon}
/>
<Typography.Text className={styles.previewTitle}>
{previewName}
</Typography.Text>
</section>
<section className={styles.previewDetails}>
<section className={styles.previewRow}>
{draftColumns.createdAt && (
<Typography.Text className={styles.formattedTime}>
<CalendarClock size={14} />
{formattedCreatedAt}
</Typography.Text>
)}
{draftColumns.createdBy && (
<div className={styles.user}>
<Typography.Text className={styles.userTag}>
{previewCreatedBy?.substring(0, 1).toUpperCase()}
</Typography.Text>
<Typography.Text className={styles.userLabel}>
{previewCreatedBy}
</Typography.Text>
</div>
)}
</section>
<section className={styles.previewRow}>
{draftColumns.updatedAt && (
<Typography.Text className={styles.formattedTime}>
<CalendarClock size={14} />
{lastUpdatedLabel(previewUpdatedAt)}
</Typography.Text>
)}
{draftColumns.updatedBy && (
<div className={styles.user}>
<Typography.Text className={styles.userTag}>
{previewUpdatedBy?.substring(0, 1).toUpperCase()}
</Typography.Text>
<Typography.Text className={styles.userLabel}>
{previewUpdatedBy}
</Typography.Text>
</div>
)}
</section>
</section>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<CalendarClock size={14} />
<Typography.Text>Created at</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
size="small"
checked
disabled
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.CREATED_AT]: check,
}))
}
/>
</div>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<CalendarClock size={14} />
<Typography.Text>Created by</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
size="small"
checked
disabled
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.CREATED_BY]: check,
}))
}
/>
</div>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<Clock4 size={14} />
<Typography.Text>Updated at</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
size="small"
checked={draftColumns.updatedAt}
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.UPDATED_AT]: check,
}))
}
/>
</div>
</div>
<div className={styles.action}>
<div className={styles.actionLeft}>
<Clock4 size={14} />
<Typography.Text>Updated by</Typography.Text>
</div>
<div className={styles.connectionLine} />
<div className={styles.actionRight}>
<Switch
size="small"
checked={draftColumns.updatedBy}
onChange={(check): void =>
setDraftColumns((prev) => ({
...prev,
[DynamicColumns.UPDATED_BY]: check,
}))
}
/>
</div>
</div>
</div>
</Modal>
);
}
export default ConfigureMetadataModal;

View File

@@ -1,52 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
export interface DashboardDynamicColumns {
createdAt: boolean;
createdBy: boolean;
updatedAt: boolean;
updatedBy: boolean;
}
export enum DynamicColumns {
CREATED_AT = 'createdAt',
CREATED_BY = 'createdBy',
UPDATED_AT = 'updatedAt',
UPDATED_BY = 'updatedBy',
}
const DEFAULT_COLUMNS: DashboardDynamicColumns = {
createdAt: true,
createdBy: true,
updatedAt: false,
updatedBy: false,
};
interface DashboardsListVisibleColumnsState {
visibleColumns: DashboardDynamicColumns;
setVisibleColumns: (next: DashboardDynamicColumns) => void;
}
export const useDashboardsListVisibleColumnsStore =
create<DashboardsListVisibleColumnsState>()(
persist(
(set) => ({
visibleColumns: DEFAULT_COLUMNS,
setVisibleColumns: (next): void => {
set({ visibleColumns: next });
},
}),
{
name: LOCALSTORAGE.DASHBOARDS_LIST_VISIBLE_COLUMNS,
merge: (persisted, current) => ({
...current,
visibleColumns: {
...DEFAULT_COLUMNS,
...((persisted as Partial<DashboardsListVisibleColumnsState>)
?.visibleColumns ?? {}),
},
}),
},
),
);

View File

@@ -1,34 +0,0 @@
.menuItem {
display: flex;
align-items: center;
gap: 8px;
}
.templatesItem {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
width: 100%;
}
.primaryButton {
padding: 5.937px 11.875px;
}
.textButton {
display: flex;
width: 153px;
align-items: center;
height: 32px;
padding: 5.937px 11.875px;
justify-content: center;
gap: 5.937px;
border-radius: 1.484px;
background: var(--primary-background);
color: var(--l1-foreground);
}
:global(.createDashboardMenuOverlay) {
width: 200px;
}

View File

@@ -1,118 +0,0 @@
import { useMemo } from 'react';
import { Button, Dropdown, MenuProps } from 'antd';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import {
ExternalLink,
Github,
LayoutGrid,
Plus,
Radius,
} from '@signozhq/icons';
import styles from './CreateDashboardDropdown.module.scss';
interface Props {
canCreate: boolean;
onCreate: () => void;
onImportJSON: () => void;
variant?: 'primary' | 'text';
}
const TEMPLATES_HREF =
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
function CreateDashboardDropdown({
canCreate,
onCreate,
onImportJSON,
variant = 'primary',
}: Props): JSX.Element {
const items: MenuProps['items'] = useMemo(() => {
const menuItems: MenuProps['items'] = [
{
key: 'import-json',
label: (
<div
className={styles.menuItem}
data-testid="import-json-menu-cta"
onClick={onImportJSON}
>
<Radius size={14} /> Import JSON
</div>
),
},
{
key: 'view-templates',
label: (
<a
href={TEMPLATES_HREF}
target="_blank"
rel="noopener noreferrer"
data-testid="view-templates-menu-cta"
>
<div className={styles.templatesItem}>
<div className={styles.menuItem}>
<Github size={14} /> View templates
</div>
<ExternalLink size={14} />
</div>
</a>
),
},
];
if (canCreate) {
menuItems.unshift({
key: 'create-dashboard',
label: (
<div
className={styles.menuItem}
data-testid="create-dashboard-menu-cta"
onClick={onCreate}
>
<LayoutGrid size={14} /> Create dashboard
</div>
),
});
}
return menuItems;
}, [canCreate, onCreate, onImportJSON]);
return (
<Dropdown
overlayClassName="createDashboardMenuOverlay"
menu={{ items }}
placement="bottomRight"
trigger={['click']}
>
{variant === 'primary' ? (
<Button
type="primary"
className={cx('periscope-btn primary', styles.primaryButton)}
icon={<Plus size={14} />}
data-testid="new-dashboard-cta"
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
New dashboard
</Button>
) : (
<Button
type="text"
className={styles.textButton}
icon={<Plus size={14} />}
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
New Dashboard
</Button>
)}
</Dropdown>
);
}
export default CreateDashboardDropdown;

View File

@@ -1,152 +0,0 @@
.row {
padding: 12px 16px 16px 16px;
border: 1px solid var(--l1-border);
border-top: none;
background: var(--l2-background);
cursor: pointer;
}
.titleWithAction {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
min-height: 24px;
}
.titleBlock {
display: flex;
align-items: center;
gap: 6px;
line-height: 20px;
flex: 1 1 auto;
min-width: 0;
}
.titleLink {
display: flex;
align-items: center;
gap: 8px;
}
.icon {
display: inline-block;
line-height: 20px;
height: 14px;
width: 14px;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 20px;
letter-spacing: -0.07px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tagsWithActions {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
justify-content: flex-end;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
height: 28px;
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: 400;
line-height: 20px;
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
.details {
margin-top: 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 24px;
}
.createdAt {
display: flex;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.createdBy {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 14px;
height: 14px;
border-radius: 50px;
background: var(--l1-border);
display: flex;
justify-content: center;
align-items: center;
}
.avatarText {
color: var(--l2-foreground);
font-size: 8px;
font-weight: var(--font-weight-normal);
line-height: normal;
letter-spacing: -0.05px;
}
.byLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.updatedBy {
display: flex;
align-items: center;
gap: 6px;
}
:global(.titleTooltipOverlay) {
:global(.ant-tooltip-content) :global(.ant-tooltip-inner) {
max-height: 400px;
overflow: auto;
}
}

View File

@@ -1,181 +0,0 @@
import { Tag, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CalendarClock } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTimezone } from 'providers/Timezone';
import { isModifierKeyPressed } from 'utils/app';
import type { DashboardListItem } from '../../utils';
import { tagsToStrings } from '../../utils';
import ActionsPopover from '../ActionsPopover/ActionsPopover';
import styles from './DashboardRow.module.scss';
interface Props {
dashboard: DashboardListItem;
index: number;
canAct: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
}
function lastUpdatedLabel(time: string | undefined): string {
if (!time || isEmpty(time)) {
return 'No updates yet!';
}
const diff = dayjs();
const ref = dayjs(time);
const months = diff.diff(ref, 'months');
if (months > 0) {
return `Last Updated ${months} months ago`;
}
const days = diff.diff(ref, 'days');
if (days > 0) {
return `Last Updated ${days} days ago`;
}
const hours = diff.diff(ref, 'hours');
if (hours > 0) {
return `Last Updated ${hours} hrs ago`;
}
const minutes = diff.diff(ref, 'minutes');
if (minutes > 0) {
return `Last Updated ${minutes} mins ago`;
}
const seconds = diff.diff(ref, 'seconds');
return `Last Updated ${seconds} sec ago`;
}
function DashboardRow({
dashboard,
index,
canAct,
showUpdatedAt,
showUpdatedBy,
}: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const id = dashboard.id;
const name = dashboard.data?.spec?.display?.name ?? '';
const image = dashboard.data?.metadata?.image || Base64Icons[0];
const createdBy = dashboard.createdBy ?? '';
const updatedBy = dashboard.updatedBy ?? '';
const createdAt = dashboard.createdAt ?? '';
const updatedAt = dashboard.updatedAt ?? '';
const isLocked = !!dashboard.locked;
const tags = tagsToStrings(dashboard.data?.metadata?.tags);
const link = generatePath(ROUTES.DASHBOARD, { dashboardId: id });
const formattedCreatedAt = formatTimezoneAdjustedTimestamp(
createdAt,
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
);
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
event.stopPropagation();
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
logEvent('Dashboard List: Clicked on dashboard', {
dashboardId: id,
dashboardName: name,
});
};
return (
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
<Tooltip
title={name.length > 50 ? name : ''}
placement="left"
overlayClassName="titleTooltipOverlay"
>
<div className={styles.titleLink} onClick={onClickHandler}>
<img src={image} alt="dashboard-image" className={styles.icon} />
<Typography.Text
data-testid={`dashboard-title-${index}`}
className={styles.title}
>
{name}
</Typography.Text>
</div>
</Tooltip>
</div>
<div className={styles.tagsWithActions}>
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<Tag className={styles.tag} key={tag}>
{tag}
</Tag>
))}
{tags.length > 3 && (
<Tag className={styles.tag} key={tags[3]}>
+ <span> {tags.length - 3} </span>
</Tag>
)}
</div>
)}
</div>
{canAct && (
<ActionsPopover
link={link}
dashboardId={id}
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
onView={onClickHandler}
/>
)}
</div>
<div className={styles.details}>
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{formattedCreatedAt}</Typography.Text>
</div>
{createdBy && (
<div className={styles.createdBy}>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{createdBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{createdBy}</Typography.Text>
</div>
)}
{showUpdatedAt && (
<div className={styles.createdAt}>
<CalendarClock size={14} />
<Typography.Text>{lastUpdatedLabel(updatedAt)}</Typography.Text>
</div>
)}
{updatedBy && showUpdatedBy && (
<div className={styles.updatedBy}>
<Typography.Text className={styles.byLabel}>
Last Updated By -
</Typography.Text>
<div className={styles.avatar}>
<Typography.Text className={styles.avatarText}>
{updatedBy.substring(0, 1).toUpperCase()}
</Typography.Text>
</div>
<Typography.Text className={styles.byLabel}>{updatedBy}</Typography.Text>
</div>
)}
</div>
</div>
);
}
export default DashboardRow;

View File

@@ -1,96 +0,0 @@
.container {
margin-top: 30px;
margin-bottom: 30px;
display: flex;
justify-content: center;
width: 100%;
}
.viewContent {
width: calc(100% - 30px);
max-width: 836px;
:global(.ant-table-wrapper) :global(.ant-table-cell) {
padding: 0 !important;
border: none !important;
background: var(--l1-background) !important;
}
:global(.ant-table-wrapper)
:global(.ant-table-tbody)
:global(.ant-table-row)
:global(.ant-table-cell)
> div {
// Row content is the only child of the td; it carries the borders.
}
:global(.ant-table-wrapper)
:global(.ant-table-tbody)
:global(.ant-table-row:last-child)
:global(.ant-table-cell)
> div {
border-radius: 0 0 6px 6px;
}
:global(.ant-pagination-item) {
display: flex;
justify-content: center;
align-items: center;
}
:global(.ant-pagination-item) > a {
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
line-height: 20px;
}
:global(.ant-pagination-item-active) {
background-color: var(--primary-background);
}
:global(.ant-pagination-item-active) > a {
color: var(--foreground) !important;
font-weight: var(--font-weight-medium);
}
}
.titleContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px;
letter-spacing: -0.09px;
}
.subtitle {
color: var(--l2-foreground);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.integrationsContainer {
margin: 16px 0;
}
.integrationsContent {
max-width: 100%;
width: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0;
}

View File

@@ -1,256 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { generatePath } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import logEvent from 'api/common/logEvent';
import {
createDashboardV2,
useListDashboardsV2,
} from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
usePage,
useSearch,
useSortColumn,
useSortOrder,
type SortColumn,
type SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import { buildSearchDSL, type DashboardListItem } from '../../utils';
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
import { useDashboardsListVisibleColumnsStore } from '../ConfigureMetadataModal/useDynamicColumns';
import CreateDashboardDropdown from '../CreateDashboardDropdown/CreateDashboardDropdown';
import EmptyState from '../EmptyState/EmptyState';
import ErrorState from '../ErrorState/ErrorState';
import ImportJSONModal from '../ImportJSONModal/ImportJSONModal';
import ListHeader from '../ListHeader/ListHeader';
import LoadingState from '../LoadingState/LoadingState';
import NoResultsState from '../NoResultsState/NoResultsState';
import SearchBar from '../SearchBar/SearchBar';
import DashboardsListContent from './DashboardsListContent';
import styles from './DashboardsList.module.scss';
const PAGE_SIZE = 20;
function DashboardsList(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation('dashboard');
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const { isCloudUser } = useGetTenantLicense();
const { user } = useAppContext();
const [action, canCreateNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
user.role,
);
const [searchString, setSearchString] = useSearch();
const [sortColumn, setSortColumn] = useSortColumn();
const [sortOrder, setSortOrder] = useSortOrder();
const [page, setPage] = usePage();
const [searchInput, setSearchInput] = useState(searchString);
const debouncedSearch = useDebounce(searchInput, 300);
useEffect(() => {
if (debouncedSearch === searchString) {
return;
}
setSearchString(debouncedSearch);
setPage(1);
}, [debouncedSearch, searchString, setSearchString, setPage]);
const listParams = useMemo(
() => ({
query: buildSearchDSL(searchString),
sort: sortColumn,
order: sortOrder,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),
[searchString, sortColumn, sortOrder, page],
);
const {
data: response,
isLoading,
isFetching,
error,
refetch,
} = useListDashboardsV2(listParams, { query: { keepPreviousData: true } });
const dashboards = useMemo<DashboardListItem[]>(
() => response?.data?.dashboards ?? [],
[response],
);
const total = response?.data?.total ?? 0;
const [isImportOpen, setIsImportOpen] = useState(false);
const [isConfigureOpen, setIsConfigureOpen] = useState(false);
const visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
);
const [creating, setCreating] = useState(false);
const handleCreateNew = useCallback(async (): Promise<void> => {
try {
logEvent('Dashboard List: Create dashboard clicked', {});
setCreating(true);
const created = await createDashboardV2({
metadata: { schemaVersion: 'v6' },
spec: {
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
},
});
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
);
} catch (e) {
showErrorModal(e as APIError);
notifications.error({
message: (e as AxiosError).toString() || 'Failed to create dashboard',
});
} finally {
setCreating(false);
}
}, [safeNavigate, showErrorModal, notifications, t]);
const handleImportToggle = useCallback((): void => {
logEvent('Dashboard List V2: Import JSON clicked', {});
setIsImportOpen((s) => !s);
}, []);
const onSortChange = useCallback(
(column: SortColumn): void => {
setSortColumn(column);
setPage(1);
},
[setSortColumn, setPage],
);
const onOrderChange = useCallback(
(order: SortOrder): void => {
setSortOrder(order);
setPage(1);
},
[setSortOrder, setPage],
);
const visitLoggedRef = useRef(false);
useEffect(() => {
if (!visitLoggedRef.current && !isLoading && response !== undefined) {
logEvent('Dashboard List V2: Page visited', { number: dashboards.length });
visitLoggedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
return (
<div className={styles.container}>
<div className={styles.viewContent}>
<div className={styles.titleContainer}>
<Typography.Title className={styles.title}>Dashboards</Typography.Title>
<Typography.Text className={styles.subtitle}>
Create and manage dashboards for your workspace.
</Typography.Text>
{isCloudUser && (
<div className={styles.integrationsContainer}>
<div className={styles.integrationsContent}>
<RequestDashboardBtn />
</div>
</div>
)}
</div>
{isLoading ? (
<LoadingState />
) : error ? (
<ErrorState
isCloudUser={!!isCloudUser}
onRetry={(): void => {
refetch();
}}
/>
) : dashboards.length === 0 && !searchString && page === 1 ? (
<EmptyState
createDropdown={
canCreateNewDashboard ? (
<CreateDashboardDropdown
canCreate={!!canCreateNewDashboard}
onCreate={handleCreateNew}
onImportJSON={handleImportToggle}
variant="text"
/>
) : null
}
/>
) : (
<>
<div className={styles.toolbar}>
<SearchBar value={searchInput} onChange={setSearchInput} />
{canCreateNewDashboard && (
<CreateDashboardDropdown
canCreate={!!canCreateNewDashboard}
onCreate={handleCreateNew}
onImportJSON={handleImportToggle}
/>
)}
</div>
{dashboards.length === 0 ? (
<NoResultsState searchString={searchInput} />
) : (
<>
<ListHeader
sortColumn={sortColumn}
onSortChange={onSortChange}
sortOrder={sortOrder}
onOrderChange={onOrderChange}
onConfigureMetadata={(): void => setIsConfigureOpen(true)}
/>
<DashboardsListContent
dashboards={dashboards}
page={page}
pageSize={PAGE_SIZE}
total={total}
onPageChange={setPage}
canAct={!!action}
showUpdatedAt={visibleColumns.updatedAt}
showUpdatedBy={visibleColumns.updatedBy}
loading={creating || isFetching}
/>
</>
)}
</>
)}
<ImportJSONModal
open={isImportOpen}
onClose={(): void => setIsImportOpen(false)}
/>
<ConfigureMetadataModal
open={isConfigureOpen}
previewDashboard={dashboards[0]}
onClose={(): void => setIsConfigureOpen(false)}
/>
</div>
</div>
);
}
export default DashboardsList;

View File

@@ -1,71 +0,0 @@
import { useMemo } from 'react';
import { Table } from 'antd';
import type { TableProps } from 'antd/lib';
import type { DashboardListItem } from '../../utils';
import DashboardRow from '../DashboardRow/DashboardRow';
interface Props {
dashboards: DashboardListItem[];
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
canAct: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
loading: boolean;
}
function DashboardsListContent({
dashboards,
page,
pageSize,
total,
onPageChange,
canAct,
showUpdatedAt,
showUpdatedBy,
loading,
}: Props): JSX.Element {
const columns: TableProps<DashboardListItem>['columns'] = useMemo(
() => [
{
title: 'Dashboards',
key: 'dashboard',
render: (_, dashboard, index): JSX.Element => (
<DashboardRow
dashboard={dashboard}
index={index}
canAct={canAct}
showUpdatedAt={showUpdatedAt}
showUpdatedBy={showUpdatedBy}
/>
),
},
],
[canAct, showUpdatedAt, showUpdatedBy],
);
const paginationConfig = total > pageSize && {
pageSize,
showSizeChanger: false,
onChange: onPageChange,
current: page,
total,
hideOnSinglePage: true,
};
return (
<Table
columns={columns}
dataSource={dashboards.map((d) => ({ ...d, key: d.id }))}
showSorterTooltip
loading={loading}
showHeader={false}
pagination={paginationConfig}
/>
);
}
export default DashboardsListContent;

View File

@@ -1,3 +0,0 @@
import DashboardsList from './DashboardsList';
export default DashboardsList;

View File

@@ -1,64 +0,0 @@
.wrapper {
display: flex;
flex-direction: column;
height: 320px;
padding: 105px 141px;
margin-top: 16px;
justify-content: center;
align-items: flex-start;
border-radius: 6px;
border: 1px dashed var(--l1-border);
}
.image {
width: 32px;
height: 32px;
}
.copy {
margin-top: 4px;
}
.noDashboard {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: -0.07px;
}
.info {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.actions {
display: flex;
gap: 24px;
align-items: center;
margin-top: 24px;
}
.learnMore {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: -0.07px;
padding: 0px;
}
.learnMoreArrow {
margin-left: -20px;
color: var(--bg-robin-400);
cursor: pointer;
}

View File

@@ -1,53 +0,0 @@
import { ReactNode } from 'react';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
import styles from './EmptyState.module.scss';
import { openInNewTab } from 'utils/navigation';
interface Props {
createDropdown?: ReactNode;
}
const LEARN_MORE_HREF =
'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state';
function EmptyState({ createDropdown }: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<img src={dashboardsUrl} alt="dashboards" className={styles.image} />
<section className={styles.copy}>
<Typography.Text className={styles.noDashboard}>
No dashboards yet.{' '}
</Typography.Text>
<Typography.Text className={styles.info}>
Create a dashboard to start visualizing your data
</Typography.Text>
</section>
{createDropdown ? (
<section className={styles.actions}>
{createDropdown}
<Button
type="text"
className={styles.learnMore}
data-testid="learn-more"
onClick={(): void => {
logEvent('Dashboard List: Learn more clicked', {});
openInNewTab(LEARN_MORE_HREF);
}}
>
Learn more
</Button>
<ArrowUpRight size={16} className={styles.learnMoreArrow} />
</section>
) : null}
</div>
);
}
export default EmptyState;

View File

@@ -1,62 +0,0 @@
.wrapper {
display: flex;
flex-direction: column;
height: 320px;
padding: 105px 141px;
margin-top: 16px;
justify-content: center;
align-items: flex-start;
border-radius: 6px;
border: 1px dashed var(--l1-border);
gap: 4px;
}
.img {
max-width: 100%;
}
.errorText {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: -0.07px;
}
.actionButtons {
display: flex;
gap: 24px;
align-items: center;
margin-top: 20px;
}
.retryButton {
display: flex;
align-items: center;
height: 32px;
padding: 5.937px 11.875px;
justify-content: center;
gap: 5.937px;
border-radius: 1.484px;
background: var(--l1-border);
color: var(--l1-foreground);
}
.learnMore {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: -0.07px;
padding: 0px;
}
.learnMoreArrow {
margin-left: -20px;
color: var(--bg-robin-400);
cursor: pointer;
}

View File

@@ -1,44 +0,0 @@
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight, RotateCw } from '@signozhq/icons';
import { handleContactSupport } from 'container/Integrations/utils';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import styles from './ErrorState.module.scss';
interface Props {
isCloudUser: boolean;
onRetry: () => void;
}
function ErrorState({ isCloudUser, onRetry }: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<img src={awwSnapUrl} alt="something went wrong" className={styles.img} />
<Typography.Text className={styles.errorText}>
Something went wrong :/ Please retry or contact support.
</Typography.Text>
<section className={styles.actionButtons}>
<Button
className={styles.retryButton}
type="text"
icon={<RotateCw size={16} />}
onClick={onRetry}
>
Retry
</Button>
<Button
type="text"
className={styles.learnMore}
onClick={(): void => handleContactSupport(isCloudUser)}
>
Contact Support
</Button>
<ArrowUpRight size={16} className={styles.learnMoreArrow} />
</section>
</div>
);
}
export default ErrorState;

View File

@@ -1,73 +0,0 @@
.contentContainer {
display: flex;
flex-direction: column;
}
.contentHeader {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--l1-border);
}
.footer {
display: flex;
flex-direction: column;
gap: 8px;
}
.jsonError {
display: flex;
align-items: center;
gap: 8px;
}
.errorText {
color: var(--warning-background);
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
:global(.importJsonModalWrapper) {
:global(.ant-modal-content) {
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%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0;
}
:global(.margin) {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
backdrop-filter: blur(20px);
}
:global(.view-lines) {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
backdrop-filter: blur(20px);
}
:global(.ant-modal-footer) {
margin-top: 0;
padding: 16px;
border-top: 1px solid var(--l1-border);
}
}

View File

@@ -1,224 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { red } from '@ant-design/colors';
import MEditor, { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Button, Flex, Modal, Upload, UploadProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
CircleAlert,
ExternalLink,
Github,
MonitorDot,
MoveRight,
Sparkles,
} from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import sampleDashboard from './sampleDashboard.json';
import styles from './ImportJSONModal.module.scss';
import { normalizeToPostable } from './ImportJSONModalUtils';
interface Props {
open: boolean;
onClose: () => void;
}
function ImportJSONModal({ open, onClose }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { t } = useTranslation(['dashboard', 'common']);
const [isUploadError, setIsUploadError] = useState(false);
const [isCreateError, setIsCreateError] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [editorValue, setEditorValue] = useState('');
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const isDarkMode = useIsDarkMode();
const handleUpload: UploadProps['onChange'] = (info) => {
const lastFile = info.fileList[info.fileList.length - 1];
if (!lastFile?.originFileObj) {
return;
}
const reader = new FileReader();
reader.onload = (event): void => {
try {
const target = event.target?.result;
if (!target) {
return;
}
const parsed = JSON.parse(target.toString());
setEditorValue(JSON.stringify(parsed, null, 2));
setIsUploadError(false);
} catch {
setIsUploadError(true);
}
};
reader.readAsText(lastFile.originFileObj);
};
const handleImport = async (): Promise<void> => {
try {
setIsCreating(true);
logEvent('Dashboard List V2: Import and next clicked', {});
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
const payload = normalizeToPostable(parsed);
const response = await createDashboardV2(payload);
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);
logEvent('Dashboard List V2: New dashboard imported successfully', {
dashboardId: response.data?.id,
});
} catch (error) {
showErrorModal(error as APIError);
setIsCreateError(true);
notifications.error({
message: error instanceof Error ? error.message : t('error_loading_json'),
});
} finally {
setIsCreating(false);
}
};
const handleClose = (): void => {
setIsUploadError(false);
setIsCreateError(false);
onClose();
};
const setEditorTheme = (monaco: Monaco): void => {
monaco.editor.defineTheme('my-theme', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
],
colors: { 'editor.background': Color.BG_INK_300 },
});
};
const renderError = (msg: string): JSX.Element => (
<div className={styles.jsonError}>
<CircleAlert size="md" color={red[7]} />
<Typography className={styles.errorText}>{msg}</Typography>
</div>
);
return (
<Modal
wrapClassName="importJsonModalWrapper"
open={open}
centered
closable
keyboard
maskClosable
onCancel={handleClose}
destroyOnClose
width="60vw"
footer={
<div className={styles.footer}>
{isCreateError && renderError(t('error_loading_json'))}
{isUploadError && renderError(t('error_upload_json'))}
<div className={styles.actions}>
<Flex gap="small">
<Upload
accept=".json"
showUploadList={false}
multiple={false}
onChange={handleUpload}
beforeUpload={(): boolean => false}
action="none"
>
<Button
type="default"
className="periscope-btn"
icon={<MonitorDot size={14} />}
onClick={(): void => {
logEvent('Dashboard List V2: Upload JSON file clicked', {});
}}
>
{t('upload_json_file')}
</Button>
</Upload>
<Button
type="default"
className="periscope-btn"
icon={<Sparkles size={14} />}
onClick={(): void => {
setEditorValue(JSON.stringify(sampleDashboard, null, 2));
setIsUploadError(false);
logEvent('Dashboard List V2: Load sample clicked', {});
}}
>
Load sample
</Button>
<a
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
target="_blank"
rel="noopener noreferrer"
>
<Button
type="default"
className="periscope-btn"
icon={<Github size={14} />}
>
{t('view_template')}&nbsp;
<ExternalLink size={14} />
</Button>
</a>
</Flex>
<Button
onClick={handleImport}
loading={isCreating}
className="periscope-btn primary"
type="primary"
>
{t('import_and_next')} &nbsp; <MoveRight size={14} />
</Button>
</div>
</div>
}
>
<div className={styles.contentContainer}>
<div className={styles.contentHeader}>
<Typography.Text>{t('import_json')}</Typography.Text>
</div>
<MEditor
language="json"
height="40vh"
onChange={(newValue): void => setEditorValue(newValue || '')}
value={editorValue}
options={{
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 14,
fontFamily: 'Space Mono',
}}
theme={isDarkMode ? 'my-theme' : 'light'}
onMount={(_, monaco): void => {
document.fonts.ready.then(() => {
monaco.editor.remeasureFonts();
});
}}
beforeMount={setEditorTheme}
/>
</div>
</Modal>
);
}
export default ImportJSONModal;

View File

@@ -1,32 +0,0 @@
import {
DashboardtypesDashboardSpecDTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostableDashboardV2MetadataDTO,
} from 'api/generated/services/sigNoz.schemas';
// Accept either a complete PostableDashboardV2 (`{ metadata, spec }`) or a
// bare spec — wrap the latter with default metadata so users can paste either
// of the shapes that exist in the wild (e.g. testdata/perses.json is a bare
// spec).
export function normalizeToPostable(
parsed: Record<string, unknown>,
): DashboardtypesPostableDashboardV2DTO {
const hasMetadata = 'metadata' in parsed;
const hasSpec = 'spec' in parsed;
if (hasMetadata && hasSpec) {
const meta = parsed.metadata as DashboardtypesPostableDashboardV2MetadataDTO;
const metadata: DashboardtypesPostableDashboardV2MetadataDTO = {
...meta,
schemaVersion: meta?.schemaVersion || 'v6',
};
return {
metadata,
spec: parsed.spec as DashboardtypesDashboardSpecDTO,
};
}
return {
metadata: { schemaVersion: 'v6' },
spec: parsed as unknown as DashboardtypesDashboardSpecDTO,
};
}

View File

@@ -1,154 +0,0 @@
{
"display": {
"name": "NV dashboard with sections",
"description": ""
},
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"panels": {
"b424e23b": {
"kind": "Panel",
"spec": {
"display": {
"name": ""
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"formatting": {
"unit": "s",
"decimalPrecision": "2"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "container.cpu.time",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": ""
}
}
}
}
}
]
}
},
"251df4d5": {
"kind": "Panel",
"spec": {
"display": {
"name": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": false
},
"formatting": {
"unit": "recommendations",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {"fillOnlyBelow": true}
},
"legend": {
"position": "bottom"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "app_recommendations_counter",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": ""
}
}
}
}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {
"title": "Bravo"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/b424e23b"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Alpha"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/251df4d5"
}
}
]
}
}
]
}

View File

@@ -1,129 +0,0 @@
.wrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
height: 44px;
flex-shrink: 0;
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.rightActions {
display: flex;
gap: 12px;
color: white;
cursor: pointer;
}
.sortContent {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 140px;
}
.sortHeading {
color: var(--l3-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 12px 18px 6px 14px;
}
.sortDivider {
width: 100%;
height: 1px;
background: var(--l1-border);
margin: 4px 0;
}
.sortButton {
text-align: start;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
padding: 12px 18px 12px 14px;
height: auto;
}
.configureContent {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.configureButton {
display: flex;
text-align: start;
align-items: center;
width: 100%;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
padding: 12px;
}
:global(.sortDashboardsPopover) {
:global(.ant-popover-inner) {
display: flex;
padding: 0px;
align-items: center;
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%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
gap: 16px;
}
}
:global(.configureGroupPopover) {
:global(.ant-popover-inner) {
display: flex;
align-items: center;
border-radius: 4px;
padding: 0px;
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%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
gap: 16px;
}
}

View File

@@ -1,129 +0,0 @@
import { Button, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
ArrowDownWideNarrow,
Check,
Ellipsis,
HdmiPort,
} from '@signozhq/icons';
import type {
SortColumn,
SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import styles from './ListHeader.module.scss';
interface Props {
sortColumn: SortColumn;
onSortChange: (column: SortColumn) => void;
sortOrder: SortOrder;
onOrderChange: (order: SortOrder) => void;
onConfigureMetadata: () => void;
}
function ListHeader({
sortColumn,
onSortChange,
sortOrder,
onOrderChange,
onConfigureMetadata,
}: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<Typography.Text className={styles.label}>All Dashboards</Typography.Text>
<section className={styles.rightActions}>
<Tooltip title="Sort">
<Popover
trigger="click"
content={
<div className={styles.sortContent}>
<Typography.Text className={styles.sortHeading}>
Sort By
</Typography.Text>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('name')}
data-testid="sort-by-name"
>
Name
{sortColumn === 'name' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('created_at')}
data-testid="sort-by-last-created"
>
Last created
{sortColumn === 'created_at' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange('updated_at')}
data-testid="sort-by-last-updated"
>
Last updated
{sortColumn === 'updated_at' && <Check size={14} />}
</Button>
<div className={styles.sortDivider} />
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange('asc')}
data-testid="sort-order-asc"
>
Ascending
{sortOrder === 'asc' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange('desc')}
data-testid="sort-order-desc"
>
Descending
{sortOrder === 'desc' && <Check size={14} />}
</Button>
</div>
}
rootClassName="sortDashboardsPopover"
placement="bottomRight"
arrow={false}
>
<ArrowDownWideNarrow size={14} data-testid="sort-by" />
</Popover>
</Tooltip>
<Popover
trigger="click"
content={
<div className={styles.configureContent}>
<Button
type="text"
icon={<HdmiPort size={14} />}
className={styles.configureButton}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onConfigureMetadata();
}}
>
Configure metadata
</Button>
</div>
}
rootClassName="configureGroupPopover"
placement="bottomRight"
arrow={false}
>
<Ellipsis size={14} />
</Popover>
</section>
</div>
);
}
export default ListHeader;

View File

@@ -1,11 +0,0 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.skeleton {
height: 125px;
width: 100%;
}

View File

@@ -1,16 +0,0 @@
import { Skeleton } from 'antd';
import styles from './LoadingState.module.scss';
function LoadingState(): JSX.Element {
return (
<div className={styles.wrapper}>
<Skeleton.Input active size="large" className={styles.skeleton} />
<Skeleton.Input active size="large" className={styles.skeleton} />
<Skeleton.Input active size="large" className={styles.skeleton} />
<Skeleton.Input active size="large" className={styles.skeleton} />
</div>
);
}
export default LoadingState;

View File

@@ -1,12 +0,0 @@
.wrapper {
display: flex;
flex-direction: column;
height: 320px;
gap: 8px;
padding: 105px 190px;
margin-top: 16px;
justify-content: center;
align-items: flex-start;
border-radius: 6px;
border: 1px dashed var(--l1-border);
}

View File

@@ -1,22 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './NoResultsState.module.scss';
interface Props {
searchString: string;
}
function NoResultsState({ searchString }: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<img src={emptyStateUrl} alt="img" height={32} width={32} />
<Typography.Text>
No dashboards found for {searchString}. Create a new dashboard?
</Typography.Text>
</div>
);
}
export default NoResultsState;

View File

@@ -1,25 +0,0 @@
import { ChangeEvent } from 'react';
import { Input } from 'antd';
import { Color } from '@signozhq/design-tokens';
import { Search } from '@signozhq/icons';
interface Props {
value: string;
onChange: (value: string) => void;
}
function SearchBar({ value, onChange }: Props): JSX.Element {
return (
<Input
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={value}
data-testid="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
onChange(e.target.value)
}
/>
);
}
export default SearchBar;

View File

@@ -1,36 +0,0 @@
import {
parseAsInteger,
parseAsString,
parseAsStringLiteral,
useQueryState,
type Options,
type UseQueryStateReturn,
} from 'nuqs';
export const SORT_COLUMNS = ['updated_at', 'created_at', 'name'] as const;
export type SortColumn = (typeof SORT_COLUMNS)[number];
export const SORT_ORDERS = ['asc', 'desc'] as const;
export type SortOrder = (typeof SORT_ORDERS)[number];
const opts: Options = { history: 'push' };
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
useQueryState(
'sort',
parseAsStringLiteral(SORT_COLUMNS)
.withDefault('updated_at')
.withOptions(opts),
);
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
useQueryState(
'order',
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
);
export const usePage = (): UseQueryStateReturn<number, number> =>
useQueryState('page', parseAsInteger.withDefault(1).withOptions(opts));
export const useSearch = (): UseQueryStateReturn<string, string> =>
useQueryState('search', parseAsString.withDefault('').withOptions(opts));

View File

@@ -1,3 +1,9 @@
import DashboardsListPageV2 from './DashboardsListPageV2';
function DashboardsListPageV2(): JSX.Element {
return (
<div>
<h1>Dashboards List Page V2</h1>
</div>
);
}
export default DashboardsListPageV2;

View File

@@ -1,24 +0,0 @@
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,
): string[] =>
(tags ?? []).map((tag) =>
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
);
// Build a dashboard list filter DSL query from a free-text search input.
// Matches against name, description, and tags; empty input returns undefined so the
// `query` param is omitted from the API call.
// This is only for the old UX that uses v2. The new UX will actually have DSL search input,
// so it will pass the DSL directly and not use this function.
export const buildSearchDSL = (search: string): string | undefined => {
const trimmed = search.trim();
if (!trimmed) {
return undefined;
}
const escaped = trimmed.replace(/'/g, "''");
return `name CONTAINS '${escaped}' or description CONTAINS '${escaped}' or tags.key CONTAINS '${escaped}' or tags.value CONTAINS '${escaped}'`;
};

View File

@@ -48,9 +48,7 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
"orval.config.ts"
],
"include": [
"./src",

View File

@@ -199,7 +199,16 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return q.executeWindowList(ctx)
}
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return emptyResultFor(q.kind, q.spec.Name), nil
}
}
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
if err != nil {
return nil, err
}
@@ -215,6 +224,81 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window and overlap=false when the trace lies
// completely outside the query window — callers should short-circuit in that case.
//
// When the trace_id is not present in trace_summary the behaviour differs by
// signal:
// - traces: trace_summary is derived from the spans table, so a missing row
// means no spans exist for that trace_id; we short-circuit to empty.
// - logs: logs can carry a trace_id even when traces are not ingested at all
// (e.g. traces disabled). We must not short-circuit; instead leave the
// window untouched and let the query run.
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool) {
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
return fromMS, toMS, true
}
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if !found || len(traceIDs) == 0 {
return fromMS, toMS, true
}
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
if !ok {
if q.spec.Signal == telemetrytypes.SignalTraces {
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, false
}
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, true
}
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if traceStartMS == 0 || traceEndMS == 0 {
return fromMS, toMS, true
}
if traceStartMS > toMS || traceEndMS < fromMS {
return fromMS, toMS, false
}
if traceStartMS > fromMS {
fromMS = traceStartMS
}
if traceEndMS < toMS {
toMS = traceEndMS
}
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
slog.String("signal", q.spec.Signal.StringValue()),
slog.Any("trace_ids", traceIDs),
slog.Uint64("start", fromMS),
slog.Uint64("end", toMS))
return fromMS, toMS, true
}
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries:
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
}
return &qbtypes.Result{
Type: kind,
Value: value,
}
}
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -310,42 +394,22 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes := uint64(0)
start := time.Now()
// Check if filter contains trace_id(s) and optimize time range if needed
if q.spec.Signal == telemetrytypes.SignalTraces &&
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if found && len(traceIDs) > 0 {
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if !ok {
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
} else if traceStartMS > 0 && traceEndMS > 0 {
// no overlap — nothing to return
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
// clamp window to trace time range before bucketing
if uint64(traceStartMS) > fromMS {
fromMS = uint64(traceStartMS)
}
if uint64(traceEndMS) < toMS {
toMS = uint64(traceEndMS)
}
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
}
// Check if filter contains trace_id(s) and optimize time range if needed.
// Applies to both traces (the listing this branch was built for) and logs
// (which carry trace_id and benefit from the same clamp before bucketing).
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
}

View File

@@ -20,6 +20,7 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_logs_list(
@@ -2293,3 +2294,331 @@ def test_logs_formula_orderby_and_limit(
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
def test_logs_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that filtering logs by trace_id uses the trace_summary lookup to
narrow the query window before scanning the logs table:
1. Returns the matching log (narrow window, single bucket).
2. Does not return duplicate logs when the query window should span multiple
exponential buckets (>1 h). But is clamped to the timerange of trace.
3. Returns no results when the query window does not contain the trace.
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
traces disabled) are still returned — the lookup miss must not
short-circuit logs queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
other_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
other_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-filter-service",
"cloud.provider": "integration",
}
# Populate signoz_traces.distributed_trace_summary by inserting spans for
# the target trace_id. trace_summary records min/max of span timestamps
# (it ignores span duration), so two spans are inserted to give the trace
# a non-trivial recorded window of [now-10s, now-5s].
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Insert logs:
# - one with the target trace_id, at a timestamp within the trace's
# recorded window (now-10s..now-5s, padded ±1s).
# - one with a different trace_id; must never appear in target_trace_id
# results.
# - one with an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={"http.method": "GET"},
body="log inside the target trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=3),
resources=common_resources,
attributes={"http.method": "POST"},
body="log with a different trace_id",
severity_text="INFO",
trace_id=other_trace_id,
span_id=other_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "PUT"},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _query(start_ms: int, end_ms: int, trace_id: str) -> list:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
return response.json()["data"]["data"]["results"][0]["rows"] or []
now_ms = int(now.timestamp() * 1000)
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows = _query(narrow_start_ms, now_ms, target_trace_id)
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 2: wide window (>1 h, camp to the timerange from trace_summary) ---
# Should still return exactly one log — no duplicates from multi-bucket scan.
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows = _query(wide_start_ms, now_ms, target_trace_id)
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 3: window that does not contain the trace returns no results ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows = _query(past_start_ms, past_end_ms, target_trace_id)
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
# --- Test 4: trace_id not present in trace_summary still returns logs ---
orphan_rows = _query(narrow_start_ms, now_ms, orphan_trace_id)
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
def test_logs_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) logs queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to an
empty result.
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
returns the matching logs — the lookup miss must not short-circuit
logs aggregation queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-agg-service",
"cloud.provider": "integration",
}
# trace_summary records min/max of span timestamps (it ignores duration),
# so insert two spans to give the trace a recorded window wide enough to
# comfortably contain the log timestamps below.
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Two logs for the target trace_id, both inside the recorded trace window.
# One additional log carries an orphan trace_id with no row in
# trace_summary — used to verify that the lookup miss does not
# short-circuit logs aggregations.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=9),
resources=common_resources,
attributes={},
body="log A inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=6),
resources=common_resources,
attributes={},
body="log B inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id not present in trace_summary still returns logs ---
orphan_count = _count(narrow_start_ms, now_ms, orphan_trace_id)
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"

View File

@@ -2123,3 +2123,115 @@ def test_traces_list_filter_by_trace_id(
past_rows = _query(past_start_ms, past_end_ms)
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
def test_traces_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) traces queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to empty.
3. Filter referencing a trace_id with no row in trace_summary
short-circuits to empty (trace_summary is authoritative for traces).
"""
target_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
missing_trace_id = TraceIdGenerator.trace_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "traces-agg-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id with no entry in trace_summary short-circuits ---
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
missing_count = _count(missing_start_ms, now_ms, missing_trace_id)
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"